品種ごとの種子在庫前提まで実装を進めました。

主な変更は、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:
akira
2026-04-05 11:22:07 +09:00
parent 11b36b28a5
commit a38472e4a0
14 changed files with 473 additions and 236 deletions

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api';
import { Field, Crop, Plan } from '@/types';
import { Field, Crop, Material, Plan } from '@/types';
import Navbar from '@/components/Navbar';
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react';
@@ -23,6 +23,7 @@ export default function AllocationPage() {
const [fields, setFields] = useState<Field[]>([]);
const [crops, setCrops] = useState<Crop[]>([]);
const [plans, setPlans] = useState<Plan[]>([]);
const [seedMaterials, setSeedMaterials] = useState<Material[]>([]);
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('allocationYear');
@@ -60,14 +61,16 @@ export default function AllocationPage() {
const fetchData = async (background = false) => {
if (!background) setLoading(true);
try {
const [fieldsRes, cropsRes, plansRes] = await Promise.all([
const [fieldsRes, cropsRes, plansRes, seedMaterialsRes] = await Promise.all([
api.get('/fields/?ordering=group_name,display_order,id'),
api.get('/plans/crops/'),
api.get(`/plans/?year=${year}`),
api.get('/materials/materials/?material_type=seed'),
]);
setFields(fieldsRes.data);
setCrops(cropsRes.data);
setPlans(plansRes.data);
setSeedMaterials(seedMaterialsRes.data);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
@@ -367,20 +370,6 @@ export default function AllocationPage() {
}
};
const handleUpdateCropSeedInventory = async (cropId: number, seedInventoryKg: string) => {
try {
const crop = crops.find((item) => item.id === cropId);
if (!crop) return;
await api.patch(`/plans/crops/${cropId}/`, {
seed_inventory_kg: seedInventoryKg,
});
await fetchData(true);
} catch (error) {
console.error('Failed to update crop seed inventory:', error);
alert('種もみ在庫の更新に失敗しました');
}
};
const handleUpdateVarietyDefaultBoxes = async (varietyId: number, defaultBoxes: string) => {
try {
const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId);
@@ -395,6 +384,20 @@ export default function AllocationPage() {
}
};
const handleUpdateVarietySeedMaterial = async (varietyId: number, seedMaterialId: string) => {
try {
const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId);
if (!variety) return;
await api.patch(`/plans/varieties/${varietyId}/`, {
seed_material: seedMaterialId ? parseInt(seedMaterialId, 10) : null,
});
await fetchData(true);
} catch (error) {
console.error('Failed to update variety seed material:', error);
alert('種子在庫の紐付け更新に失敗しました');
}
};
const toggleFieldSelection = (fieldId: number) => {
setSelectedFields((prev) => {
const next = new Set(prev);
@@ -1057,15 +1060,6 @@ export default function AllocationPage() {
</div>
<div className="flex-1 overflow-y-auto p-4">
{managerCropId && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="mb-2 text-xs font-semibold text-amber-800"></p>
<CropSeedInventoryForm
crop={crops.find((crop) => crop.id === managerCropId) || null}
onSave={handleUpdateCropSeedInventory}
/>
</div>
)}
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
<ul className="space-y-2">
{getVarietiesForCrop(managerCropId).map((v) => (
@@ -1085,6 +1079,15 @@ export default function AllocationPage() {
initialValue={v.default_seedling_boxes_per_tan}
onSave={handleUpdateVarietyDefaultBoxes}
/>
<div className="mt-3">
<VarietySeedMaterialForm
varietyId={v.id}
initialValue={v.seed_material ? String(v.seed_material) : ''}
initialLabel={v.seed_material_name}
materials={seedMaterials}
onSave={handleUpdateVarietySeedMaterial}
/>
</div>
</li>
))}
</ul>
@@ -1150,49 +1153,6 @@ function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name
);
}
function CropSeedInventoryForm({
crop,
onSave,
}: {
crop: Crop | null;
onSave: (cropId: number, seedInventoryKg: string) => Promise<void>;
}) {
const [value, setValue] = useState(crop?.seed_inventory_kg ?? '0');
const [saving, setSaving] = useState(false);
useEffect(() => {
setValue(crop?.seed_inventory_kg ?? '0');
}, [crop?.id, crop?.seed_inventory_kg]);
const handleSave = async () => {
if (!crop) return;
setSaving(true);
await onSave(crop.id, value);
setSaving(false);
};
return (
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs text-gray-600">(kg)</label>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</div>
<button
onClick={handleSave}
disabled={!crop || saving}
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
</button>
</div>
);
}
function VarietyDefaultBoxesForm({
varietyId,
initialValue,
@@ -1236,3 +1196,60 @@ function VarietyDefaultBoxesForm({
</div>
);
}
function VarietySeedMaterialForm({
varietyId,
initialValue,
initialLabel,
materials,
onSave,
}: {
varietyId: number;
initialValue: string;
initialLabel: string | null;
materials: Material[];
onSave: (varietyId: number, seedMaterialId: string) => Promise<void>;
}) {
const [value, setValue] = useState(initialValue);
const [saving, setSaving] = useState(false);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const handleSave = async () => {
setSaving(true);
await onSave(varietyId, value);
setSaving(false);
};
return (
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs text-gray-600"></label>
<select
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value=""></option>
{materials.map((material) => (
<option key={material.id} value={material.id}>
{material.name}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
: {initialLabel || '未設定'}
</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
</button>
</div>
);
}