原因は RiceTransplantEditPage.tsx の初期値セット用 useEffect で、新規作成時に isNew を条件にしていたため、反当苗箱枚数 を入力しても毎回デフォルト値で上書きされていたことです。これを seedlingBoxesPerTan === '' のときだけ初期値を入れるように直したので、今は手入力できるはずです。 あわせて、同じファイルで 面積(反) は toFixed(2) 表示に変更しました。反当苗箱枚数 は入力欄のまま 1 桁運用に寄せる前提で、表示系はご要望に近づけています。再読み込みしてもう一度画面操作してみてください。
551 lines
21 KiB
TypeScript
551 lines
21 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { ChevronLeft, Save } from 'lucide-react';
|
||
|
||
import Navbar from '@/components/Navbar';
|
||
import { api } from '@/lib/api';
|
||
import { Crop, Field, RiceTransplantPlan, StockSummary, Variety } from '@/types';
|
||
|
||
type BoxMap = Record<number, string>;
|
||
|
||
const currentYear = new Date().getFullYear();
|
||
|
||
export default function RiceTransplantEditPage({ planId }: { planId?: number }) {
|
||
const router = useRouter();
|
||
const isNew = !planId;
|
||
|
||
const [name, setName] = useState('');
|
||
const [year, setYear] = useState(currentYear);
|
||
const [seedMaterialId, setSeedMaterialId] = useState<number | ''>('');
|
||
const [seedlingBoxesPerTan, setSeedlingBoxesPerTan] = useState('');
|
||
const [defaultSeedGramsPerBox, setDefaultSeedGramsPerBox] = useState('200');
|
||
const [notes, setNotes] = useState('');
|
||
|
||
const [crops, setCrops] = useState<Crop[]>([]);
|
||
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 [boxesRounded, setBoxesRounded] = useState(false);
|
||
|
||
const [loading, setLoading] = useState(!isNew);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||
|
||
const allVarieties = crops.flatMap((crop: Crop) => crop.varieties);
|
||
const getVarietyBySeedMaterial = (id: number) =>
|
||
allVarieties.find((variety: Variety) => variety.seed_material === id) ?? null;
|
||
|
||
const calculateDefaultBoxes = (field: Field, perTan: string) => {
|
||
const areaTan = parseFloat(field.area_tan || '0');
|
||
const boxesPerTan = parseFloat(perTan || '0');
|
||
return Number.isNaN(areaTan * boxesPerTan) ? '' : (areaTan * boxesPerTan).toFixed(1);
|
||
};
|
||
|
||
useEffect(() => {
|
||
const init = async () => {
|
||
setError(null);
|
||
try {
|
||
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}/`);
|
||
const plan: RiceTransplantPlan = planRes.data;
|
||
const fetchedVarieties = cropsRes.data.flatMap((crop: Crop) => crop.varieties);
|
||
const linkedVariety =
|
||
fetchedVarieties.find((variety: Variety) => variety.id === plan.variety) ?? null;
|
||
setName(plan.name);
|
||
setYear(plan.year);
|
||
setSeedMaterialId(linkedVariety?.seed_material ?? '');
|
||
setSeedlingBoxesPerTan(plan.seedling_boxes_per_tan);
|
||
setDefaultSeedGramsPerBox(plan.default_seed_grams_per_box);
|
||
setNotes(plan.notes);
|
||
|
||
const fieldIds = new Set(plan.entries.map((entry) => entry.field));
|
||
const planFields = fieldsRes.data.filter((field: Field) => fieldIds.has(field.id));
|
||
setSelectedFields(planFields);
|
||
setCandidateFields(planFields);
|
||
|
||
const nextAdjusted: BoxMap = {};
|
||
const nextCalc: BoxMap = {};
|
||
plan.entries.forEach((entry) => {
|
||
nextAdjusted[entry.field] = Number(entry.installed_seedling_boxes).toFixed(1);
|
||
nextCalc[entry.field] = Number(entry.default_seedling_boxes).toFixed(1);
|
||
});
|
||
setAdjustedBoxes(nextAdjusted);
|
||
setCalcBoxes(nextCalc);
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
setError('データの読み込みに失敗しました。');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
init();
|
||
}, [isNew, planId]);
|
||
|
||
useEffect(() => {
|
||
const fetchCandidates = async () => {
|
||
const selectedVariety = seedMaterialId ? getVarietyBySeedMaterial(seedMaterialId) : null;
|
||
if (!selectedVariety || !year || (!isNew && loading)) return;
|
||
try {
|
||
const res = await api.get(
|
||
`/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${selectedVariety.id}`
|
||
);
|
||
const nextCandidates: Field[] = res.data;
|
||
setCandidateFields(nextCandidates);
|
||
if (isNew) {
|
||
setSelectedFields(nextCandidates);
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
setError('候補圃場の取得に失敗しました。');
|
||
}
|
||
};
|
||
fetchCandidates();
|
||
}, [seedMaterialId, year, isNew, loading]);
|
||
|
||
useEffect(() => {
|
||
if (!seedMaterialId) return;
|
||
const variety = getVarietyBySeedMaterial(seedMaterialId);
|
||
if (!variety) return;
|
||
if (seedlingBoxesPerTan === '') {
|
||
setSeedlingBoxesPerTan(variety.default_seedling_boxes_per_tan);
|
||
}
|
||
}, [seedMaterialId, crops, seedlingBoxesPerTan]);
|
||
|
||
useEffect(() => {
|
||
const nextCalc: BoxMap = {};
|
||
selectedFields.forEach((field) => {
|
||
nextCalc[field.id] = calculateDefaultBoxes(field, seedlingBoxesPerTan);
|
||
});
|
||
setCalcBoxes(nextCalc);
|
||
setBoxesRounded(false);
|
||
}, [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]: value,
|
||
}));
|
||
};
|
||
|
||
const applyColumnDefaults = () => {
|
||
setAdjustedBoxes((prev) => {
|
||
const next = { ...prev };
|
||
selectedFields.forEach((field) => {
|
||
next[field.id] = calcBoxes[field.id] ?? '';
|
||
});
|
||
return next;
|
||
});
|
||
setBoxesRounded(false);
|
||
};
|
||
|
||
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 = calcBoxes[field.id] ?? prev[field.id];
|
||
if (!raw) return;
|
||
const value = parseFloat(raw);
|
||
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 value = parseFloat(raw ?? '0');
|
||
return Number.isNaN(value) ? 0 : value;
|
||
};
|
||
|
||
const selectedSeedStock = seedMaterialId
|
||
? seedStocks.find((item) => item.material_id === seedMaterialId) ?? null
|
||
: null;
|
||
const selectedVariety = seedMaterialId ? getVarietyBySeedMaterial(seedMaterialId) : 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 seedInventoryKg = parseFloat(selectedSeedStock?.current_stock ?? '0');
|
||
const remainingSeedKg = seedInventoryKg - totalSeedKg;
|
||
|
||
const handleSave = async () => {
|
||
setError(null);
|
||
if (!name.trim()) {
|
||
setError('計画名を入力してください。');
|
||
return;
|
||
}
|
||
if (!seedMaterialId) {
|
||
setError('種子資材を選択してください。');
|
||
return;
|
||
}
|
||
if (!selectedVariety) {
|
||
setError('選択した種子資材に対応する品種が未設定です。資材マスタで紐付けてください。');
|
||
return;
|
||
}
|
||
if (selectedFields.length === 0) {
|
||
setError('圃場を1つ以上選択してください。');
|
||
return;
|
||
}
|
||
|
||
const entries = selectedFields.map((field) => ({
|
||
field_id: field.id,
|
||
installed_seedling_boxes: effectiveBoxes(field.id).toFixed(2),
|
||
}));
|
||
|
||
const payload = {
|
||
name,
|
||
year,
|
||
variety: selectedVariety.id,
|
||
seedling_boxes_per_tan: seedlingBoxesPerTan || '0',
|
||
default_seed_grams_per_box: defaultSeedGramsPerBox || '0',
|
||
notes,
|
||
entries,
|
||
};
|
||
|
||
setSaving(true);
|
||
try {
|
||
if (isNew) {
|
||
await api.post('/plans/rice-transplant-plans/', payload);
|
||
} else {
|
||
await api.put(`/plans/rice-transplant-plans/${planId}/`, payload);
|
||
}
|
||
router.push('/rice-transplant');
|
||
} catch (e) {
|
||
console.error(e);
|
||
setError('保存に失敗しました。');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const unselectedFields = (candidateFields.length > 0 ? candidateFields : allFields).filter(
|
||
(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">
|
||
<Navbar />
|
||
<div className="mx-auto max-w-6xl px-4 py-8 text-gray-500">読み込み中...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
<Navbar />
|
||
<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"
|
||
>
|
||
<ChevronLeft className="h-5 w-5" />
|
||
</button>
|
||
<h1 className="text-2xl font-bold text-gray-800">
|
||
{isNew ? '田植え計画 新規作成' : '田植え計画 編集'}
|
||
</h1>
|
||
</div>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
<Save className="h-4 w-4" />
|
||
{saving ? '保存中...' : '保存'}
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||
{error}
|
||
</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}
|
||
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回"
|
||
/>
|
||
<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>
|
||
<select
|
||
value={year}
|
||
onChange={(e) => setYear(parseInt(e.target.value, 10))}
|
||
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"
|
||
>
|
||
{years.map((value) => (
|
||
<option key={value} value={value}>
|
||
{value}年度
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-gray-600">種子資材</label>
|
||
<select
|
||
value={seedMaterialId}
|
||
onChange={(e) =>
|
||
setSeedMaterialId(e.target.value ? parseInt(e.target.value, 10) : '')
|
||
}
|
||
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"
|
||
>
|
||
<option value="">選択してください</option>
|
||
{seedStocks.map((stock) => (
|
||
<option key={stock.material_id} value={stock.material_id}>
|
||
{stock.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<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 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>
|
||
|
||
<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 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>
|
||
<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">
|
||
<h2 className="mb-3 text-sm font-semibold text-gray-800">集計</h2>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between text-gray-600">
|
||
<span>対象圃場</span>
|
||
<span>{selectedFields.length}筆</span>
|
||
</div>
|
||
<div className="flex justify-between text-gray-600">
|
||
<span>苗箱合計</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>{selectedSeedStock?.name || '種子在庫未設定'}</span>
|
||
<span>{seedInventoryKg.toFixed(3)}kg</span>
|
||
</div>
|
||
<div
|
||
className={`flex justify-between font-semibold ${
|
||
remainingSeedKg < 0 ? 'text-red-600' : 'text-emerald-700'
|
||
}`}
|
||
>
|
||
<span>残在庫見込み</span>
|
||
<span>{remainingSeedKg.toFixed(3)}kg</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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-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 }) => (
|
||
<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">
|
||
{Number(field.area_tan).toFixed(2)}
|
||
</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>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|