施肥計画編集画面に四捨五入トグル機能を追加

- calcMatrix(計算値)+ adjusted(確定値)の2層構成に変更
- 肥料列ヘッダーに ≈(青)/ ↩(琥珀)トグルボタンを追加
- 四捨五入後は元の計算値をグレーで参照表示
- docker-compose.yml に WATCHPACK_POLLING=true を追加(Windowsホットリロード修正)
- マスタードキュメント(文書13)を新 UI 仕様に更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Akira
2026-03-01 13:40:38 +09:00
parent 8ac3a00737
commit cfd67e0d55
3 changed files with 177 additions and 65 deletions

View File

@@ -15,7 +15,7 @@ interface CalcSetting {
param: string;
}
// matrix: field_id → fertilizer_id → bags
// field_id → fertilizer_id → bags (string)
type Matrix = Record<number, Record<number, string>>;
const METHOD_LABELS: Record<CalcMethod, string> = {
@@ -56,8 +56,13 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([]);
const [showFertPicker, setShowFertPicker] = useState(false);
// ─── マトリクス(袋数)
const [matrix, setMatrix] = useState<Matrix>({});
// ─── マトリクス
// calcMatrix: 自動計算の結果(参照用・変更不可の表示値)
// adjusted: ユーザーが最終確定した値(保存対象)
// roundedColumns: 四捨五入済みの肥料列ID↩ トグル用)
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
const [adjusted, setAdjusted] = useState<Matrix>({});
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
@@ -82,7 +87,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
setYear(plan.year);
setVarietyId(plan.variety);
// エントリからマトリクス・肥料リストを復元
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);
@@ -92,12 +96,13 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const fields = fieldsRes.data.filter((f: Field) => fieldIds.includes(f.id));
setSelectedFields(fields);
const newMatrix: Matrix = {};
// 保存済みの値は adjusted に復元calc値はなし
const newAdjusted: Matrix = {};
plan.entries.forEach((e) => {
if (!newMatrix[e.field]) newMatrix[e.field] = {};
newMatrix[e.field][e.fertilizer] = String(e.bags);
if (!newAdjusted[e.field]) newAdjusted[e.field] = {};
newAdjusted[e.field][e.fertilizer] = String(e.bags);
});
setMatrix(newMatrix);
setAdjusted(newAdjusted);
}
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: unknown } };
@@ -116,7 +121,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
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);
@@ -129,7 +133,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
}
}, [varietyId, year, fetchCandidates]);
// ─── 肥料追加
// ─── 肥料追加・削除
const addFertilizer = (fert: Fertilizer) => {
if (planFertilizers.find((f) => f.id === fert.id)) return;
setPlanFertilizers((prev) => [...prev, fert]);
@@ -141,15 +145,18 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
if (!confirm('この肥料を計画から削除しますか?')) return;
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
setMatrix((prev) => {
const next = { ...prev };
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; });
};
// ─── 圃場追加・削除
@@ -161,11 +168,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const removeField = (id: number) => {
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
setMatrix((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; });
};
// ─── 自動計算
@@ -181,7 +185,9 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
field_ids: selectedFields.map((f) => f.id),
});
const results: { field_id: number; bags: number }[] = res.data;
setMatrix((prev) => {
// calc値を更新
setCalcMatrix((prev) => {
const next = { ...prev };
results.forEach(({ field_id, bags }) => {
if (!next[field_id]) next[field_id] = {};
@@ -189,6 +195,20 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
});
return next;
});
// adjusted と丸め状態をリセット(新しい計算結果を再丸めさせる)
setAdjusted((prev) => {
const next = { ...prev };
results.forEach(({ field_id }) => {
if (next[field_id]) {
const row = { ...next[field_id] };
delete row[setting.fertilizer_id];
next[field_id] = row;
}
});
return next;
});
setRoundedColumns((prev) => { const next = new Set(prev); next.delete(setting.fertilizer_id); return next; });
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
alert(err.response?.data?.error ?? '計算に失敗しました');
@@ -201,9 +221,9 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
);
};
// ─── セル更新
// ─── セル更新adjusted を更新)
const updateCell = (fieldId: number, fertId: number, value: string) => {
setMatrix((prev) => {
setAdjusted((prev) => {
const next = { ...prev };
if (!next[fieldId]) next[fieldId] = {};
next[fieldId][fertId] = value;
@@ -211,7 +231,60 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
});
};
// ─── 保存
// ─── 列単位で四捨五入 / 元に戻す(トグル)
const roundColumn = (fertId: number) => {
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 calc = calcMatrix[field.id]?.[fertId];
if (calc !== undefined && calc !== '') {
const v = parseFloat(calc);
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);
// ─── 保存adjusted 優先、なければ calc 値を使用)
const handleSave = async () => {
if (!name.trim()) return alert('計画名を入力してください');
if (!varietyId) return alert('品種を選択してください');
@@ -220,9 +293,12 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const entries: { field_id: number; fertilizer_id: number; bags: number }[] = [];
selectedFields.forEach((field) => {
planFertilizers.forEach((fert) => {
const v = matrix[field.id]?.[fert.id];
if (v && parseFloat(v) > 0) {
entries.push({ field_id: field.id, fertilizer_id: fert.id, bags: parseFloat(v) });
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 });
}
});
});
@@ -261,21 +337,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
}
};
// ─── 集計
const rowTotal = (fieldId: number) =>
planFertilizers.reduce((sum, f) => {
const v = parseFloat(matrix[fieldId]?.[f.id] ?? '0') || 0;
return sum + v;
}, 0);
const colTotal = (fertId: number) =>
selectedFields.reduce((sum, f) => {
const v = parseFloat(matrix[f.id]?.[fertId] ?? '0') || 0;
return sum + v;
}, 0);
const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 0);
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));
@@ -469,12 +530,28 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<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) => (
<th key={f.id} className="text-center px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">
{f.name}
<span className="block text-xs font-normal text-gray-400"></span>
</th>
))}
{planFertilizers.map((f) => {
const isRounded = roundedColumns.has(f.id);
return (
<th key={f.id} className="text-center px-3 py-2 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">
{f.name}
<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)}
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'
}`}
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>
@@ -483,18 +560,31 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<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) => (
<td key={fert.id} className="px-2 py-1 border border-gray-200">
<input
type="number"
step="0.01"
className="w-full text-right border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-green-400 rounded px-1 py-1"
value={matrix[field.id]?.[fert.id] ?? ''}
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
placeholder="-"
/>
</td>
))}
{planFertilizers.map((fert) => {
const calcVal = calcMatrix[field.id]?.[fert.id];
const adjVal = adjusted[field.id]?.[fert.id];
// adjusted が設定されているときだけ灰色参照を表示(丸め後)
const showRef = adjVal !== undefined && 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="-"
/>
</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>