- getPlanAvailableStock: 自計画の引当を足し戻す計算を廃止し、 サーバー側available_stock + 初期引当 - 現在計画量でリアルタイム算出 - getPlanShortage: available_stockベースの不足判定に変更 - 編集中の計画変更が即座に利用可能数に反映されるように Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
913 lines
39 KiB
TypeScript
913 lines
39 KiB
TypeScript
'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<number, Record<number, string>>;
|
||
|
||
const METHOD_LABELS: Record<CalcMethod, string> = {
|
||
per_tan: '反当袋数',
|
||
even: '均等配分',
|
||
nitrogen: '反当チッソ',
|
||
};
|
||
|
||
const METHOD_UNIT: Record<CalcMethod, string> = {
|
||
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<number | ''>('');
|
||
|
||
// ─── マスタデータ
|
||
const [crops, setCrops] = useState<Crop[]>([]);
|
||
const [allFertilizers, setAllFertilizers] = useState<Fertilizer[]>([]);
|
||
|
||
// ─── 圃場
|
||
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
|
||
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
|
||
const [showFieldPicker, setShowFieldPicker] = useState(false);
|
||
const [allFields, setAllFields] = useState<Field[]>([]);
|
||
|
||
// ─── 肥料(計画に使う肥料)
|
||
const [planFertilizers, setPlanFertilizers] = useState<Fertilizer[]>([]);
|
||
const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([]);
|
||
const [showFertPicker, setShowFertPicker] = useState(false);
|
||
|
||
// ─── マトリクス
|
||
// calcMatrix: 自動計算の結果(参照用・変更不可の表示値)
|
||
// adjusted: ユーザーが最終確定した値(保存対象)
|
||
// roundedColumns: 四捨五入済みの肥料列ID(↩ トグル用)
|
||
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
|
||
const [adjusted, setAdjusted] = useState<Matrix>({});
|
||
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
|
||
const [stockByMaterialId, setStockByMaterialId] = useState<Record<number, StockSummary>>({});
|
||
const [initialPlanTotals, setInitialPlanTotals] = useState<Record<number, number>>({});
|
||
const [isConfirmed, setIsConfirmed] = useState(false);
|
||
const [confirmedAt, setConfirmedAt] = useState<string | null>(null);
|
||
|
||
const [loading, setLoading] = useState(!isNew);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saveError, setSaveError] = useState<string | null>(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<number, StockSummary>, 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<number, number>, 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<number, StockSummary>, 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 (
|
||
<div className="min-h-screen bg-gray-50">
|
||
<Navbar />
|
||
<div className="max-w-7xl mx-auto px-4 py-8 text-gray-500">読み込み中...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
<Navbar />
|
||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||
{/* ヘッダー */}
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div className="flex items-center gap-3">
|
||
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
|
||
<ChevronLeft className="h-5 w-5" />
|
||
</button>
|
||
<h1 className="text-xl font-bold text-gray-800">
|
||
{isNew ? '施肥計画 新規作成' : '施肥計画 編集'}
|
||
</h1>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{!isNew && isConfirmed && (
|
||
<button
|
||
onClick={handleUnconfirm}
|
||
className="flex items-center gap-2 px-4 py-2 border border-amber-300 rounded-lg text-sm text-amber-700 hover:bg-amber-50"
|
||
>
|
||
<Undo2 className="h-4 w-4" />
|
||
確定取消
|
||
</button>
|
||
)}
|
||
{!isNew && (
|
||
<button
|
||
onClick={handlePdf}
|
||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
|
||
>
|
||
<FileDown className="h-4 w-4" />
|
||
PDF出力
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving || isConfirmed}
|
||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<Save className="h-4 w-4" />
|
||
{isConfirmed ? '確定済み' : saving ? '保存中...' : '保存'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* エラーバナー */}
|
||
{saveError && (
|
||
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
|
||
<span className="font-bold shrink-0">⚠</span>
|
||
<span>{saveError}</span>
|
||
<button onClick={() => setSaveError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600">✕</button>
|
||
</div>
|
||
)}
|
||
|
||
{isConfirmed && (
|
||
<div className="mb-4 flex items-start gap-2 bg-sky-50 border border-sky-300 text-sky-800 rounded-lg px-4 py-3 text-sm">
|
||
<span className="font-bold shrink-0">i</span>
|
||
<span>
|
||
この施肥計画は散布確定済みです。
|
||
{confirmedAt ? ` 確定日時: ${new Date(confirmedAt).toLocaleString('ja-JP')}` : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 基本情報 */}
|
||
<div className="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||
<div className="flex-1 min-w-48">
|
||
<label className="block text-xs font-medium text-gray-600 mb-1">計画名</label>
|
||
<input
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="例: 2025年度 コシヒカリ 元肥"
|
||
disabled={isConfirmed}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 mb-1">年度</label>
|
||
<select
|
||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||
value={year}
|
||
onChange={(e) => setYear(parseInt(e.target.value))}
|
||
disabled={isConfirmed}
|
||
>
|
||
{years.map((y) => <option key={y} value={y}>{y}年度</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="min-w-48">
|
||
<label className="block text-xs font-medium text-gray-600 mb-1">品種</label>
|
||
<select
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||
value={varietyId}
|
||
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
|
||
disabled={isConfirmed}
|
||
>
|
||
<option value="">品種を選択</option>
|
||
{crops.map((crop) => (
|
||
<optgroup key={crop.id} label={crop.name}>
|
||
{crop.varieties.map((v) => (
|
||
<option key={v.id} value={v.id}>{v.name}</option>
|
||
))}
|
||
</optgroup>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 対象圃場 */}
|
||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-sm font-semibold text-gray-700">
|
||
対象圃場
|
||
<span className="ml-2 text-gray-400 font-normal">
|
||
{selectedFields.length}筆 /
|
||
{selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)}反
|
||
</span>
|
||
</h2>
|
||
<button
|
||
onClick={() => setShowFieldPicker(true)}
|
||
disabled={isConfirmed}
|
||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
<Plus className="h-3 w-3" />圃場を追加
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedFields.length === 0 && (
|
||
<p className="text-sm text-gray-400">
|
||
品種を選択すると作付け計画から圃場が自動抽出されます
|
||
</p>
|
||
)}
|
||
{selectedFields.map((f) => (
|
||
<span
|
||
key={f.id}
|
||
className="flex items-center gap-1 bg-green-50 border border-green-200 rounded-full px-3 py-1 text-xs text-green-800"
|
||
>
|
||
{f.name}({f.area_tan}反)
|
||
<button
|
||
onClick={() => removeField(f.id)}
|
||
disabled={isConfirmed}
|
||
className="text-green-400 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 自動計算設定パネル */}
|
||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-sm font-semibold text-gray-700">自動計算設定</h2>
|
||
<div className="flex items-center gap-3">
|
||
<label className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={calcNewOnly}
|
||
onChange={(e) => setCalcNewOnly(e.target.checked)}
|
||
disabled={isConfirmed}
|
||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
未入力圃場のみ
|
||
</label>
|
||
<button
|
||
onClick={() => setShowFertPicker(true)}
|
||
disabled={isConfirmed}
|
||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
<Plus className="h-3 w-3" />肥料を追加
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{planFertilizers.length === 0 ? (
|
||
<p className="text-sm text-gray-400">肥料を追加してください</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{planFertilizers.map((fert) => {
|
||
const setting = calcSettings.find((s) => s.fertilizer_id === fert.id);
|
||
if (!setting) return null;
|
||
return (
|
||
<div key={fert.id} className="flex items-center gap-3 py-2 border-b last:border-b-0">
|
||
<span className="font-medium text-sm w-40 truncate" title={fert.name}>
|
||
{fert.name}
|
||
</span>
|
||
<select
|
||
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||
value={setting.method}
|
||
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
|
||
disabled={isConfirmed}
|
||
>
|
||
{Object.entries(METHOD_LABELS).map(([k, v]) => (
|
||
<option key={k} value={k}>{v}</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
className="border border-gray-300 rounded px-2 py-1 text-xs w-24 text-right"
|
||
value={setting.param}
|
||
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
|
||
placeholder="値"
|
||
disabled={isConfirmed}
|
||
/>
|
||
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
|
||
<button
|
||
onClick={() => runCalc(setting)}
|
||
disabled={isConfirmed}
|
||
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
<Calculator className="h-3 w-3" />計算
|
||
</button>
|
||
<button
|
||
onClick={() => removeFertilizer(fert.id)}
|
||
disabled={isConfirmed}
|
||
className="ml-auto text-gray-300 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* マトリクス表 */}
|
||
{selectedFields.length > 0 && planFertilizers.length > 0 && (
|
||
<div className="bg-white rounded-lg shadow overflow-x-auto">
|
||
<table className="w-full text-sm border-collapse">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="text-left px-4 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">圃場名</th>
|
||
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">面積(反)</th>
|
||
{planFertilizers.map((f) => {
|
||
const isRounded = roundedColumns.has(f.id);
|
||
const stock = getStockInfo(f);
|
||
const planAvailable = getPlanAvailableStock(f);
|
||
const shortage = getPlanShortage(f);
|
||
return (
|
||
<th key={f.id} className="text-center px-3 py-2 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">
|
||
<div>{f.name}</div>
|
||
{stock ? (
|
||
<div className="mt-1 space-y-0.5 text-[11px] font-normal leading-4">
|
||
<div className="text-gray-500">
|
||
在庫 {stock.current_stock}{stock.stock_unit_display}
|
||
{planAvailable !== null && (
|
||
<span className="ml-1">
|
||
/ 利用可能 {planAvailable.toFixed(2)}{stock.stock_unit_display}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className={shortage > 0 ? 'text-red-600' : 'text-gray-500'}>
|
||
計画計 {colTotal(f.id).toFixed(2)}{stock.stock_unit_display}
|
||
{shortage > 0 && (
|
||
<span className="ml-1">/ 不足 {shortage.toFixed(2)}{stock.stock_unit_display}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="mt-1 text-[11px] font-normal text-amber-600">
|
||
在庫情報なし
|
||
</div>
|
||
)}
|
||
<span className="flex items-center justify-center gap-1.5 text-xs font-normal text-gray-400 mt-0.5">
|
||
(袋)
|
||
<button
|
||
onClick={() => roundColumn(f.id)}
|
||
disabled={isConfirmed}
|
||
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
|
||
isRounded
|
||
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
|
||
: 'bg-blue-100 text-blue-500 hover:bg-blue-200'
|
||
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||
title={isRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
|
||
>
|
||
{isRounded ? '↩' : '≈'}
|
||
</button>
|
||
</span>
|
||
</th>
|
||
);
|
||
})}
|
||
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">合計袋数</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{selectedFields.map((field) => (
|
||
<tr key={field.id} className="hover:bg-gray-50">
|
||
<td className="px-4 py-2 border border-gray-200 whitespace-nowrap">{field.name}</td>
|
||
<td className="px-3 py-2 border border-gray-200 text-right text-gray-600">{field.area_tan}</td>
|
||
{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 (
|
||
<td key={fert.id} className="px-2 py-1 border border-gray-200">
|
||
<div className="flex items-center justify-end gap-1.5">
|
||
{showRef && (
|
||
<span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
|
||
)}
|
||
<input
|
||
type="number"
|
||
step="0.1"
|
||
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
|
||
value={inputValue}
|
||
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
||
placeholder="-"
|
||
disabled={isConfirmed}
|
||
/>
|
||
</div>
|
||
</td>
|
||
);
|
||
})}
|
||
<td className="px-3 py-2 border border-gray-200 text-right font-medium">
|
||
{rowTotal(field.id) > 0 ? rowTotal(field.id).toFixed(2) : '-'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot className="bg-gray-50 font-semibold">
|
||
<tr>
|
||
<td className="px-4 py-2 border border-gray-200">合計</td>
|
||
<td className="px-3 py-2 border border-gray-200 text-right text-gray-500">
|
||
{selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)}
|
||
</td>
|
||
{planFertilizers.map((f) => (
|
||
<td key={f.id} className="px-3 py-2 border border-gray-200 text-right">
|
||
{colTotal(f.id) > 0 ? colTotal(f.id).toFixed(2) : '-'}
|
||
</td>
|
||
))}
|
||
<td className="px-3 py-2 border border-gray-200 text-right text-green-700">
|
||
{grandTotal > 0 ? grandTotal.toFixed(2) : '-'}
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 圃場選択ピッカー */}
|
||
{showFieldPicker && (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||
<div className="flex items-center justify-between p-4 border-b">
|
||
<h3 className="font-bold">圃場を追加</h3>
|
||
<button onClick={() => setShowFieldPicker(false)}><X className="h-5 w-5 text-gray-400" /></button>
|
||
</div>
|
||
<div className="overflow-y-auto flex-1 p-2">
|
||
{candidateFields.length > 0 && (
|
||
<>
|
||
<p className="text-xs text-gray-500 px-2 py-1">作付け計画から({year}年度 / 選択品種)</p>
|
||
{candidateFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id)).map((f) => (
|
||
<button
|
||
key={f.id}
|
||
onClick={() => addField(f)}
|
||
disabled={isConfirmed}
|
||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
<span>{f.name}</span>
|
||
<span className="text-gray-400">{f.area_tan}反</span>
|
||
</button>
|
||
))}
|
||
<hr className="my-2" />
|
||
<p className="text-xs text-gray-500 px-2 py-1">その他の圃場</p>
|
||
</>
|
||
)}
|
||
{unselectedFields.filter((f) => !candidateFields.find((cf) => cf.id === f.id)).map((f) => (
|
||
<button
|
||
key={f.id}
|
||
onClick={() => addField(f)}
|
||
disabled={isConfirmed}
|
||
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
<span>{f.name}</span>
|
||
<span className="text-gray-400">{f.area_tan}反</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 肥料選択ピッカー */}
|
||
{showFertPicker && (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||
<div className="flex items-center justify-between p-4 border-b">
|
||
<h3 className="font-bold">肥料を追加</h3>
|
||
<button onClick={() => setShowFertPicker(false)}><X className="h-5 w-5 text-gray-400" /></button>
|
||
</div>
|
||
<div className="overflow-y-auto flex-1 p-2">
|
||
{availableFerts.length === 0 ? (
|
||
<p className="text-sm text-gray-400 px-3 py-4">追加できる肥料がありません</p>
|
||
) : (
|
||
availableFerts.map((f) => (
|
||
<button
|
||
key={f.id}
|
||
onClick={() => addFertilizer(f)}
|
||
disabled={isConfirmed}
|
||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
<span className="font-medium">{f.name}</span>
|
||
{f.maker && <span className="ml-2 text-gray-400 text-xs">{f.maker}</span>}
|
||
{f.nitrogen_pct && (
|
||
<span className="ml-2 text-blue-500 text-xs">N:{f.nitrogen_pct}%</span>
|
||
)}
|
||
</button>
|
||
))
|
||
)}
|
||
<div className="border-t mt-2 pt-2">
|
||
<button
|
||
onClick={() => { setShowFertPicker(false); router.push('/fertilizer/masters'); }}
|
||
className="w-full text-left px-3 py-2 text-xs text-green-600 hover:bg-green-50 rounded"
|
||
>
|
||
+ 新しい肥料をマスタに登録する
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|