Add allocation variety change history UI
This commit is contained in:
@@ -35,6 +35,8 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
crop_name = serializers.ReadOnlyField(source='crop.name')
|
crop_name = serializers.ReadOnlyField(source='crop.name')
|
||||||
variety_name = serializers.ReadOnlyField(source='variety.name')
|
variety_name = serializers.ReadOnlyField(source='variety.name')
|
||||||
field_name = serializers.ReadOnlyField(source='field.name')
|
field_name = serializers.ReadOnlyField(source='field.name')
|
||||||
|
variety_change_count = serializers.SerializerMethodField()
|
||||||
|
latest_variety_change = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Plan
|
model = Plan
|
||||||
@@ -52,6 +54,32 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
notes=validated_data.get('notes', NO_CHANGE),
|
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):
|
class RiceTransplantEntrySerializer(serializers.ModelSerializer):
|
||||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||||
|
|||||||
@@ -25,11 +25,15 @@ class VarietyViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class PlanViewSet(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
|
serializer_class = PlanSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = Plan.objects.all()
|
queryset = self.queryset
|
||||||
year = self.request.query_params.get('year')
|
year = self.request.query_params.get('year')
|
||||||
if year:
|
if year:
|
||||||
queryset = queryset.filter(year=year)
|
queryset = queryset.filter(year=year)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Field, Crop, Plan } from '@/types';
|
import { Field, Crop, Plan } from '@/types';
|
||||||
import Navbar from '@/components/Navbar';
|
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 {
|
interface SummaryItem {
|
||||||
cropId: number;
|
cropId: number;
|
||||||
@@ -48,6 +48,13 @@ export default function AllocationPage() {
|
|||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
|
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
|
||||||
const [filterUnassigned, setFilterUnassigned] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('allocationYear', String(year));
|
localStorage.setItem('allocationYear', String(year));
|
||||||
@@ -233,17 +240,46 @@ export default function AllocationPage() {
|
|||||||
const existingPlan = getPlanForField(fieldId);
|
const existingPlan = getPlanForField(fieldId);
|
||||||
|
|
||||||
if (!existingPlan || !existingPlan.crop) return;
|
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);
|
setSaving(fieldId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.patch(`/plans/${existingPlan.id}/`, {
|
const res = await api.patch(`/plans/${existingPlan.id}/`, {
|
||||||
variety,
|
variety,
|
||||||
notes: existingPlan.notes,
|
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);
|
await fetchData(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save variety:', error);
|
console.error('Failed to save variety:', error);
|
||||||
|
setToast({
|
||||||
|
type: 'error',
|
||||||
|
message: '品種変更に失敗しました。',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null);
|
setSaving(null);
|
||||||
}
|
}
|
||||||
@@ -563,6 +599,17 @@ export default function AllocationPage() {
|
|||||||
{/* メインコンテンツ */}
|
{/* メインコンテンツ */}
|
||||||
<div className="flex-1 min-w-0 p-4 lg:p-0">
|
<div className="flex-1 min-w-0 p-4 lg:p-0">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
className={`mb-4 rounded-md border px-4 py-3 text-sm ${
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'border-green-300 bg-green-50 text-green-800'
|
||||||
|
: 'border-red-300 bg-red-50 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
作付け計画 <span className="text-green-700">{year}年度</span>
|
作付け計画 <span className="text-green-700">{year}年度</span>
|
||||||
@@ -887,6 +934,7 @@ export default function AllocationPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={selectedVarietyId || ''}
|
value={selectedVarietyId || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -908,6 +956,21 @@ export default function AllocationPage() {
|
|||||||
))}
|
))}
|
||||||
{selectedCropId > 0 && <option value="__add__">+ 新しい品種を追加...</option>}
|
{selectedCropId > 0 && <option value="__add__">+ 新しい品種を追加...</option>}
|
||||||
</select>
|
</select>
|
||||||
|
{plan?.latest_variety_change && (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800"
|
||||||
|
title={[
|
||||||
|
`変更日時: ${new Date(plan.latest_variety_change.changed_at).toLocaleString('ja-JP')}`,
|
||||||
|
`変更前: ${plan.latest_variety_change.old_variety_name || '未設定'}`,
|
||||||
|
`変更後: ${plan.latest_variety_change.new_variety_name || '未設定'}`,
|
||||||
|
`施肥移動件数: ${plan.latest_variety_change.fertilizer_moved_entry_count}`,
|
||||||
|
].join('\n')}
|
||||||
|
>
|
||||||
|
<History className="h-3 w-3" />
|
||||||
|
変更履歴あり
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export interface Plan {
|
|||||||
variety: number;
|
variety: number;
|
||||||
variety_name: string;
|
variety_name: string;
|
||||||
notes: string | null;
|
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 {
|
export interface Fertilizer {
|
||||||
|
|||||||
Reference in New Issue
Block a user