Files
keinasystem/frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx
akira 3eb2852b78 修正しました。
原因は RiceTransplantEditPage.tsx の初期値セット用 useEffect で、新規作成時に isNew を条件にしていたため、反当苗箱枚数 を入力しても毎回デフォルト値で上書きされていたことです。これを seedlingBoxesPerTan === '' のときだけ初期値を入れるように直したので、今は手入力できるはずです。

あわせて、同じファイルで 面積(反) は toFixed(2) 表示に変更しました。反当苗箱枚数 は入力欄のまま 1 桁運用に寄せる前提で、表示系はご要望に近づけています。再読み込みしてもう一度画面操作してみてください。
2026-04-05 12:23:22 +09:00

551 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}