'use client'; 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, History } from 'lucide-react'; interface SummaryItem { cropId: number; cropName: string; areaTan: number; varieties: { varietyId: number | null; varietyName: string; areaTan: number; }[]; } type SortType = 'custom' | 'group' | 'crop'; export default function AllocationPage() { const [fields, setFields] = useState([]); const [crops, setCrops] = useState([]); const [plans, setPlans] = useState([]); const [year, setYear] = useState(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('allocationYear'); if (saved) return parseInt(saved); } return new Date().getFullYear(); }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); const [showSidebar, setShowSidebar] = useState(true); const [showMobileSummary, setShowMobileSummary] = useState(false); const [expandedCrops, setExpandedCrops] = useState>(new Set()); const [sortType, setSortType] = useState('custom'); const [copying, setCopying] = useState(false); const [addingVariety, setAddingVariety] = useState<{ fieldId: number; cropId: number } | null>(null); const [newVarietyName, setNewVarietyName] = useState(''); const [showVarietyManager, setShowVarietyManager] = useState(false); const [managerCropId, setManagerCropId] = useState(null); const [selectedFields, setSelectedFields] = useState>(new Set()); const [bulkCropId, setBulkCropId] = useState(0); const [bulkVarietyId, setBulkVarietyId] = useState(0); const [bulkUpdating, setBulkUpdating] = useState(false); 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)); fetchData(); }, [year]); const currentYear = new Date().getFullYear(); const isPastYear = year < currentYear; const fetchData = async (background = false) => { if (!background) setLoading(true); try { const [fieldsRes, cropsRes, plansRes] = await Promise.all([ api.get('/fields/?ordering=group_name,display_order,id'), api.get('/plans/crops/'), api.get(`/plans/?year=${year}`), ]); setFields(fieldsRes.data); setCrops(cropsRes.data); setPlans(plansRes.data); } catch (error) { console.error('Failed to fetch data:', error); } finally { if (!background) setLoading(false); } }; const summary = useMemo(() => { const cropSummary = new Map(); let totalAreaTan = 0; let unassignedAreaTan = 0; fields.forEach((field) => { const area = parseFloat(field.area_tan) || 0; totalAreaTan += area; const plan = plans.find((p) => p.field === field.id); if (!plan?.crop) { unassignedAreaTan += area; return; } if (!cropSummary.has(plan.crop)) { const crop = crops.find((c) => c.id === plan.crop); cropSummary.set(plan.crop, { cropId: plan.crop, cropName: crop?.name || '不明', areaTan: 0, varieties: [], }); } const item = cropSummary.get(plan.crop)!; item.areaTan += area; const varietyId = plan.variety; const varietyName = plan.variety_name || '(品種未選択)'; let varietyItem = item.varieties.find((v) => v.varietyId === varietyId); if (!varietyItem) { varietyItem = { varietyId, varietyName, areaTan: 0 }; item.varieties.push(varietyItem); } varietyItem.areaTan += area; }); return { totalAreaTan, unassignedAreaTan, items: Array.from(cropSummary.values()), }; }, [fields, plans, crops]); const sortedFields = useMemo(() => { const sorted = [...fields]; if (sortType === 'custom') { sorted.sort((a, b) => { const orderA = a.display_order ?? 0; const orderB = b.display_order ?? 0; return orderA - orderB; }); } else if (sortType === 'group') { sorted.sort((a, b) => { const groupA = a.group_name || ''; const groupB = b.group_name || ''; if (groupA !== groupB) return groupA.localeCompare(groupB); const orderA = a.display_order ?? 0; const orderB = b.display_order ?? 0; return orderA - orderB; }); } else if (sortType === 'crop') { sorted.sort((a, b) => { const planA = plans.find((p) => p.field === a.id); const planB = plans.find((p) => p.field === b.id); const cropA = planA?.crop || 0; const cropB = planB?.crop || 0; if (cropA !== cropB) return cropA - cropB; const orderA = a.display_order ?? 0; const orderB = b.display_order ?? 0; return orderA - orderB; }); } return sorted; }, [fields, sortType, plans]); const filteredFields = useMemo(() => { let result = sortedFields; if (searchText) { const query = searchText.toLowerCase(); result = result.filter( (f) => f.name.toLowerCase().includes(query) || (f.address && f.address.toLowerCase().includes(query)) ); } if (filterCropId) { result = result.filter((f) => { const plan = plans.find((p) => p.field === f.id); return plan?.crop === filterCropId; }); } if (filterUnassigned) { result = result.filter((f) => { const plan = plans.find((p) => p.field === f.id); return !plan?.crop; }); } return result; }, [sortedFields, searchText, filterCropId, filterUnassigned, plans]); const groupOptions = useMemo(() => { const groups = new Set(); fields.forEach(f => { if (f.group_name) groups.add(f.group_name); }); return Array.from(groups).sort(); }, [fields]); const getPlanForField = (fieldId: number): Plan | undefined => { return plans.find((p) => p.field === fieldId); }; const handleCropChange = async (fieldId: number, cropId: string) => { const crop = parseInt(cropId); if (!crop) return; const existingPlan = getPlanForField(fieldId); setSaving(fieldId); try { if (existingPlan) { await api.patch(`/plans/${existingPlan.id}/`, { crop, variety: null, notes: existingPlan.notes, }); } else { await api.post('/plans/', { field: fieldId, year, crop, variety: null, notes: '', }); } await fetchData(true); } catch (error) { console.error('Failed to save crop:', error); } finally { setSaving(null); } }; const handleVarietyChange = async (fieldId: number, varietyId: string) => { const variety = parseInt(varietyId) || null; 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 { 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); } }; const handleNotesChange = async (fieldId: number, notes: string) => { const existingPlan = getPlanForField(fieldId); if (!existingPlan) return; setSaving(fieldId); try { await api.patch(`/plans/${existingPlan.id}/`, { notes }); await fetchData(true); } catch (error) { console.error('Failed to save notes:', error); } finally { setSaving(null); } }; const handleGroupChange = async (fieldId: number, groupName: string) => { setFields(prev => prev.map(f => f.id === fieldId ? { ...f, group_name: groupName || null } : f )); try { await api.patch(`/fields/${fieldId}/`, { group_name: groupName || null }); } catch (error) { console.error('Failed to save group:', error); await fetchData(true); } }; const moveUp = async (index: number) => { if (index === 0) return; const current = sortedFields[index]; const prev = sortedFields[index - 1]; setSaving(current.id); setSaving(prev.id); try { const newOrderCurrent = prev.display_order ?? (index - 1); const newOrderPrev = current.display_order ?? index; await Promise.all([ api.patch(`/fields/${current.id}/`, { display_order: newOrderCurrent }), api.patch(`/fields/${prev.id}/`, { display_order: newOrderPrev }) ]); await fetchData(true); } catch (error) { console.error('Failed to move up:', error); } finally { setSaving(null); } }; const moveDown = async (index: number) => { if (index === sortedFields.length - 1) return; const current = sortedFields[index]; const next = sortedFields[index + 1]; setSaving(current.id); setSaving(next.id); try { const newOrderCurrent = next.display_order ?? (index + 1); const newOrderPrev = current.display_order ?? index; await Promise.all([ api.patch(`/fields/${current.id}/`, { display_order: newOrderCurrent }), api.patch(`/fields/${next.id}/`, { display_order: newOrderPrev }) ]); await fetchData(true); } catch (error) { console.error('Failed to move down:', error); } finally { setSaving(null); } }; const handleAddVariety = async (fieldId: number, cropId: number) => { const name = newVarietyName.trim(); if (!name) return; try { const res = await api.post('/plans/varieties/', { crop: cropId, name }); setNewVarietyName(''); setAddingVariety(null); await fetchData(true); // Auto-select the new variety const plan = getPlanForField(fieldId); if (plan) { await api.patch(`/plans/${plan.id}/`, { variety: res.data.id }); await fetchData(true); } } catch (error: any) { if (error.response?.status === 400) { alert('この品種名は既に登録されています'); } else { console.error('Failed to add variety:', error); alert('品種の追加に失敗しました'); } } }; const handleDeleteVariety = async (varietyId: number, varietyName: string) => { if (!confirm(`品種「${varietyName}」を削除しますか?\nこの品種が設定されている作付け計画がある場合、削除できません。`)) return; try { await api.delete(`/plans/varieties/${varietyId}/`); await fetchData(true); } catch (error: any) { console.error('Failed to delete variety:', error); alert('品種の削除に失敗しました。使用中の品種は削除できません。'); } }; const handleUpdateVarietyDefaultBoxes = async (varietyId: number, defaultBoxes: string) => { try { const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId); if (!variety) return; await api.patch(`/plans/varieties/${varietyId}/`, { default_seedling_boxes_per_tan: defaultBoxes, }); await fetchData(true); } catch (error) { console.error('Failed to update variety default boxes:', error); alert('品種デフォルトの更新に失敗しました'); } }; const toggleFieldSelection = (fieldId: number) => { setSelectedFields((prev) => { const next = new Set(prev); if (next.has(fieldId)) next.delete(fieldId); else next.add(fieldId); return next; }); }; const toggleAllFields = () => { if (selectedFields.size === filteredFields.length) { setSelectedFields(new Set()); } else { setSelectedFields(new Set(filteredFields.map((f) => f.id))); } }; const handleBulkUpdate = async () => { if (selectedFields.size === 0 || !bulkCropId) return; if (!confirm(`選択した${selectedFields.size}件の圃場に一括で作物・品種を設定します。\n既存の設定は上書きされます。実行しますか?`)) return; setBulkUpdating(true); try { await api.post('/plans/bulk_update/', { field_ids: Array.from(selectedFields), year, crop: bulkCropId, variety: bulkVarietyId || null, }); setSelectedFields(new Set()); setBulkCropId(0); setBulkVarietyId(0); await fetchData(); } catch (error) { console.error('Bulk update failed:', error); alert('一括更新に失敗しました'); } finally { setBulkUpdating(false); } }; const handleCopyFromPreviousYear = async () => { const fromYear = year - 1; if (!confirm(`${fromYear}年度の作付け計画を${year}年度にコピーします。\n既に設定済みの圃場はスキップされます。\n実行しますか?`)) return; setCopying(true); try { const res = await api.post('/plans/copy_from_previous_year/', { from_year: fromYear, to_year: year, }); alert(res.data.message || 'コピーが完了しました'); await fetchData(); } catch (error: any) { console.error('Failed to copy:', error); alert(error.response?.data?.error || 'コピーに失敗しました'); } finally { setCopying(false); } }; const getVarietiesForCrop = (cropId: number) => { const crop = crops.find((c) => c.id === cropId); return crop?.varieties || []; }; const toggleCropExpand = (cropId: number) => { setExpandedCrops((prev) => { const next = new Set(prev); if (next.has(cropId)) { next.delete(cropId); } else { next.add(cropId); } return next; }); }; const renderSidebar = () => ( <>
{showSidebar &&

集計

}
{showSidebar && (
合計面積
{summary.totalAreaTan.toFixed(2)} 反
({Math.round(summary.totalAreaTan * 1000)} m²)
{summary.unassignedAreaTan > 0 && (
未設定
{summary.unassignedAreaTan.toFixed(2)} 反
)}
{summary.items.map((item) => (
{expandedCrops.has(item.cropId) && (
{item.varieties.map((v, idx) => (
{v.varietyName} {v.areaTan.toFixed(2)} 反
))}
)}
))}
)} ); if (loading) { return (
読み込み中...
); } return (
{/* PC用サイドバー */}
{renderSidebar()}
{/* メインコンテンツ */}
{toast && (
{toast.message}
)}

作付け計画 {year}年度

{/* スマホ用集計ボタン */}
{/* 検索・フィルタバー */}
setSearchText(e.target.value)} placeholder="圃場名・住所で検索..." className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500" />
{(searchText || filterCropId || filterUnassigned) && ( {filteredFields.length}/{fields.length}件 )}
{filteredFields.length === 0 && fields.length > 0 ? (

条件に一致する圃場がありません。

) : sortedFields.length === 0 ? (

圃場データがありません。インポートを実行してください。

) : ( <> {isPastYear && (

{year}年度のデータを参照中(過去年度)

)}

💡 グループ名を入力して「グループ順」で並び替え、「↑」「↓」ボタンで順序を変更できます

{selectedFields.size > 0 && (
{selectedFields.size}件選択中 {bulkCropId > 0 && ( )}
)}
{sortType === 'custom' && ( )} {filteredFields.map((field, index) => { const plan = getPlanForField(field.id); const selectedCropId = plan?.crop || 0; const selectedVarietyId = plan?.variety || 0; return ( {sortType === 'custom' && ( )} ); })}
0} onChange={toggleAllFields} className="rounded border-gray-300 text-green-600 focus:ring-green-500" /> 順序 グループ 圃場名 面積(反) 作物 品種 備考
toggleFieldSelection(field.id)} className="rounded border-gray-300 text-green-600 focus:ring-green-500" />
handleGroupChange(field.id, e.target.value)} disabled={saving === field.id} placeholder="選択または入力" className="w-36 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50" /> {groupOptions.map((g) => (
{field.name}
{field.address}
{field.area_tan} {addingVariety?.fieldId === field.id ? (
setNewVarietyName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleAddVariety(field.id, addingVariety.cropId); if (e.key === 'Escape') { setAddingVariety(null); setNewVarietyName(''); } }} placeholder="品種名を入力" className="px-2 py-1.5 border border-green-400 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 w-32" autoFocus />
) : (
{plan?.latest_variety_change && (
変更履歴あり
)}
)}
handleNotesChange(field.id, e.target.value) } disabled={saving === field.id || !plan} placeholder="備考を入力" className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm w-full disabled:opacity-50 disabled:bg-gray-100" />
)}
{/* スマホ用モーダル */} {showMobileSummary && (
setShowMobileSummary(false)} />

集計

合計面積
{summary.totalAreaTan.toFixed(2)} 反
({Math.round(summary.totalAreaTan * 1000)} m²)
{summary.unassignedAreaTan > 0 && (
未設定
{summary.unassignedAreaTan.toFixed(2)} 反
)}
{summary.items.map((item) => (
{expandedCrops.has(item.cropId) && (
{item.varieties.map((v, idx) => (
{v.varietyName} {v.areaTan.toFixed(2)} 反
))}
)}
))}
)} {/* 品種管理モーダル */} {showVarietyManager && (

品種管理

{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
    {getVarietiesForCrop(managerCropId).map((v) => (
  • {v.name}
  • ))}
) : (

品種が登録されていません

)}
{ if (!managerCropId) return; try { await api.post('/plans/varieties/', { crop: managerCropId, name }); await fetchData(true); } catch (error: any) { if (error.response?.status === 400) { alert('この品種名は既に登録されています'); } else { alert('品種の追加に失敗しました'); } } }} />
)}
); } function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name: string) => Promise }) { const [name, setName] = useState(''); const [adding, setAdding] = useState(false); const handleSubmit = async () => { const trimmed = name.trim(); if (!trimmed) return; setAdding(true); await onAdd(trimmed); setName(''); setAdding(false); }; return (
setName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }} placeholder="新しい品種名" disabled={!cropId || adding} className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50" />
); } function VarietyDefaultBoxesForm({ varietyId, initialValue, onSave, }: { varietyId: number; initialValue: string; onSave: (varietyId: number, defaultBoxes: string) => Promise; }) { const [value, setValue] = useState(initialValue); const [saving, setSaving] = useState(false); useEffect(() => { setValue(initialValue); }, [initialValue]); const handleSave = async () => { setSaving(true); await onSave(varietyId, value); setSaving(false); }; return (
setValue(e.target.value)} className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" inputMode="decimal" />
); }