品種ごとの種子在庫前提まで実装を進めました。
主な変更は、seed 資材種別の追加と Variety.seed_material の導入です。backend/apps/materials/models.py、backend/apps/plans/models.py、backend/apps/plans/serializers.py で、田植え計画が作物在庫ではなく品種に紐づく種子資材の現在庫を参照するように切り替えました。マイグレーションは backend/apps/materials/migrations/0005_material_seed_type.py と backend/apps/plans/migrations/0008_variety_seed_material.py を追加しています。 画面側は、frontend/src/app/materials/page.tsx と frontend/src/app/materials/masters/page.tsx に「種子」タブを追加し、frontend/src/app/allocation/page.tsx の品種管理モーダルで品種ごとに種子在庫資材を設定できるようにしました。田植え計画画面 frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx も、苗箱数 列中心に整理し、種もみkg 列を削除、反当苗箱枚数 の列反映と ≈ / ↩ の四捨五入トグルを施肥計画寄りの操作感に寄せています。仕様書 document/16_マスタードキュメント_田植え計画編.md も更新済みです。 確認できたのは python3 -m py_compile backend/apps/materials/models.py backend/apps/materials/serializers.py backend/apps/plans/models.py backend/apps/plans/serializers.py backend/apps/plans/views.py までです。frontend/node_modules が無いためフロントのビルド確認はまだできていません。Issue #2 にも反映内容をコメント済みです。必要なら次にコミットします。
This commit is contained in:
@@ -6,7 +6,7 @@ import { ChevronLeft, Save } from 'lucide-react';
|
||||
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Crop, Field, RiceTransplantPlan } from '@/types';
|
||||
import { Crop, Field, RiceTransplantPlan, StockSummary } from '@/types';
|
||||
|
||||
type BoxMap = Record<number, string>;
|
||||
|
||||
@@ -27,10 +27,11 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
const [allFields, setAllFields] = useState<Field[]>([]);
|
||||
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
|
||||
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
|
||||
const [seedStocks, setSeedStocks] = useState<StockSummary[]>([]);
|
||||
|
||||
const [calcBoxes, setCalcBoxes] = useState<BoxMap>({});
|
||||
const [adjustedBoxes, setAdjustedBoxes] = useState<BoxMap>({});
|
||||
const [applyToEmptyOnly, setApplyToEmptyOnly] = useState(true);
|
||||
const [boxesRounded, setBoxesRounded] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -41,27 +42,24 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
const getVariety = (id: number) =>
|
||||
crops.flatMap((crop) => crop.varieties).find((variety) => variety.id === id);
|
||||
|
||||
const getSelectedCrop = () => {
|
||||
if (!varietyId) return null;
|
||||
return crops.find((crop) => crop.varieties.some((variety) => variety.id === varietyId)) ?? null;
|
||||
};
|
||||
|
||||
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);
|
||||
return Number.isNaN(areaTan * boxesPerTan) ? '' : (areaTan * boxesPerTan).toFixed(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [cropsRes, fieldsRes] = await Promise.all([
|
||||
const [cropsRes, fieldsRes, seedStockRes] = await Promise.all([
|
||||
api.get('/plans/crops/'),
|
||||
api.get('/fields/?ordering=display_order,id'),
|
||||
api.get('/materials/stock-summary/?material_type=seed'),
|
||||
]);
|
||||
setCrops(cropsRes.data);
|
||||
setAllFields(fieldsRes.data);
|
||||
setSeedStocks(seedStockRes.data);
|
||||
|
||||
if (!isNew && planId) {
|
||||
const planRes = await api.get(`/plans/rice-transplant-plans/${planId}/`);
|
||||
@@ -81,8 +79,8 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
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);
|
||||
nextAdjusted[entry.field] = Number(entry.installed_seedling_boxes).toFixed(1);
|
||||
nextCalc[entry.field] = Number(entry.default_seedling_boxes).toFixed(1);
|
||||
});
|
||||
setAdjustedBoxes(nextAdjusted);
|
||||
setCalcBoxes(nextCalc);
|
||||
@@ -101,7 +99,9 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
const fetchCandidates = async () => {
|
||||
if (!varietyId || !year || (!isNew && loading)) return;
|
||||
try {
|
||||
const res = await api.get(`/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${varietyId}`);
|
||||
const res = await api.get(
|
||||
`/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${varietyId}`
|
||||
);
|
||||
const nextCandidates: Field[] = res.data;
|
||||
setCandidateFields(nextCandidates);
|
||||
if (isNew) {
|
||||
@@ -122,7 +122,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
if (isNew || seedlingBoxesPerTan === '') {
|
||||
setSeedlingBoxesPerTan(variety.default_seedling_boxes_per_tan);
|
||||
}
|
||||
}, [varietyId, crops, isNew]);
|
||||
}, [varietyId, crops, isNew, seedlingBoxesPerTan]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextCalc: BoxMap = {};
|
||||
@@ -130,6 +130,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
nextCalc[field.id] = calculateDefaultBoxes(field, seedlingBoxesPerTan);
|
||||
});
|
||||
setCalcBoxes(nextCalc);
|
||||
setBoxesRounded(false);
|
||||
}, [selectedFields, seedlingBoxesPerTan]);
|
||||
|
||||
const addField = (field: Field) => {
|
||||
@@ -162,39 +163,59 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
setAdjustedBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
selectedFields.forEach((field) => {
|
||||
const currentValue = prev[field.id] ?? '';
|
||||
if (applyToEmptyOnly && currentValue !== '') return;
|
||||
next[field.id] = calcBoxes[field.id] ?? '';
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setBoxesRounded(false);
|
||||
};
|
||||
|
||||
const roundColumn = () => {
|
||||
const toggleRoundColumn = () => {
|
||||
if (boxesRounded) {
|
||||
setAdjustedBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
selectedFields.forEach((field) => {
|
||||
delete next[field.id];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setBoxesRounded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustedBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
selectedFields.forEach((field) => {
|
||||
const raw = prev[field.id] !== undefined && prev[field.id] !== '' ? prev[field.id] : calcBoxes[field.id];
|
||||
const raw = calcBoxes[field.id] ?? prev[field.id];
|
||||
if (!raw) return;
|
||||
const value = parseFloat(raw);
|
||||
if (isNaN(value)) return;
|
||||
if (Number.isNaN(value)) return;
|
||||
next[field.id] = String(Math.round(value));
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setBoxesRounded(true);
|
||||
};
|
||||
|
||||
const effectiveBoxes = (fieldId: number) => {
|
||||
const raw = adjustedBoxes[fieldId] !== undefined && adjustedBoxes[fieldId] !== '' ? adjustedBoxes[fieldId] : calcBoxes[fieldId];
|
||||
const raw =
|
||||
adjustedBoxes[fieldId] !== undefined && adjustedBoxes[fieldId] !== ''
|
||||
? adjustedBoxes[fieldId]
|
||||
: calcBoxes[fieldId];
|
||||
const value = parseFloat(raw ?? '0');
|
||||
return isNaN(value) ? 0 : value;
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
};
|
||||
|
||||
const selectedVariety = varietyId ? getVariety(varietyId) : null;
|
||||
const seedStock = selectedVariety?.seed_material
|
||||
? seedStocks.find((item) => item.material_id === selectedVariety.seed_material) ?? null
|
||||
: null;
|
||||
|
||||
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;
|
||||
const seedInventoryKg = parseFloat(seedStock?.current_stock ?? '0');
|
||||
const remainingSeedKg = seedInventoryKg - totalSeedKg;
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
@@ -251,7 +272,10 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
selectedFields.map((field) => ({
|
||||
field,
|
||||
defaultBoxes: calcBoxes[field.id] ?? '',
|
||||
boxCount: adjustedBoxes[field.id] !== undefined && adjustedBoxes[field.id] !== '' ? adjustedBoxes[field.id] : calcBoxes[field.id] ?? '',
|
||||
boxCount:
|
||||
adjustedBoxes[field.id] !== undefined && adjustedBoxes[field.id] !== ''
|
||||
? adjustedBoxes[field.id]
|
||||
: calcBoxes[field.id] ?? '',
|
||||
})),
|
||||
[selectedFields, calcBoxes, adjustedBoxes]
|
||||
);
|
||||
@@ -271,7 +295,10 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.push('/rice-transplant')} className="text-gray-500 hover:text-gray-700">
|
||||
<button
|
||||
onClick={() => router.push('/rice-transplant')}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
@@ -301,9 +328,11 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
value={name}
|
||||
onChange={(e) => setName(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"
|
||||
placeholder="例: 2026年度 コシヒカリ 第1回"
|
||||
placeholder="例: 2026年度 にこまる 第1回"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">同じ年度・同じ品種でも、第1回や播種日ごとに複数計画を作れます。</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
同じ年度・同じ品種でも、第1回や播種日ごとに複数計画を作れます。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">年度</label>
|
||||
@@ -339,7 +368,9 @@ 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)}
|
||||
@@ -352,17 +383,6 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
<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">
|
||||
@@ -375,7 +395,9 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
{field.name} ×
|
||||
</button>
|
||||
))}
|
||||
{selectedFields.length === 0 && <p className="text-sm text-gray-500">圃場が選択されていません。</p>}
|
||||
{selectedFields.length === 0 && (
|
||||
<p className="text-sm text-gray-500">圃場が選択されていません。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unselectedFields.length > 0 && (
|
||||
@@ -396,35 +418,6 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
)}
|
||||
</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">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">備考</label>
|
||||
@@ -445,17 +438,21 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>苗箱合計</span>
|
||||
<span>{totalBoxes.toFixed(2)}枚</span>
|
||||
<span>{totalBoxes.toFixed(1)}枚</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>必要種もみ量</span>
|
||||
<span>{totalSeedKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>{getSelectedCrop()?.name ?? '作物'} 在庫</span>
|
||||
<span>{cropSeedInventoryKg.toFixed(3)}kg</span>
|
||||
<span>{seedStock?.name || selectedVariety?.seed_material_name || '種子在庫未設定'}</span>
|
||||
<span>{seedInventoryKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
<div className={`flex justify-between font-semibold ${remainingSeedKg < 0 ? 'text-red-600' : 'text-emerald-700'}`}>
|
||||
<div
|
||||
className={`flex justify-between font-semibold ${
|
||||
remainingSeedKg < 0 ? 'text-red-600' : 'text-emerald-700'
|
||||
}`}
|
||||
>
|
||||
<span>残在庫見込み</span>
|
||||
<span>{remainingSeedKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
@@ -469,32 +466,74 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
<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">種もみkg</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-700">
|
||||
<div>苗箱数</div>
|
||||
<div className="mt-1 space-y-0.5 text-[11px] font-normal leading-4">
|
||||
<div className="text-gray-500">反当 {seedlingBoxesPerTan || '0'}枚</div>
|
||||
<div className="text-gray-500">合計 {totalBoxes.toFixed(1)}枚</div>
|
||||
</div>
|
||||
<span className="mt-1 flex items-center justify-center gap-1.5 text-xs font-normal text-gray-400">
|
||||
(枚)
|
||||
<button
|
||||
onClick={toggleRoundColumn}
|
||||
className={`inline-flex h-5 w-5 items-center justify-center rounded font-bold leading-none ${
|
||||
boxesRounded
|
||||
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
|
||||
: 'bg-blue-100 text-blue-500 hover:bg-blue-200'
|
||||
}`}
|
||||
title={boxesRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
|
||||
>
|
||||
{boxesRounded ? '↩' : '≈'}
|
||||
</button>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border-t border-gray-200 px-4 py-2 text-left text-xs font-medium text-gray-500">
|
||||
反当苗箱枚数
|
||||
</th>
|
||||
<th className="border-t border-gray-200 px-4 py-2" />
|
||||
<th className="border-t border-gray-200 px-4 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<input
|
||||
value={seedlingBoxesPerTan}
|
||||
onChange={(e) => setSeedlingBoxesPerTan(e.target.value)}
|
||||
className="w-24 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:outline-none focus:ring-1 focus:ring-green-400"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyColumnDefaults}
|
||||
className="rounded border border-blue-300 px-3 py-1 text-xs text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
反映
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{fieldRows.map(({ field, defaultBoxes, boxCount }) => (
|
||||
<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.0'}枚
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user