From ae0249be69ea257d8d69bb32ac1c30a3f0dc9b95 Mon Sep 17 00:00:00 2001 From: akira Date: Sun, 5 Apr 2026 16:55:44 +0900 Subject: [PATCH] Add allocation variety change history UI --- backend/apps/plans/serializers.py | 28 +++++++ backend/apps/plans/views.py | 8 +- frontend/src/app/allocation/page.tsx | 109 +++++++++++++++++++++------ frontend/src/types/index.ts | 10 +++ 4 files changed, 130 insertions(+), 25 deletions(-) diff --git a/backend/apps/plans/serializers.py b/backend/apps/plans/serializers.py index 0ba1b78..6ee6e36 100644 --- a/backend/apps/plans/serializers.py +++ b/backend/apps/plans/serializers.py @@ -35,6 +35,8 @@ class PlanSerializer(serializers.ModelSerializer): crop_name = serializers.ReadOnlyField(source='crop.name') variety_name = serializers.ReadOnlyField(source='variety.name') field_name = serializers.ReadOnlyField(source='field.name') + variety_change_count = serializers.SerializerMethodField() + latest_variety_change = serializers.SerializerMethodField() class Meta: model = Plan @@ -52,6 +54,32 @@ class PlanSerializer(serializers.ModelSerializer): notes=validated_data.get('notes', NO_CHANGE), ) + def get_variety_change_count(self, obj): + prefetched = getattr(obj, '_prefetched_objects_cache', {}) + changes = prefetched.get('variety_changes') + if changes is not None: + return len(changes) + return obj.variety_changes.count() + + def get_latest_variety_change(self, obj): + prefetched = getattr(obj, '_prefetched_objects_cache', {}) + changes = prefetched.get('variety_changes') + if changes is not None: + latest = changes[0] if changes else None + else: + latest = obj.variety_changes.select_related('old_variety', 'new_variety').first() + if latest is None: + return None + return { + 'id': latest.id, + 'changed_at': latest.changed_at, + 'old_variety_id': latest.old_variety_id, + 'old_variety_name': latest.old_variety.name if latest.old_variety else None, + 'new_variety_id': latest.new_variety_id, + 'new_variety_name': latest.new_variety.name if latest.new_variety else None, + 'fertilizer_moved_entry_count': latest.fertilizer_moved_entry_count, + } + class RiceTransplantEntrySerializer(serializers.ModelSerializer): field_name = serializers.CharField(source='field.name', read_only=True) diff --git a/backend/apps/plans/views.py b/backend/apps/plans/views.py index 62964dc..562de18 100644 --- a/backend/apps/plans/views.py +++ b/backend/apps/plans/views.py @@ -25,11 +25,15 @@ class VarietyViewSet(viewsets.ModelViewSet): class PlanViewSet(viewsets.ModelViewSet): - queryset = Plan.objects.all() + queryset = Plan.objects.select_related('crop', 'variety', 'field').prefetch_related( + 'variety_changes', + 'variety_changes__old_variety', + 'variety_changes__new_variety', + ) serializer_class = PlanSerializer def get_queryset(self): - queryset = Plan.objects.all() + queryset = self.queryset year = self.request.query_params.get('year') if year: queryset = queryset.filter(year=year) diff --git a/frontend/src/app/allocation/page.tsx b/frontend/src/app/allocation/page.tsx index 0a7cab5..5cd9e37 100644 --- a/frontend/src/app/allocation/page.tsx +++ b/frontend/src/app/allocation/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react'; import { api } from '@/lib/api'; import { Field, Crop, Plan } from '@/types'; import Navbar from '@/components/Navbar'; -import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react'; +import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search, History } from 'lucide-react'; interface SummaryItem { cropId: number; @@ -48,6 +48,13 @@ export default function AllocationPage() { const [searchText, setSearchText] = useState(''); const [filterCropId, setFilterCropId] = useState(0); const [filterUnassigned, setFilterUnassigned] = useState(false); + const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + + useEffect(() => { + if (!toast) return; + const timer = window.setTimeout(() => setToast(null), 4000); + return () => window.clearTimeout(timer); + }, [toast]); useEffect(() => { localStorage.setItem('allocationYear', String(year)); @@ -233,17 +240,46 @@ export default function AllocationPage() { const existingPlan = getPlanForField(fieldId); if (!existingPlan || !existingPlan.crop) return; + if ((existingPlan.variety || null) === variety) return; + + const nextVarietyName = + variety === null + ? '(品種未選択)' + : getVarietiesForCrop(existingPlan.crop).find((item) => item.id === variety)?.name || '不明'; + const currentVarietyName = existingPlan.variety_name || '(品種未選択)'; + + const shouldProceed = confirm( + [ + `品種を「${currentVarietyName}」から「${nextVarietyName}」へ変更します。`, + '施肥計画・田植え計画の関連エントリが自動で移動する場合があります。', + '実行しますか?', + ].join('\n') + ); + if (!shouldProceed) return; setSaving(fieldId); try { - await api.patch(`/plans/${existingPlan.id}/`, { + const res = await api.patch(`/plans/${existingPlan.id}/`, { variety, notes: existingPlan.notes, }); + const updatedPlan: Plan = res.data; + const movedCount = updatedPlan.latest_variety_change?.fertilizer_moved_entry_count ?? 0; + setToast({ + type: 'success', + message: + movedCount > 0 + ? `品種を変更し、施肥計画 ${movedCount} 件を移動しました。` + : '品種を変更しました。関連する施肥計画の移動はありませんでした。', + }); await fetchData(true); } catch (error) { console.error('Failed to save variety:', error); + setToast({ + type: 'error', + message: '品種変更に失敗しました。', + }); } finally { setSaving(null); } @@ -563,6 +599,17 @@ export default function AllocationPage() { {/* メインコンテンツ */}
+ {toast && ( +
+ {toast.message} +
+ )}

作付け計画 {year}年度 @@ -887,27 +934,43 @@ export default function AllocationPage() {

) : ( - +
+ + {plan?.latest_variety_change && ( +
+ + 変更履歴あり +
+ )} +
)} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 27443d4..c168252 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -57,6 +57,16 @@ export interface Plan { variety: number; variety_name: string; notes: string | null; + variety_change_count?: number; + latest_variety_change?: { + id: number; + changed_at: string; + old_variety_id: number | null; + old_variety_name: string | null; + new_variety_id: number | null; + new_variety_name: string | null; + fertilizer_moved_entry_count: number; + } | null; } export interface Fertilizer {