'use client'; import { useEffect, useMemo, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { ChevronLeft, Pencil, Plus, Save, Sprout, Trash2, X } from 'lucide-react'; import Navbar from '@/components/Navbar'; import { api } from '@/lib/api'; import { DeliveryPlan, FertilizationPlan, SpreadingCandidate, SpreadingSession, } from '@/types'; const CURRENT_YEAR = new Date().getFullYear(); const YEAR_KEY = 'spreadingYear'; type SourceType = 'delivery' | 'plan' | 'year'; type FormState = { date: string; name: string; notes: string; itemValues: Record; }; type MatrixField = { id: number; name: string; area_tan: string; }; type MatrixFertilizer = { id: number; name: string; }; const candidateKey = (fieldId: number, fertilizerId: number) => `${fieldId}:${fertilizerId}`; const toNumber = (value: string | number | null | undefined) => { const parsed = Number(value ?? 0); return Number.isFinite(parsed) ? parsed : 0; }; const formatDisplay = (value: string | number | null | undefined) => { const num = toNumber(value); if (Number.isInteger(num)) { return String(num); } return num.toFixed(4).replace(/\.?0+$/, ''); }; const formatInputValue = (value: number) => { if (value <= 0) return '0'; return value.toFixed(2).replace(/\.?0+$/, ''); }; 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}`; }; const getSourceType = (deliveryPlanId: number | null, fertilizationPlanId: number | null): SourceType => { if (deliveryPlanId) return 'delivery'; if (fertilizationPlanId) return 'plan'; return 'year'; }; const buildCreateInitialValues = (rows: SpreadingCandidate[], sourceType: SourceType) => { const values: Record = {}; rows.forEach((candidate) => { let base = 0; if (sourceType === 'delivery') { base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other); } else if (sourceType === 'plan') { base = toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other); } else { base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other); } values[candidateKey(candidate.field, candidate.fertilizer)] = formatInputValue(Math.max(base, 0)); }); return values; }; export default function SpreadingPage() { const router = useRouter(); const searchParams = useSearchParams(); const queryYear = Number(searchParams.get('year') || '0') || null; const deliveryPlanId = Number(searchParams.get('delivery_plan') || '0') || null; const fertilizationPlanId = Number(searchParams.get('plan') || '0') || null; const sourceType = getSourceType(deliveryPlanId, fertilizationPlanId); 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 [loading, setLoading] = useState(true); const [formLoading, setFormLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [editingSessionId, setEditingSessionId] = useState(null); const [form, setForm] = useState(null); const [openedFromQuery, setOpenedFromQuery] = useState(false); const [openedFromSource, setOpenedFromSource] = useState(false); const [sourceName, setSourceName] = useState(null); useEffect(() => { if (queryYear && queryYear !== year) { setYear(queryYear); } }, [queryYear, year]); useEffect(() => { localStorage.setItem(YEAR_KEY, String(year)); void fetchSessions(); setForm(null); setEditingSessionId(null); setOpenedFromQuery(false); setOpenedFromSource(false); }, [year]); useEffect(() => { const loadSource = async () => { if (deliveryPlanId) { try { const res = await api.get(`/fertilizer/delivery/${deliveryPlanId}/`); const plan: DeliveryPlan = res.data; setSourceName(plan.name); return; } catch (e) { console.error(e); setSourceName(`運搬計画 #${deliveryPlanId}`); return; } } if (fertilizationPlanId) { try { const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`); const plan: FertilizationPlan = res.data; setSourceName(plan.name); return; } catch (e) { console.error(e); setSourceName(`施肥計画 #${fertilizationPlanId}`); return; } } setSourceName(null); }; void loadSource(); }, [deliveryPlanId, fertilizationPlanId]); useEffect(() => { const sessionParam = searchParams.get('session'); if (!sessionParam || openedFromQuery || sessions.length === 0) { return; } const targetId = Number(sessionParam); if (!targetId) { return; } const target = sessions.find((session) => session.id === targetId); if (target) { void openEditor(target); setOpenedFromQuery(true); } }, [openedFromQuery, searchParams, sessions]); useEffect(() => { const sessionParam = searchParams.get('session'); if (sessionParam || sourceType === 'year' || openedFromSource || form || formLoading) { return; } void startCreate(); setOpenedFromSource(true); }, [form, formLoading, openedFromSource, searchParams, sourceType]); const fetchSessions = async () => { setLoading(true); setError(null); try { const res = await api.get(`/fertilizer/spreading/?year=${year}`); setSessions(res.data); } catch (e) { console.error(e); setError('散布実績の読み込みに失敗しました。'); } finally { setLoading(false); } }; const loadCandidates = async (sessionId?: number) => { const params = new URLSearchParams({ year: String(year) }); if (sessionId) { params.set('session_id', String(sessionId)); } if (deliveryPlanId) { params.set('delivery_plan_id', String(deliveryPlanId)); } if (fertilizationPlanId) { params.set('plan_id', String(fertilizationPlanId)); } const res = await api.get(`/fertilizer/spreading/candidates/?${params.toString()}`); setCandidates(res.data); return res.data as SpreadingCandidate[]; }; const startCreate = async () => { setFormLoading(true); setError(null); try { const loaded = await loadCandidates(); setEditingSessionId(null); setForm({ date: getDefaultDate(year), name: '', notes: '', itemValues: buildCreateInitialValues(loaded, sourceType), }); } catch (e) { console.error(e); setError('散布候補の読み込みに失敗しました。'); } finally { setFormLoading(false); } }; const openEditor = async (session: SpreadingSession) => { setFormLoading(true); setError(null); try { await loadCandidates(session.id); const itemValues = session.items.reduce>((acc, item) => { acc[candidateKey(item.field, item.fertilizer)] = String(item.actual_bags); return acc; }, {}); setEditingSessionId(session.id); setForm({ date: session.date, name: session.name, notes: session.notes, itemValues, }); } catch (e) { console.error(e); setError('散布候補の読み込みに失敗しました。'); } finally { setFormLoading(false); } }; const closeEditor = () => { setEditingSessionId(null); setForm(null); setCandidates([]); }; const candidateMap = useMemo(() => { const map = new Map(); candidates.forEach((candidate) => { map.set(candidateKey(candidate.field, candidate.fertilizer), candidate); }); return map; }, [candidates]); const matrixFields = useMemo(() => { const map = new Map(); candidates.forEach((candidate) => { if (!map.has(candidate.field)) { map.set(candidate.field, { id: candidate.field, name: candidate.field_name, area_tan: candidate.field_area_tan, }); } }); return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja')); }, [candidates]); const matrixFertilizers = useMemo(() => { const map = new Map(); candidates.forEach((candidate) => { if (!map.has(candidate.fertilizer)) { map.set(candidate.fertilizer, { id: candidate.fertilizer, name: candidate.fertilizer_name, }); } }); return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja')); }, [candidates]); const handleItemChange = (fieldId: number, fertilizerId: number, value: string) => { if (!form) return; const key = candidateKey(fieldId, fertilizerId); setForm({ ...form, itemValues: { ...form.itemValues, [key]: value, }, }); }; const getCellValue = (fieldId: number, fertilizerId: number) => { if (!form) return ''; return form.itemValues[candidateKey(fieldId, fertilizerId)] ?? '0'; }; const selectedRows = useMemo(() => { if (!form) return []; return candidates.filter((candidate) => { const value = toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0'); return value > 0; }); }, [candidates, form]); const getRowTotal = (fieldId: number) => { if (!form) return 0; return matrixFertilizers.reduce((sum, fertilizer) => { const candidate = candidateMap.get(candidateKey(fieldId, fertilizer.id)); if (!candidate) return sum; return sum + toNumber(getCellValue(fieldId, fertilizer.id)); }, 0); }; const getColumnTotal = (fertilizerId: number) => { if (!form) return 0; return matrixFields.reduce((sum, field) => { const candidate = candidateMap.get(candidateKey(field.id, fertilizerId)); if (!candidate) return sum; return sum + toNumber(getCellValue(field.id, fertilizerId)); }, 0); }; const totalInputBags = selectedRows.reduce((sum, candidate) => { return sum + toNumber(form?.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0'); }, 0); const handleSave = async () => { if (!form) return; setError(null); if (!form.date) { setError('散布日を入力してください。'); return; } const items = selectedRows.map((candidate) => ({ field_id: candidate.field, fertilizer_id: candidate.fertilizer, actual_bags: toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0'), planned_bags_snapshot: toNumber(candidate.planned_bags), delivered_bags_snapshot: toNumber(candidate.delivered_bags), })); if (items.length === 0) { setError('散布実績を1件以上入力してください。'); return; } setSaving(true); try { const payload = { year, date: form.date, name: form.name, notes: form.notes, items, }; if (editingSessionId) { await api.put(`/fertilizer/spreading/${editingSessionId}/`, payload); } else { await api.post('/fertilizer/spreading/', payload); } await fetchSessions(); closeEditor(); } catch (e) { console.error(e); setError('散布実績の保存に失敗しました。'); } finally { setSaving(false); } }; const handleDelete = async (sessionId: number) => { setError(null); try { await api.delete(`/fertilizer/spreading/${sessionId}/`); await fetchSessions(); if (editingSessionId === sessionId) { closeEditor(); } } catch (e) { console.error(e); setError('散布実績の削除に失敗しました。'); } }; const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i); const sourceSummary = sourceType === 'delivery' ? '初期値は運搬計画値から散布済を引いた値です。' : sourceType === 'plan' ? '初期値は施肥計画値から散布済を引いた値です。' : '初期値は運搬済みから散布済を引いた値です。'; const sourceLabel = sourceType === 'delivery' ? '運搬計画を選択した状態です' : sourceType === 'plan' ? '施肥計画を選択した状態です' : null; const clearFilterHref = `/fertilizer/spreading?year=${year}`; return (

散布実績

{sourceLabel && (
{sourceLabel}
{sourceName ?? (sourceType === 'delivery' ? `運搬計画 #${deliveryPlanId}` : `施肥計画 #${fertilizationPlanId}`)} {' '}を起点に散布候補を絞り込んでいます。
{sourceSummary}
)} {error && (
{error}
)} {(form || formLoading) && (

{editingSessionId ? '散布実績を編集' : '散布実績を登録'}

施肥計画と同じ感覚で、圃場 × 肥料のマトリックスで実績を入力します。

{sourceSummary}

{formLoading || !form ? (
候補を読み込み中...
) : (
setForm({ ...form, date: e.target.value })} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" />
setForm({ ...form, name: e.target.value })} placeholder="例: 3/17 元肥散布" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" />
setForm({ ...form, notes: e.target.value })} placeholder="任意" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" />
{matrixFertilizers.map((fertilizer) => ( ))} {matrixFields.length === 0 ? ( ) : ( matrixFields.map((field) => ( {matrixFertilizers.map((fertilizer) => { const candidate = candidateMap.get(candidateKey(field.id, fertilizer.id)); if (!candidate) { return ( ); } return ( ); })} )) )} {matrixFields.length > 0 && ( {matrixFertilizers.map((fertilizer) => ( ))} )}
圃場
{fertilizer.name}
入力計 {formatDisplay(getColumnTotal(fertilizer.id))}袋
行合計
散布対象の候補がありません。
{field.name}
{field.area_tan}反
-
計画 {formatDisplay(candidate.planned_bags)}
{sourceType === 'plan' ? '計画残' : '未散布'} {formatDisplay( sourceType === 'plan' ? Math.max(toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other), 0) : Math.max(toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other), 0) )}
運搬 {formatDisplay(candidate.delivered_bags)}
散布済 {formatDisplay(candidate.spread_bags_other)}
handleItemChange(field.id, fertilizer.id, e.target.value)} className="w-20 shrink-0 rounded border border-gray-300 px-2 py-1.5 text-right text-sm focus:outline-none focus:ring-2 focus:ring-green-500" />
{formatDisplay(getRowTotal(field.id))}
合計 {formatDisplay(getColumnTotal(fertilizer.id))} {formatDisplay(totalInputBags)}

入力中 {selectedRows.length}件 / 合計 {formatDisplay(totalInputBags)}袋

)}
)}

登録済み散布実績

{loading ? (
読み込み中...
) : sessions.length === 0 ? (
この年度の散布実績はまだありません。
) : (
{sessions.map((session) => { const totalBags = session.items.reduce((sum, item) => sum + toNumber(item.actual_bags), 0); return ( ); })}
散布日 名称 明細数 合計袋数 作業記録
{session.date}
{session.name || '名称なし'}
{session.notes &&
{session.notes}
}
{session.items.length} {formatDisplay(totalBags)} {session.work_record_id ? `#${session.work_record_id}` : '-'}
)}
); }