Files
keinasystem/frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx
Akira d9a4bd19eb 施肥計画の「利用可能」表示を修正: 在庫の実残数を正しく表示
- getPlanAvailableStock: 自計画の引当を足し戻す計算を廃止し、
  サーバー側available_stock + 初期引当 - 現在計画量でリアルタイム算出
- getPlanShortage: available_stockベースの不足判定に変更
- 編集中の計画変更が即座に利用可能数に反映されるように

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:13:41 +09:00

913 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}