Issue #2 に最新の理解を記録し、その内容で仕様書と実装を修正しました。

document/16_マスタードキュメント_田植え計画編.md は、「行ごとに保持するのは圃場の苗箱数」「列側に反当苗箱枚数を持つ」「種もみg/箱 は全体共通値」という前提に更新しています。コード側は backend/apps/plans/models.py と backend/apps/plans/serializers.py で計画ヘッダに seedling_boxes_per_tan を追加し、backend/apps/plans/migrations/0007_ricetransplantplan_seedling_boxes_per_tan.py も作成しました。画面は frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx を施肥計画寄りに組み直し、列単位のデフォルト反映と四捨五入、行ごとの苗箱数入力に寄せています。frontend/src/types/index.ts も合わせて更新済みです。

確認できたのはバックエンドの構文チェックまでで、python3 -m py_compile backend/apps/plans/models.py backend/apps/plans/serializers.py backend/apps/plans/views.py は通過しています。フロントのビルド確認まではこの環境では回していません。Issue #2 にも今回の反映内容をコメント済みです。
This commit is contained in:
akira
2026-04-05 10:53:24 +09:00
parent 95c90dd699
commit 11b36b28a5
6 changed files with 275 additions and 219 deletions

View File

@@ -8,12 +8,7 @@ import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Crop, Field, RiceTransplantPlan } from '@/types';
type EntryInput = {
installed_seedling_boxes: string;
seed_grams_per_box: string;
};
type EntryMap = Record<number, EntryInput>;
type BoxMap = Record<number, string>;
const currentYear = new Date().getFullYear();
@@ -24,6 +19,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
const [name, setName] = useState('');
const [year, setYear] = useState(currentYear);
const [varietyId, setVarietyId] = useState<number | ''>('');
const [seedlingBoxesPerTan, setSeedlingBoxesPerTan] = useState('');
const [defaultSeedGramsPerBox, setDefaultSeedGramsPerBox] = useState('200');
const [notes, setNotes] = useState('');
@@ -31,7 +27,10 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
const [allFields, setAllFields] = useState<Field[]>([]);
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
const [entries, setEntries] = useState<EntryMap>({});
const [calcBoxes, setCalcBoxes] = useState<BoxMap>({});
const [adjustedBoxes, setAdjustedBoxes] = useState<BoxMap>({});
const [applyToEmptyOnly, setApplyToEmptyOnly] = useState(true);
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
@@ -47,19 +46,10 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
return crops.find((crop) => crop.varieties.some((variety) => variety.id === varietyId)) ?? null;
};
const calculateDefaultBoxes = (fieldId: number) => {
const field = allFields.find((item) => item.id === fieldId);
const variety = varietyId ? getVariety(varietyId) : null;
const areaTan = parseFloat(field?.area_tan ?? '0');
const defaultBoxesPerTan = parseFloat(variety?.default_seedling_boxes_per_tan ?? '0');
return (areaTan * defaultBoxesPerTan).toFixed(2);
};
const initializeEntry = (fieldId: number, nextDefaultSeedGramsPerBox = defaultSeedGramsPerBox) => {
return {
installed_seedling_boxes: calculateDefaultBoxes(fieldId),
seed_grams_per_box: nextDefaultSeedGramsPerBox,
};
const calculateDefaultBoxes = (field: Field, perTan: string) => {
const areaTan = parseFloat(field.area_tan || '0');
const boxesPerTan = parseFloat(perTan || '0');
return isNaN(areaTan * boxesPerTan) ? '' : (areaTan * boxesPerTan).toFixed(2);
};
useEffect(() => {
@@ -79,6 +69,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
setName(plan.name);
setYear(plan.year);
setVarietyId(plan.variety);
setSeedlingBoxesPerTan(plan.seedling_boxes_per_tan);
setDefaultSeedGramsPerBox(plan.default_seed_grams_per_box);
setNotes(plan.notes);
@@ -86,15 +77,15 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
const planFields = fieldsRes.data.filter((field: Field) => fieldIds.has(field.id));
setSelectedFields(planFields);
setCandidateFields(planFields);
setEntries(
plan.entries.reduce((acc: EntryMap, entry) => {
acc[entry.field] = {
installed_seedling_boxes: String(entry.installed_seedling_boxes),
seed_grams_per_box: String(entry.seed_grams_per_box),
};
return acc;
}, {})
);
const nextAdjusted: BoxMap = {};
const nextCalc: BoxMap = {};
plan.entries.forEach((entry) => {
nextAdjusted[entry.field] = String(entry.installed_seedling_boxes);
nextCalc[entry.field] = String(entry.default_seedling_boxes);
});
setAdjustedBoxes(nextAdjusted);
setCalcBoxes(nextCalc);
}
} catch (e) {
console.error(e);
@@ -115,15 +106,6 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
setCandidateFields(nextCandidates);
if (isNew) {
setSelectedFields(nextCandidates);
setEntries((prev) => {
const next = { ...prev };
nextCandidates.forEach((field) => {
if (!next[field.id]) {
next[field.id] = initializeEntry(field.id);
}
});
return next;
});
}
} catch (e) {
console.error(e);
@@ -133,62 +115,84 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
fetchCandidates();
}, [varietyId, year, isNew, loading]);
const updateEntry = (fieldId: number, key: keyof EntryInput, value: string) => {
setEntries((prev) => ({
useEffect(() => {
if (!varietyId) return;
const variety = getVariety(varietyId);
if (!variety) return;
if (isNew || seedlingBoxesPerTan === '') {
setSeedlingBoxesPerTan(variety.default_seedling_boxes_per_tan);
}
}, [varietyId, crops, isNew]);
useEffect(() => {
const nextCalc: BoxMap = {};
selectedFields.forEach((field) => {
nextCalc[field.id] = calculateDefaultBoxes(field, seedlingBoxesPerTan);
});
setCalcBoxes(nextCalc);
}, [selectedFields, seedlingBoxesPerTan]);
const addField = (field: Field) => {
if (selectedFields.some((selected) => selected.id === field.id)) return;
setSelectedFields((prev) => [...prev, field]);
};
const removeField = (fieldId: number) => {
setSelectedFields((prev) => prev.filter((field) => field.id !== fieldId));
setCalcBoxes((prev) => {
const next = { ...prev };
delete next[fieldId];
return next;
});
setAdjustedBoxes((prev) => {
const next = { ...prev };
delete next[fieldId];
return next;
});
};
const updateBoxCount = (fieldId: number, value: string) => {
setAdjustedBoxes((prev) => ({
...prev,
[fieldId]: {
...(prev[fieldId] ?? initializeEntry(fieldId)),
[key]: value,
},
[fieldId]: value,
}));
};
const applyDefaultsToSelected = () => {
setEntries((prev) => {
const applyColumnDefaults = () => {
setAdjustedBoxes((prev) => {
const next = { ...prev };
selectedFields.forEach((field) => {
next[field.id] = initializeEntry(field.id, defaultSeedGramsPerBox);
const currentValue = prev[field.id] ?? '';
if (applyToEmptyOnly && currentValue !== '') return;
next[field.id] = calcBoxes[field.id] ?? '';
});
return next;
});
};
const addField = (field: Field) => {
if (selectedFields.some((selected) => selected.id === field.id)) return;
setSelectedFields((prev) => [...prev, field]);
setEntries((prev) => ({
...prev,
[field.id]: prev[field.id] ?? initializeEntry(field.id),
}));
const roundColumn = () => {
setAdjustedBoxes((prev) => {
const next = { ...prev };
selectedFields.forEach((field) => {
const raw = prev[field.id] !== undefined && prev[field.id] !== '' ? prev[field.id] : calcBoxes[field.id];
if (!raw) return;
const value = parseFloat(raw);
if (isNaN(value)) return;
next[field.id] = String(Math.round(value));
});
return next;
});
};
const removeField = (fieldId: number) => {
setSelectedFields((prev) => prev.filter((field) => field.id !== fieldId));
const effectiveBoxes = (fieldId: number) => {
const raw = adjustedBoxes[fieldId] !== undefined && adjustedBoxes[fieldId] !== '' ? adjustedBoxes[fieldId] : calcBoxes[fieldId];
const value = parseFloat(raw ?? '0');
return isNaN(value) ? 0 : value;
};
const fieldRows = useMemo(
() =>
selectedFields.map((field) => ({
field,
entry: entries[field.id] ?? initializeEntry(field.id),
defaultBoxes: calculateDefaultBoxes(field.id),
})),
[selectedFields, entries, varietyId, defaultSeedGramsPerBox, allFields]
);
const calculateBoxes = (_field: Field, entry: EntryInput) => {
const installedBoxes = parseFloat(entry.installed_seedling_boxes || '0');
return installedBoxes;
};
const calculateSeedKg = (field: Field, entry: EntryInput) => {
const boxes = calculateBoxes(field, entry);
const gramsPerBox = parseFloat(entry.seed_grams_per_box || '0');
return (boxes * gramsPerBox) / 1000;
};
const totalBoxes = fieldRows.reduce((sum, row) => sum + calculateBoxes(row.field, row.entry), 0);
const totalSeedKg = fieldRows.reduce((sum, row) => sum + calculateSeedKg(row.field, row.entry), 0);
const totalBoxes = selectedFields.reduce((sum, field) => sum + effectiveBoxes(field.id), 0);
const seedGrams = parseFloat(defaultSeedGramsPerBox || '0');
const totalSeedKg = seedGrams > 0 ? (totalBoxes * seedGrams) / 1000 : 0;
const cropSeedInventoryKg = parseFloat(getSelectedCrop()?.seed_inventory_kg ?? '0');
const remainingSeedKg = cropSeedInventoryKg - totalSeedKg;
@@ -207,17 +211,19 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
return;
}
const entries = selectedFields.map((field) => ({
field_id: field.id,
installed_seedling_boxes: effectiveBoxes(field.id).toFixed(2),
}));
const payload = {
name,
year,
variety: varietyId,
default_seed_grams_per_box: defaultSeedGramsPerBox,
seedling_boxes_per_tan: seedlingBoxesPerTan || '0',
default_seed_grams_per_box: defaultSeedGramsPerBox || '0',
notes,
entries: selectedFields.map((field) => ({
field_id: field.id,
installed_seedling_boxes: entries[field.id]?.installed_seedling_boxes ?? initializeEntry(field.id).installed_seedling_boxes,
seed_grams_per_box: entries[field.id]?.seed_grams_per_box ?? defaultSeedGramsPerBox,
})),
entries,
};
setSaving(true);
@@ -240,6 +246,16 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
(field) => !selectedFields.some((selected) => selected.id === field.id)
);
const fieldRows = useMemo(
() =>
selectedFields.map((field) => ({
field,
defaultBoxes: calcBoxes[field.id] ?? '',
boxCount: adjustedBoxes[field.id] !== undefined && adjustedBoxes[field.id] !== '' ? adjustedBoxes[field.id] : calcBoxes[field.id] ?? '',
})),
[selectedFields, calcBoxes, adjustedBoxes]
);
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
@@ -278,8 +294,8 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
</div>
)}
<div className="mb-4 grid gap-4 rounded-lg bg-white p-4 shadow md:grid-cols-2 xl:grid-cols-4">
<div>
<div className="mb-4 grid gap-4 rounded-lg bg-white p-4 shadow md:grid-cols-2 xl:grid-cols-5">
<div className="xl:col-span-2">
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={name}
@@ -323,55 +339,101 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">1(g) </label>
<label className="mb-1 block text-xs font-medium text-gray-600">1(g)</label>
<input
value={defaultSeedGramsPerBox}
onChange={(e) => setDefaultSeedGramsPerBox(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"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-right focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</div>
</div>
<div className="mb-4 rounded-lg bg-white p-4 shadow">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800"></h2>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600">
<input
type="checkbox"
checked={applyToEmptyOnly}
onChange={(e) => setApplyToEmptyOnly(e.target.checked)}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</label>
</div>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{selectedFields.map((field) => (
<button
key={field.id}
onClick={() => removeField(field.id)}
className="rounded-full border border-green-200 bg-green-50 px-3 py-1 text-xs text-green-800"
>
{field.name} ×
</button>
))}
{selectedFields.length === 0 && <p className="text-sm text-gray-500"></p>}
</div>
{unselectedFields.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-gray-500"></p>
<div className="flex flex-wrap gap-2">
{unselectedFields.map((field) => (
<button
key={field.id}
onClick={() => addField(field)}
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-gray-700 hover:bg-gray-100"
>
{field.name}
</button>
))}
</div>
</div>
)}
</div>
<div className="mb-4 rounded-lg bg-white p-4 shadow">
<div className="mb-3 flex flex-wrap items-end gap-4">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={seedlingBoxesPerTan}
onChange={(e) => setSeedlingBoxesPerTan(e.target.value)}
className="w-28 rounded-lg border border-gray-300 px-3 py-2 text-sm text-right focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</div>
<button
type="button"
onClick={applyColumnDefaults}
className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
</button>
<button
type="button"
onClick={roundColumn}
className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
</button>
</div>
<p className="text-xs text-gray-500"> `反当苗箱枚数 × 面積(反)` </p>
</div>
<div className="mb-4 grid gap-4 md:grid-cols-[2fr,1fr]">
<div className="rounded-lg bg-white p-4 shadow">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800"></h2>
<button
onClick={applyDefaultsToSelected}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
>
</button>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{selectedFields.map((field) => (
<button
key={field.id}
onClick={() => removeField(field.id)}
className="rounded-full border border-green-200 bg-green-50 px-3 py-1 text-xs text-green-800"
>
{field.name} ×
</button>
))}
{selectedFields.length === 0 && <p className="text-sm text-gray-500"></p>}
</div>
{unselectedFields.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-gray-500"></p>
<div className="flex flex-wrap gap-2">
{unselectedFields.map((field) => (
<button
key={field.id}
onClick={() => addField(field)}
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-gray-700 hover:bg-gray-100"
>
{field.name}
</button>
))}
</div>
</div>
)}
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
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"
/>
</div>
<div className="rounded-lg bg-white p-4 shadow">
@@ -386,7 +448,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
<span>{totalBoxes.toFixed(2)}</span>
</div>
<div className="flex justify-between text-gray-600">
<span></span>
<span></span>
<span>{totalSeedKg.toFixed(3)}kg</span>
</div>
<div className="flex justify-between text-gray-600">
@@ -401,66 +463,38 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
</div>
</div>
<div className="mb-4 rounded-lg bg-white p-4 shadow">
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
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"
/>
</div>
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="w-full text-sm">
<thead className="border-b bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700">()</th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700">g/</th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700">kg</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{fieldRows.map(({ field, entry, defaultBoxes }) => (
<tr key={field.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{field.name}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{field.area_tan}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<span className="text-xs tabular-nums text-gray-500">{defaultBoxes}</span>
<button
type="button"
onClick={() => updateEntry(field.id, 'installed_seedling_boxes', defaultBoxes)}
className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-100"
>
</button>
</div>
</td>
<td className="px-4 py-3">
<input
value={entry.installed_seedling_boxes}
onChange={(e) => updateEntry(field.id, 'installed_seedling_boxes', e.target.value)}
className="w-24 rounded-md border border-gray-300 px-2 py-1 text-right focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</td>
<td className="px-4 py-3">
<input
value={entry.seed_grams_per_box}
onChange={(e) => updateEntry(field.id, 'seed_grams_per_box', e.target.value)}
className="w-24 rounded-md border border-gray-300 px-2 py-1 text-right focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-700">{calculateBoxes(field, entry).toFixed(2)}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-700">{calculateSeedKg(field, entry).toFixed(3)}</td>
</tr>
))}
{fieldRows.map(({ field, defaultBoxes, boxCount }) => {
const seedKg = (effectiveBoxes(field.id) * seedGrams) / 1000;
return (
<tr key={field.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{field.name}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{field.area_tan}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-3">
<span className="text-xs tabular-nums text-gray-500"> {defaultBoxes || '0.00'}</span>
<input
value={boxCount}
onChange={(e) => updateBoxCount(field.id, e.target.value)}
className="w-24 rounded-md border border-gray-300 px-2 py-1 text-right focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</div>
</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-700">{seedKg.toFixed(3)}</td>
</tr>
);
})}
</tbody>
</table>
</div>

View File

@@ -173,7 +173,6 @@ export interface RiceTransplantEntry {
field_name?: string;
field_area_tan?: string;
installed_seedling_boxes: string;
seed_grams_per_box: string;
default_seedling_boxes: string;
planned_boxes: string;
planned_seed_kg: string;
@@ -187,6 +186,7 @@ export interface RiceTransplantPlan {
variety_name: string;
crop_name: string;
default_seed_grams_per_box: string;
seedling_boxes_per_tan: string;
notes: string;
entries: RiceTransplantEntry[];
field_count: number;