'use client'; import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Undo2 } from 'lucide-react'; import Navbar from '@/components/Navbar'; import { api } from '@/lib/api'; import { Crop, FertilizationPlan, Fertilizer, Field, StockSummary } from '@/types'; type CalcMethod = 'per_tan' | 'even' | 'nitrogen'; interface CalcSetting { fertilizer_id: number; method: CalcMethod; param: string; } // field_id → fertilizer_id → bags (string) type Matrix = Record>; const METHOD_LABELS: Record = { per_tan: '反当袋数', even: '均等配分', nitrogen: '反当チッソ', }; const METHOD_UNIT: Record = { per_tan: '袋/反', even: '袋(総数)', nitrogen: 'kg/反 (N)', }; const currentYear = new Date().getFullYear(); export default function FertilizerEditPage({ planId }: { planId?: number }) { const router = useRouter(); const isNew = !planId; // ─── ヘッダー情報 const [name, setName] = useState(''); const [year, setYear] = useState(currentYear); const [varietyId, setVarietyId] = useState(''); // ─── マスタデータ const [crops, setCrops] = useState([]); const [allFertilizers, setAllFertilizers] = useState([]); // ─── 圃場 const [selectedFields, setSelectedFields] = useState([]); const [candidateFields, setCandidateFields] = useState([]); const [showFieldPicker, setShowFieldPicker] = useState(false); const [allFields, setAllFields] = useState([]); // ─── 肥料(計画に使う肥料) const [planFertilizers, setPlanFertilizers] = useState([]); const [calcSettings, setCalcSettings] = useState([]); const [showFertPicker, setShowFertPicker] = useState(false); // ─── マトリクス // calcMatrix: 自動計算の結果(参照用・変更不可の表示値) // adjusted: ユーザーが最終確定した値(保存対象) // roundedColumns: 四捨五入済みの肥料列ID(↩ トグル用) const [calcMatrix, setCalcMatrix] = useState({}); const [adjusted, setAdjusted] = useState({}); const [roundedColumns, setRoundedColumns] = useState>(new Set()); const [stockByMaterialId, setStockByMaterialId] = useState>({}); const [initialPlanTotals, setInitialPlanTotals] = useState>({}); const [isConfirmed, setIsConfirmed] = useState(false); const [confirmedAt, setConfirmedAt] = useState(null); const [loading, setLoading] = useState(!isNew); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); // ─── 初期データ取得 useEffect(() => { const init = async () => { setSaveError(null); try { const [cropsRes, fertsRes, fieldsRes, stockRes] = await Promise.all([ api.get('/plans/crops/'), api.get('/fertilizer/fertilizers/'), api.get('/fields/?ordering=display_order,id'), api.get('/materials/fertilizer-stock/'), ]); setCrops(cropsRes.data); setAllFertilizers(fertsRes.data); setAllFields(fieldsRes.data); setStockByMaterialId( stockRes.data.reduce( (acc: Record, summary: StockSummary) => { acc[summary.material_id] = summary; return acc; }, {} ) ); if (!isNew && planId) { const planRes = await api.get(`/fertilizer/plans/${planId}/`); const plan: FertilizationPlan = planRes.data; setName(plan.name); setYear(plan.year); setVarietyId(plan.variety); setIsConfirmed(plan.is_confirmed); setConfirmedAt(plan.confirmed_at); const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer))); const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id)); setPlanFertilizers(ferts); // 保存済み計算設定を復元、なければデフォルト値 setCalcSettings(ferts.map((f: Fertilizer) => { const saved = plan.calc_settings?.find((s) => s.fertilizer_id === f.id); return saved ? { fertilizer_id: f.id, method: saved.method as CalcMethod, param: saved.param } : { fertilizer_id: f.id, method: 'per_tan' as CalcMethod, param: '' }; })); const fieldIds = Array.from(new Set(plan.entries.map((e) => e.field))); const fields = fieldsRes.data.filter((f: Field) => fieldIds.includes(f.id)); setSelectedFields(fields); // 保存済みの値は adjusted に復元 const newAdjusted: Matrix = {}; plan.entries.forEach((e) => { if (!newAdjusted[e.field]) newAdjusted[e.field] = {}; newAdjusted[e.field][e.fertilizer] = String(e.bags); }); setAdjusted(newAdjusted); setInitialPlanTotals( plan.entries.reduce((acc: Record, entry) => { acc[entry.fertilizer] = (acc[entry.fertilizer] ?? 0) + Number(entry.bags); return acc; }, {}) ); // 保存済み calc_settings でページ開時に自動計算してラベル用 calcMatrix を生成 const validSettings = plan.calc_settings?.filter((s) => s.param) ?? []; if (validSettings.length > 0 && fieldIds.length > 0) { try { const calcResults = await Promise.all( validSettings.map((s) => api.post('/fertilizer/calculate/', { method: s.method, param: parseFloat(s.param), fertilizer_id: s.fertilizer_id, field_ids: fieldIds, }) ) ); const newCalcMatrix: Matrix = {}; validSettings.forEach((s, i) => { const results: { field_id: number; bags: number }[] = calcResults[i].data; results.forEach(({ field_id, bags }) => { if (!newCalcMatrix[field_id]) newCalcMatrix[field_id] = {}; newCalcMatrix[field_id][s.fertilizer_id] = String(bags); }); }); setCalcMatrix(newCalcMatrix); } catch { // 自動計算失敗はサイレントに無視(ラベルなしで表示継続) } } } } catch (e: unknown) { const err = e as { response?: { status?: number; data?: unknown } }; console.error('初期データ取得エラー:', err); setSaveError( `データの読み込みに失敗しました (${err.response?.status ?? 'network error'}): ${JSON.stringify(err.response?.data ?? '')}` ); } finally { setLoading(false); } }; init(); }, [planId, isNew]); // ─── 品種変更時: 候補圃場を取得して selectedFields をリセット const fetchCandidates = useCallback(async (y: number, vId: number) => { try { const res = await api.get(`/fertilizer/candidate_fields/?year=${y}&variety_id=${vId}`); const candidates: Field[] = res.data; setCandidateFields(candidates); if (isNew) setSelectedFields(candidates); } catch (e) { console.error(e); } }, [isNew]); useEffect(() => { if (varietyId && year) { fetchCandidates(year, varietyId as number); } }, [varietyId, year, fetchCandidates]); // ─── 肥料追加・削除 const addFertilizer = (fert: Fertilizer) => { if (isConfirmed) return; if (planFertilizers.find((f) => f.id === fert.id)) return; setPlanFertilizers((prev) => [...prev, fert]); setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]); setShowFertPicker(false); }; const removeFertilizer = (id: number) => { if (isConfirmed) return; setPlanFertilizers((prev) => prev.filter((f) => f.id !== id)); setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id)); const dropCol = (m: Matrix): Matrix => { const next = { ...m }; Object.keys(next).forEach((fid) => { const row = { ...next[Number(fid)] }; delete row[id]; next[Number(fid)] = row; }); return next; }; setCalcMatrix(dropCol); setAdjusted(dropCol); setRoundedColumns((prev) => { const next = new Set(prev); next.delete(id); return next; }); }; // ─── 圃場追加・削除 const addField = (field: Field) => { if (isConfirmed) return; if (selectedFields.find((f) => f.id === field.id)) return; setSelectedFields((prev) => [...prev, field]); setShowFieldPicker(false); }; const removeField = (id: number) => { if (isConfirmed) return; setSelectedFields((prev) => prev.filter((f) => f.id !== id)); setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; }); setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; }); }; const [calcNewOnly, setCalcNewOnly] = useState(true); // ─── 自動計算 const runCalc = async (setting: CalcSetting) => { if (isConfirmed) return; if (!setting.param) { setSaveError('パラメータを入力してください'); return; } if (selectedFields.length === 0) { setSaveError('対象圃場を選択してください'); return; } setSaveError(null); const targetFields = calcNewOnly ? selectedFields.filter((f) => { const adjVal = adjusted[f.id]?.[setting.fertilizer_id]; const calcVal = calcMatrix[f.id]?.[setting.fertilizer_id]; const inputValue = adjVal !== undefined ? adjVal : (calcVal ?? ''); return inputValue === ''; }) : selectedFields; if (targetFields.length === 0) { setSaveError('未入力の圃場がありません。「全圃場」で実行してください。'); return; } try { const res = await api.post('/fertilizer/calculate/', { method: setting.method, param: parseFloat(setting.param), fertilizer_id: setting.fertilizer_id, field_ids: targetFields.map((f) => f.id), }); const results: { field_id: number; bags: number }[] = res.data; // calc値を更新 setCalcMatrix((prev) => { const next = { ...prev }; results.forEach(({ field_id, bags }) => { if (!next[field_id]) next[field_id] = {}; next[field_id][setting.fertilizer_id] = String(bags); }); return next; }); // adjusted は保持する(テキストボックスにDB/確定値を維持し、ラベルに計算結果を表示) } catch (e: unknown) { const err = e as { response?: { data?: { error?: string } } }; setSaveError(err.response?.data?.error ?? '計算に失敗しました'); } }; const updateCalcSetting = (fertId: number, key: keyof CalcSetting, value: string) => { setCalcSettings((prev) => prev.map((s) => (s.fertilizer_id === fertId ? { ...s, [key]: value } : s)) ); }; // ─── セル更新(adjusted を更新) const updateCell = (fieldId: number, fertId: number, value: string) => { if (isConfirmed) return; setAdjusted((prev) => { const next = { ...prev }; if (!next[fieldId]) next[fieldId] = {}; next[fieldId][fertId] = value; return next; }); }; // ─── 列単位で四捨五入 / 元に戻す(トグル) const roundColumn = (fertId: number) => { if (isConfirmed) return; if (roundedColumns.has(fertId)) { // 元に戻す: adjusted からこの列を削除 → calc値が再び表示される setAdjusted((prev) => { const next = { ...prev }; selectedFields.forEach((field) => { if (next[field.id]) { const row = { ...next[field.id] }; delete row[fertId]; next[field.id] = row; } }); return next; }); setRoundedColumns((prev) => { const next = new Set(prev); next.delete(fertId); return next; }); } else { // 四捨五入: calc値(なければ現在の入力値)を整数に丸めて adjusted に書き込む setAdjusted((prev) => { const next = { ...prev }; selectedFields.forEach((field) => { const raw = calcMatrix[field.id]?.[fertId] ?? prev[field.id]?.[fertId]; if (raw !== undefined && raw !== '') { const v = parseFloat(raw); if (!isNaN(v)) { if (!next[field.id]) next[field.id] = {}; next[field.id][fertId] = String(Math.round(v)); } } }); return next; }); setRoundedColumns((prev) => { const next = new Set(prev); next.add(fertId); return next; }); } }; // ─── 集計(adjusted 優先、なければ calc 値) const effectiveValue = (fieldId: number, fertId: number): number => { const adj = adjusted[fieldId]?.[fertId]; const calc = calcMatrix[fieldId]?.[fertId]; const raw = adj !== undefined && adj !== '' ? adj : calc; const v = parseFloat(raw ?? '0'); return isNaN(v) ? 0 : v; }; const rowTotal = (fieldId: number) => planFertilizers.reduce((sum, f) => sum + effectiveValue(fieldId, f.id), 0); const colTotal = (fertId: number) => selectedFields.reduce((sum, f) => sum + effectiveValue(f.id, fertId), 0); const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 0); const getNumericValue = (value: string | null | undefined) => { const parsed = parseFloat(value ?? '0'); return isNaN(parsed) ? 0 : parsed; }; const getStockInfo = (fertilizer: Fertilizer) => fertilizer.material_id ? stockByMaterialId[fertilizer.material_id] ?? null : null; const getPlanAvailableStock = (fertilizer: Fertilizer) => { const stock = getStockInfo(fertilizer); if (!stock) return null; const serverAvailable = getNumericValue(stock.available_stock); const initialReserve = initialPlanTotals[fertilizer.id] ?? 0; const currentPlanTotal = colTotal(fertilizer.id); return serverAvailable + initialReserve - currentPlanTotal; }; const getPlanShortage = (fertilizer: Fertilizer) => { const available = getPlanAvailableStock(fertilizer); if (available === null) return 0; return available < 0 ? Math.abs(available) : 0; }; // ─── 保存(adjusted 優先、なければ calc 値を使用) const handleSave = async () => { setSaveError(null); if (isConfirmed) { setSaveError('確定済みの施肥計画は編集できません。'); return; } if (!name.trim()) { setSaveError('計画名を入力してください'); return; } if (!varietyId) { setSaveError('品種を選択してください'); return; } if (selectedFields.length === 0) { setSaveError('圃場を1つ以上選択してください'); return; } const entries: { field_id: number; fertilizer_id: number; bags: number }[] = []; selectedFields.forEach((field) => { planFertilizers.forEach((fert) => { const adj = adjusted[field.id]?.[fert.id]; const calc = calcMatrix[field.id]?.[fert.id]; const raw = adj !== undefined && adj !== '' ? adj : calc; if (raw) { const v = parseFloat(raw); if (v > 0) entries.push({ field_id: field.id, fertilizer_id: fert.id, bags: v }); } }); }); if (entries.length === 0 && planFertilizers.length > 0) { setSaveError('袋数が1件も入力されていません。計算ボタンを押すか、直接数値を入力してください'); return; } setSaving(true); try { const payload = { name, year, variety: varietyId, calc_settings: calcSettings, entries }; if (isNew) { await api.post('/fertilizer/plans/', payload); } else { await api.put(`/fertilizer/plans/${planId}/`, payload); } router.push('/fertilizer'); } catch (e: unknown) { const err = e as { response?: { data?: unknown } }; console.error(err); setSaveError('保存に失敗しました: ' + JSON.stringify(err.response?.data)); } finally { setSaving(false); } }; // ─── 確定取消 const handleUnconfirm = async () => { if (!planId) return; setSaveError(null); try { await api.post(`/fertilizer/plans/${planId}/unconfirm/`); setIsConfirmed(false); setConfirmedAt(null); // 引当が再作成されるので在庫情報を再取得 const stockRes = await api.get('/materials/fertilizer-stock/'); setStockByMaterialId( stockRes.data.reduce( (acc: Record, summary: StockSummary) => { acc[summary.material_id] = summary; return acc; }, {} ) ); } catch (e) { console.error(e); setSaveError('確定取消に失敗しました'); } }; // ─── PDF出力 const handlePdf = async () => { if (!planId) return; setSaveError(null); try { const res = await api.get(`/fertilizer/plans/${planId}/pdf/`, { responseType: 'blob' }); const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' })); const a = document.createElement('a'); a.href = url; a.download = `施肥計画_${year}_${name}.pdf`; a.click(); URL.revokeObjectURL(url); } catch (e) { console.error(e); setSaveError('PDF出力に失敗しました'); } }; const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i); const availableFerts = allFertilizers.filter((f) => !planFertilizers.find((pf) => pf.id === f.id)); const unselectedFields = allFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id)); if (loading) { return (
読み込み中...
); } return (
{/* ヘッダー */}

{isNew ? '施肥計画 新規作成' : '施肥計画 編集'}

{!isNew && isConfirmed && ( )} {!isNew && ( )}
{/* エラーバナー */} {saveError && (
{saveError}
)} {isConfirmed && (
i この施肥計画は散布確定済みです。 {confirmedAt ? ` 確定日時: ${new Date(confirmedAt).toLocaleString('ja-JP')}` : ''}
)} {/* 基本情報 */}
setName(e.target.value)} placeholder="例: 2025年度 コシヒカリ 元肥" disabled={isConfirmed} />
{/* 対象圃場 */}

対象圃場 {selectedFields.length}筆 / {selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)}反

{selectedFields.length === 0 && (

品種を選択すると作付け計画から圃場が自動抽出されます

)} {selectedFields.map((f) => ( {f.name}({f.area_tan}反) ))}
{/* 自動計算設定パネル */}

自動計算設定

{planFertilizers.length === 0 ? (

肥料を追加してください

) : (
{planFertilizers.map((fert) => { const setting = calcSettings.find((s) => s.fertilizer_id === fert.id); if (!setting) return null; return (
{fert.name} updateCalcSetting(fert.id, 'param', e.target.value)} placeholder="値" disabled={isConfirmed} /> {METHOD_UNIT[setting.method]}
); })}
)}
{/* マトリクス表 */} {selectedFields.length > 0 && planFertilizers.length > 0 && (
{planFertilizers.map((f) => { const isRounded = roundedColumns.has(f.id); const stock = getStockInfo(f); const planAvailable = getPlanAvailableStock(f); const shortage = getPlanShortage(f); return ( ); })} {selectedFields.map((field) => ( {planFertilizers.map((fert) => { const calcVal = calcMatrix[field.id]?.[fert.id]; const adjVal = adjusted[field.id]?.[fert.id]; // 計算結果があればラベルを表示(adjusted が上書きされた場合は参照値として) const showRef = calcVal !== undefined; // 入力欄: adjusted → calc値 → 空 const inputValue = adjVal !== undefined ? adjVal : (calcVal ?? ''); return ( ); })} ))} {planFertilizers.map((f) => ( ))}
圃場名 面積(反)
{f.name}
{stock ? (
在庫 {stock.current_stock}{stock.stock_unit_display} {planAvailable !== null && ( / 利用可能 {planAvailable.toFixed(2)}{stock.stock_unit_display} )}
0 ? 'text-red-600' : 'text-gray-500'}> 計画計 {colTotal(f.id).toFixed(2)}{stock.stock_unit_display} {shortage > 0 && ( / 不足 {shortage.toFixed(2)}{stock.stock_unit_display} )}
) : (
在庫情報なし
)} (袋)
合計袋数
{field.name} {field.area_tan}
{showRef && ( {calcVal} )} updateCell(field.id, fert.id, e.target.value)} placeholder="-" disabled={isConfirmed} />
{rowTotal(field.id) > 0 ? rowTotal(field.id).toFixed(2) : '-'}
合計 {selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)} {colTotal(f.id) > 0 ? colTotal(f.id).toFixed(2) : '-'} {grandTotal > 0 ? grandTotal.toFixed(2) : '-'}
)}
{/* 圃場選択ピッカー */} {showFieldPicker && (

圃場を追加

{candidateFields.length > 0 && ( <>

作付け計画から({year}年度 / 選択品種)

{candidateFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id)).map((f) => ( ))}

その他の圃場

)} {unselectedFields.filter((f) => !candidateFields.find((cf) => cf.id === f.id)).map((f) => ( ))}
)} {/* 肥料選択ピッカー */} {showFertPicker && (

肥料を追加

{availableFerts.length === 0 ? (

追加できる肥料がありません

) : ( availableFerts.map((f) => ( )) )}
)}
); }