From b7b9818855be5375c8ff33c6dd66fcee1fd814f7 Mon Sep 17 00:00:00 2001 From: akira Date: Sat, 4 Apr 2026 11:32:26 +0900 Subject: [PATCH] feat: add levee work records --- backend/apps/levee_work/__init__.py | 1 + backend/apps/levee_work/admin.py | 17 + backend/apps/levee_work/apps.py | 8 + .../levee_work/migrations/0001_initial.py | 54 +++ .../apps/levee_work/migrations/__init__.py | 1 + backend/apps/levee_work/models.py | 59 +++ backend/apps/levee_work/serializers.py | 142 ++++++ backend/apps/levee_work/urls.py | 13 + backend/apps/levee_work/views.py | 70 +++ .../0002_workrecord_levee_work_session.py | 41 ++ backend/apps/workrecords/models.py | 10 +- backend/apps/workrecords/serializers.py | 2 +- backend/apps/workrecords/services.py | 15 + backend/apps/workrecords/views.py | 2 +- backend/keinasystem/settings.py | 1 + backend/keinasystem/urls.py | 1 + frontend/src/app/levee-work/page.tsx | 439 ++++++++++++++++++ frontend/src/app/workrecords/page.tsx | 8 +- frontend/src/components/Navbar.tsx | 13 +- frontend/src/types/index.ts | 38 +- 20 files changed, 929 insertions(+), 6 deletions(-) create mode 100644 backend/apps/levee_work/__init__.py create mode 100644 backend/apps/levee_work/admin.py create mode 100644 backend/apps/levee_work/apps.py create mode 100644 backend/apps/levee_work/migrations/0001_initial.py create mode 100644 backend/apps/levee_work/migrations/__init__.py create mode 100644 backend/apps/levee_work/models.py create mode 100644 backend/apps/levee_work/serializers.py create mode 100644 backend/apps/levee_work/urls.py create mode 100644 backend/apps/levee_work/views.py create mode 100644 backend/apps/workrecords/migrations/0002_workrecord_levee_work_session.py create mode 100644 frontend/src/app/levee-work/page.tsx diff --git a/backend/apps/levee_work/__init__.py b/backend/apps/levee_work/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/levee_work/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/levee_work/admin.py b/backend/apps/levee_work/admin.py new file mode 100644 index 0000000..e18e5b4 --- /dev/null +++ b/backend/apps/levee_work/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import LeveeWorkSession, LeveeWorkSessionItem + + +class LeveeWorkSessionItemInline(admin.TabularInline): + model = LeveeWorkSessionItem + extra = 0 + + +@admin.register(LeveeWorkSession) +class LeveeWorkSessionAdmin(admin.ModelAdmin): + list_display = ['date', 'title', 'year', 'created_at'] + list_filter = ['year', 'date'] + search_fields = ['title', 'items__field__name'] + inlines = [LeveeWorkSessionItemInline] + diff --git a/backend/apps/levee_work/apps.py b/backend/apps/levee_work/apps.py new file mode 100644 index 0000000..6b4c0ad --- /dev/null +++ b/backend/apps/levee_work/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class LeveeWorkConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.levee_work' + verbose_name = '畔塗作業' + diff --git a/backend/apps/levee_work/migrations/0001_initial.py b/backend/apps/levee_work/migrations/0001_initial.py new file mode 100644 index 0000000..83fbc05 --- /dev/null +++ b/backend/apps/levee_work/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2 on 2026-04-04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('fields', '0006_e1c_chusankan_17_fields'), + ('plans', '0004_crop_base_temp'), + ] + + operations = [ + migrations.CreateModel( + name='LeveeWorkSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('year', models.IntegerField(verbose_name='年度')), + ('date', models.DateField(verbose_name='畔塗日')), + ('title', models.CharField(default='水稲畔塗', max_length=100, verbose_name='タイトル')), + ('notes', models.TextField(blank=True, default='', verbose_name='備考')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '畔塗記録', + 'verbose_name_plural': '畔塗記録', + 'ordering': ['-date', '-id'], + }, + ), + migrations.CreateModel( + name='LeveeWorkSessionItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('crop_name_snapshot', models.CharField(max_length=100, verbose_name='作物名スナップショット')), + ('variety_name_snapshot', models.CharField(blank=True, default='', max_length=100, verbose_name='品種名スナップショット')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')), + ('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='plans.plan', verbose_name='作付け計画')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='levee_work.leveeworksession', verbose_name='畔塗記録')), + ], + options={ + 'verbose_name': '畔塗対象圃場', + 'verbose_name_plural': '畔塗対象圃場', + 'ordering': ['field__display_order', 'field__id'], + 'unique_together': {('session', 'field')}, + }, + ), + ] + diff --git a/backend/apps/levee_work/migrations/__init__.py b/backend/apps/levee_work/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/levee_work/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/levee_work/models.py b/backend/apps/levee_work/models.py new file mode 100644 index 0000000..684978c --- /dev/null +++ b/backend/apps/levee_work/models.py @@ -0,0 +1,59 @@ +from django.db import models + + +class LeveeWorkSession(models.Model): + year = models.IntegerField(verbose_name='年度') + date = models.DateField(verbose_name='畔塗日') + title = models.CharField(max_length=100, default='水稲畔塗', verbose_name='タイトル') + notes = models.TextField(blank=True, default='', verbose_name='備考') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = '畔塗記録' + verbose_name_plural = '畔塗記録' + ordering = ['-date', '-id'] + + def __str__(self): + return f'{self.date} {self.title}' + + +class LeveeWorkSessionItem(models.Model): + session = models.ForeignKey( + LeveeWorkSession, + on_delete=models.CASCADE, + related_name='items', + verbose_name='畔塗記録', + ) + field = models.ForeignKey( + 'fields.Field', + on_delete=models.PROTECT, + verbose_name='圃場', + ) + plan = models.ForeignKey( + 'plans.Plan', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='+', + verbose_name='作付け計画', + ) + crop_name_snapshot = models.CharField(max_length=100, verbose_name='作物名スナップショット') + variety_name_snapshot = models.CharField( + max_length=100, + blank=True, + default='', + verbose_name='品種名スナップショット', + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = '畔塗対象圃場' + verbose_name_plural = '畔塗対象圃場' + unique_together = [['session', 'field']] + ordering = ['field__display_order', 'field__id'] + + def __str__(self): + return f'{self.session} / {self.field.name}' + diff --git a/backend/apps/levee_work/serializers.py b/backend/apps/levee_work/serializers.py new file mode 100644 index 0000000..32aa780 --- /dev/null +++ b/backend/apps/levee_work/serializers.py @@ -0,0 +1,142 @@ +from django.db import transaction +from rest_framework import serializers + +from apps.plans.models import Plan +from apps.workrecords.services import sync_levee_work_record +from .models import LeveeWorkSession, LeveeWorkSessionItem + + +class LeveeWorkSessionItemReadSerializer(serializers.ModelSerializer): + field_name = serializers.CharField(source='field.name', read_only=True) + field_area_tan = serializers.DecimalField( + source='field.area_tan', + max_digits=6, + decimal_places=4, + read_only=True, + ) + group_name = serializers.CharField(source='field.group_name', read_only=True, allow_null=True) + + class Meta: + model = LeveeWorkSessionItem + fields = [ + 'id', + 'field', + 'field_name', + 'field_area_tan', + 'group_name', + 'plan', + 'crop_name_snapshot', + 'variety_name_snapshot', + ] + + +class LeveeWorkSessionSerializer(serializers.ModelSerializer): + items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True) + work_record_id = serializers.IntegerField(source='work_record.id', read_only=True) + item_count = serializers.SerializerMethodField() + + class Meta: + model = LeveeWorkSession + fields = [ + 'id', + 'year', + 'date', + 'title', + 'notes', + 'work_record_id', + 'item_count', + 'items', + 'created_at', + 'updated_at', + ] + + def get_item_count(self, obj): + return len(obj.items.all()) + + +class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer): + field = serializers.IntegerField() + plan = serializers.IntegerField(required=False, allow_null=True) + + +class LeveeWorkSessionWriteSerializer(serializers.ModelSerializer): + items = LeveeWorkSessionItemWriteInputSerializer(many=True, write_only=True) + + class Meta: + model = LeveeWorkSession + fields = ['id', 'year', 'date', 'title', 'notes', 'items'] + + def validate(self, attrs): + year = attrs.get('year', getattr(self.instance, 'year', None)) + date = attrs.get('date', getattr(self.instance, 'date', None)) + if year is not None and date is not None and year != date.year: + raise serializers.ValidationError({'year': 'year は date.year と一致させてください。'}) + return attrs + + def validate_items(self, value): + if not value: + raise serializers.ValidationError('items を1件以上指定してください。') + seen = set() + for item in value: + key = item['field'] + if key in seen: + raise serializers.ValidationError('同一 session 内で同じ圃場を重複登録できません。') + seen.add(key) + return value + + @transaction.atomic + def create(self, validated_data): + items_data = validated_data.pop('items', []) + validated_data['title'] = (validated_data.get('title') or '').strip() or '水稲畔塗' + session = LeveeWorkSession.objects.create(**validated_data) + self._replace_items(session, items_data) + sync_levee_work_record(session) + return session + + @transaction.atomic + def update(self, instance, validated_data): + items_data = validated_data.pop('items', None) + for attr, value in validated_data.items(): + if attr == 'title': + value = (value or '').strip() or '水稲畔塗' + setattr(instance, attr, value) + if 'title' not in validated_data: + instance.title = (instance.title or '').strip() or '水稲畔塗' + instance.save() + if items_data is not None: + self._replace_items(instance, items_data) + sync_levee_work_record(instance) + return instance + + def _replace_items(self, session, items_data): + session.items.all().delete() + for item in items_data: + plan = self._resolve_plan(session.year, item['field'], item.get('plan')) + LeveeWorkSessionItem.objects.create( + session=session, + field_id=item['field'], + plan=plan, + crop_name_snapshot=plan.crop.name, + variety_name_snapshot=plan.variety.name if plan.variety else '', + ) + + def _resolve_plan(self, year, field_id, plan_id): + queryset = Plan.objects.select_related('crop', 'variety').filter( + year=year, + field_id=field_id, + crop__name='水稲', + ) + if plan_id is not None: + try: + return queryset.get(id=plan_id) + except Plan.DoesNotExist as exc: + raise serializers.ValidationError( + {'items': f'field={field_id} に対応する水稲作付け計画(plan={plan_id})が見つかりません。'} + ) from exc + + plan = queryset.first() + if plan is None: + raise serializers.ValidationError( + {'items': f'field={field_id} は当年の水稲作付け圃場ではありません。'} + ) + return plan diff --git a/backend/apps/levee_work/urls.py b/backend/apps/levee_work/urls.py new file mode 100644 index 0000000..66ec3c1 --- /dev/null +++ b/backend/apps/levee_work/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import LeveeWorkCandidatesView, LeveeWorkSessionViewSet + +router = DefaultRouter() +router.register(r'sessions', LeveeWorkSessionViewSet, basename='levee-work-session') + +urlpatterns = [ + path('candidates/', LeveeWorkCandidatesView.as_view(), name='levee-work-candidates'), + path('', include(router.urls)), +] + diff --git a/backend/apps/levee_work/views.py b/backend/apps/levee_work/views.py new file mode 100644 index 0000000..522efcd --- /dev/null +++ b/backend/apps/levee_work/views.py @@ -0,0 +1,70 @@ +from rest_framework import status, viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.plans.models import Plan +from .models import LeveeWorkSession +from .serializers import LeveeWorkSessionSerializer, LeveeWorkSessionWriteSerializer + + +class LeveeWorkSessionViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + + def get_queryset(self): + queryset = LeveeWorkSession.objects.prefetch_related( + 'items', + 'items__field', + 'items__plan', + ).select_related('work_record') + year = self.request.query_params.get('year') + if year: + queryset = queryset.filter(year=year) + return queryset + + def get_serializer_class(self): + if self.action in ['create', 'update', 'partial_update']: + return LeveeWorkSessionWriteSerializer + return LeveeWorkSessionSerializer + + +class LeveeWorkCandidatesView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + year = request.query_params.get('year') + if not year: + return Response( + {'detail': 'year が必要です。'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + year = int(year) + except (TypeError, ValueError): + return Response( + {'detail': 'year は数値で指定してください。'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + plans = ( + Plan.objects.select_related('field', 'crop', 'variety') + .filter(year=year, crop__name='水稲') + .order_by('field__display_order', 'field__id') + ) + + data = [ + { + 'field_id': plan.field_id, + 'field_name': plan.field.name, + 'field_area_tan': str(plan.field.area_tan), + 'group_name': plan.field.group_name, + 'plan_id': plan.id, + 'crop_name': plan.crop.name, + 'variety_name': plan.variety.name if plan.variety else '', + 'selected': True, + } + for plan in plans + ] + return Response(data) + diff --git a/backend/apps/workrecords/migrations/0002_workrecord_levee_work_session.py b/backend/apps/workrecords/migrations/0002_workrecord_levee_work_session.py new file mode 100644 index 0000000..f24eded --- /dev/null +++ b/backend/apps/workrecords/migrations/0002_workrecord_levee_work_session.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2 on 2026-04-04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('levee_work', '0001_initial'), + ('workrecords', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='workrecord', + name='work_type', + field=models.CharField( + choices=[ + ('fertilizer_delivery', '肥料運搬'), + ('fertilizer_spreading', '肥料散布'), + ('levee_work', '畔塗'), + ], + max_length=40, + verbose_name='作業種別', + ), + ), + migrations.AddField( + model_name='workrecord', + name='levee_work_session', + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='work_record', + to='levee_work.leveeworksession', + verbose_name='畔塗記録', + ), + ), + ] + diff --git a/backend/apps/workrecords/models.py b/backend/apps/workrecords/models.py index f7948bb..c522c6b 100644 --- a/backend/apps/workrecords/models.py +++ b/backend/apps/workrecords/models.py @@ -5,6 +5,7 @@ class WorkRecord(models.Model): class WorkType(models.TextChoices): FERTILIZER_DELIVERY = 'fertilizer_delivery', '肥料運搬' FERTILIZER_SPREADING = 'fertilizer_spreading', '肥料散布' + LEVEE_WORK = 'levee_work', '畔塗' work_date = models.DateField(verbose_name='作業日') work_type = models.CharField( @@ -31,6 +32,14 @@ class WorkRecord(models.Model): related_name='work_record', verbose_name='散布実績', ) + levee_work_session = models.OneToOneField( + 'levee_work.LeveeWorkSession', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='work_record', + verbose_name='畔塗記録', + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -41,4 +50,3 @@ class WorkRecord(models.Model): def __str__(self): return f'{self.work_date} {self.get_work_type_display()}' - diff --git a/backend/apps/workrecords/serializers.py b/backend/apps/workrecords/serializers.py index e011ffb..54ccc26 100644 --- a/backend/apps/workrecords/serializers.py +++ b/backend/apps/workrecords/serializers.py @@ -22,6 +22,7 @@ class WorkRecordSerializer(serializers.ModelSerializer): 'delivery_plan_id', 'delivery_plan_name', 'spreading_session', + 'levee_work_session', 'created_at', 'updated_at', ] @@ -35,4 +36,3 @@ class WorkRecordSerializer(serializers.ModelSerializer): if obj.delivery_trip_id: return obj.delivery_trip.delivery_plan.name return None - diff --git a/backend/apps/workrecords/services.py b/backend/apps/workrecords/services.py index 534d6b5..4fe51c6 100644 --- a/backend/apps/workrecords/services.py +++ b/backend/apps/workrecords/services.py @@ -31,3 +31,18 @@ def sync_spreading_work_record(session): 'delivery_trip': None, }, ) + + +def sync_levee_work_record(session): + WorkRecord.objects.update_or_create( + levee_work_session=session, + defaults={ + 'work_date': session.date, + 'work_type': WorkRecord.WorkType.LEVEE_WORK, + 'title': session.title, + 'year': session.year, + 'auto_created': True, + 'delivery_trip': None, + 'spreading_session': None, + }, + ) diff --git a/backend/apps/workrecords/views.py b/backend/apps/workrecords/views.py index 547a4cb..676de2e 100644 --- a/backend/apps/workrecords/views.py +++ b/backend/apps/workrecords/views.py @@ -14,9 +14,9 @@ class WorkRecordViewSet(viewsets.ReadOnlyModelViewSet): 'delivery_trip', 'delivery_trip__delivery_plan', 'spreading_session', + 'levee_work_session', ) year = self.request.query_params.get('year') if year: queryset = queryset.filter(year=year) return queryset - diff --git a/backend/keinasystem/settings.py b/backend/keinasystem/settings.py index 63c1573..f9164e2 100644 --- a/backend/keinasystem/settings.py +++ b/backend/keinasystem/settings.py @@ -46,6 +46,7 @@ INSTALLED_APPS = [ 'apps.fertilizer', 'apps.materials', 'apps.workrecords', + 'apps.levee_work', ] MIDDLEWARE = [ diff --git a/backend/keinasystem/urls.py b/backend/keinasystem/urls.py index c35d8f8..f85cfbe 100644 --- a/backend/keinasystem/urls.py +++ b/backend/keinasystem/urls.py @@ -60,4 +60,5 @@ urlpatterns = [ path('api/fertilizer/', include('apps.fertilizer.urls')), path('api/materials/', include('apps.materials.urls')), path('api/workrecords/', include('apps.workrecords.urls')), + path('api/levee-work/', include('apps.levee_work.urls')), ] diff --git a/frontend/src/app/levee-work/page.tsx b/frontend/src/app/levee-work/page.tsx new file mode 100644 index 0000000..ce897dd --- /dev/null +++ b/frontend/src/app/levee-work/page.tsx @@ -0,0 +1,439 @@ +'use client'; + +import { Suspense, useEffect, useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ChevronLeft, PencilLine, Plus, Save, Trash2 } from 'lucide-react'; + +import Navbar from '@/components/Navbar'; +import { api } from '@/lib/api'; +import { LeveeWorkCandidate, LeveeWorkSession } from '@/types'; + +const CURRENT_YEAR = new Date().getFullYear(); +const YEAR_KEY = 'leveeWorkYear'; + +type FormState = { + date: string; + title: string; + notes: string; + selectedFieldIds: Set; +}; + +const extractErrorMessage = (error: any) => { + const data = error?.response?.data; + if (!data) return '保存に失敗しました。'; + if (typeof data.detail === 'string') return data.detail; + if (Array.isArray(data.year) && data.year[0]) return data.year[0]; + if (Array.isArray(data.items) && data.items[0]) return data.items[0]; + if (typeof data.items === 'string') return data.items; + return '保存に失敗しました。'; +}; + +const getDefaultDate = (year: number) => { + const today = new Date(); + if (today.getFullYear() !== year) { + return `${year}-01-01`; + } + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +export default function LeveeWorkPage() { + return ( +
読み込み中...
}> + +
+ ); +} + +function LeveeWorkPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [year, setYear] = useState(() => { + if (typeof window !== 'undefined') { + return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10); + } + return CURRENT_YEAR; + }); + const [sessions, setSessions] = useState([]); + const [candidates, setCandidates] = useState([]); + const [form, setForm] = useState(null); + const [editingSessionId, setEditingSessionId] = useState(null); + const [loading, setLoading] = useState(true); + const [formLoading, setFormLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [openedFromQuery, setOpenedFromQuery] = useState(false); + + useEffect(() => { + localStorage.setItem(YEAR_KEY, String(year)); + void fetchSessions(); + setForm(null); + setEditingSessionId(null); + setOpenedFromQuery(false); + }, [year]); + + useEffect(() => { + const sessionParam = Number(searchParams.get('session') || '0') || null; + if (!sessionParam || openedFromQuery || sessions.length === 0) { + return; + } + const target = sessions.find((session) => session.id === sessionParam); + if (target) { + void openEditor(target); + setOpenedFromQuery(true); + } + }, [openedFromQuery, searchParams, sessions]); + + const fetchSessions = async () => { + setLoading(true); + setError(null); + try { + const res = await api.get(`/levee-work/sessions/?year=${year}`); + setSessions(res.data); + } catch (e) { + console.error(e); + setError('畔塗記録の読み込みに失敗しました。'); + } finally { + setLoading(false); + } + }; + + const loadCandidates = async () => { + const res = await api.get(`/levee-work/candidates/?year=${year}`); + setCandidates(res.data); + return res.data as LeveeWorkCandidate[]; + }; + + const startCreate = async () => { + setFormLoading(true); + setError(null); + try { + const loaded = await loadCandidates(); + setEditingSessionId(null); + setForm({ + date: getDefaultDate(year), + title: '水稲畔塗', + notes: '', + selectedFieldIds: new Set(loaded.filter((candidate) => candidate.selected).map((candidate) => candidate.field_id)), + }); + } catch (e) { + console.error(e); + setError('候補圃場の読み込みに失敗しました。'); + } finally { + setFormLoading(false); + } + }; + + const openEditor = async (session: LeveeWorkSession) => { + setFormLoading(true); + setError(null); + try { + const loaded = await loadCandidates(); + const selectedIds = new Set(session.items.map((item) => item.field)); + const fallbackSelected = loaded.filter((candidate) => candidate.selected).map((candidate) => candidate.field_id); + setEditingSessionId(session.id); + setForm({ + date: session.date, + title: session.title, + notes: session.notes, + selectedFieldIds: selectedIds.size > 0 ? selectedIds : new Set(fallbackSelected), + }); + } catch (e) { + console.error(e); + setError('編集用データの読み込みに失敗しました。'); + } finally { + setFormLoading(false); + } + }; + + const handleToggleField = (fieldId: number) => { + if (!form) return; + const next = new Set(form.selectedFieldIds); + if (next.has(fieldId)) { + next.delete(fieldId); + } else { + next.add(fieldId); + } + setForm({ ...form, selectedFieldIds: next }); + }; + + const handleSelectAll = () => { + if (!form) return; + setForm({ + ...form, + selectedFieldIds: new Set(candidates.map((candidate) => candidate.field_id)), + }); + }; + + const handleClearAll = () => { + if (!form) return; + setForm({ ...form, selectedFieldIds: new Set() }); + }; + + const selectedCount = form?.selectedFieldIds.size ?? 0; + + const selectedCandidates = useMemo(() => { + if (!form) return []; + return candidates.filter((candidate) => form.selectedFieldIds.has(candidate.field_id)); + }, [candidates, form]); + + const handleSave = async () => { + if (!form) return; + if (selectedCount === 0) { + setError('対象圃場を1件以上選択してください。'); + return; + } + setSaving(true); + setError(null); + try { + const payload = { + year, + date: form.date, + title: form.title, + notes: form.notes, + items: selectedCandidates.map((candidate) => ({ + field: candidate.field_id, + plan: candidate.plan_id, + })), + }; + if (editingSessionId) { + await api.put(`/levee-work/sessions/${editingSessionId}/`, payload); + } else { + await api.post('/levee-work/sessions/', payload); + } + await fetchSessions(); + await startCreate(); + } catch (e: any) { + console.error(e); + setError(extractErrorMessage(e)); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!editingSessionId) return; + if (!window.confirm('この畔塗記録を削除しますか?')) return; + setSaving(true); + setError(null); + try { + await api.delete(`/levee-work/sessions/${editingSessionId}/`); + await fetchSessions(); + setEditingSessionId(null); + setForm(null); + } catch (e) { + console.error(e); + setError('削除に失敗しました。'); + } finally { + setSaving(false); + } + }; + + const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i); + + return ( +
+ +
+
+
+ + +

畔塗記録

+
+
+ + + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+
記録一覧
+ {loading ? ( +
読み込み中...
+ ) : sessions.length === 0 ? ( +
この年度の畔塗記録はまだありません。
+ ) : ( +
+ {sessions.map((session) => ( + + ))} +
+ )} +
+ +
+
+ {editingSessionId ? '畔塗記録を編集' : '畔塗記録を作成'} +
+ + {!form ? ( +
+ {formLoading ? 'フォームを準備中...' : '「新規作成」または既存記録の選択で編集を始められます。'} +
+ ) : ( +
+
+ + +
+ +