Add rice transplant planning feature
This commit is contained in:
5
frontend/src/app/rice-transplant/[id]/edit/page.tsx
Normal file
5
frontend/src/app/rice-transplant/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import RiceTransplantEditPage from '../../_components/RiceTransplantEditPage';
|
||||
|
||||
export default function EditRiceTransplantPage({ params }: { params: { id: string } }) {
|
||||
return <RiceTransplantEditPage planId={parseInt(params.id, 10)} />;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
'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 } from '@/types';
|
||||
|
||||
type EntryInput = {
|
||||
seedling_boxes_per_tan: string;
|
||||
seed_grams_per_box: string;
|
||||
};
|
||||
|
||||
type EntryMap = Record<number, EntryInput>;
|
||||
|
||||
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 [varietyId, setVarietyId] = useState<number | ''>('');
|
||||
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 [entries, setEntries] = useState<EntryMap>({});
|
||||
|
||||
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 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 initializeEntry = (fieldId: number, nextDefaultSeedGramsPerBox = defaultSeedGramsPerBox) => {
|
||||
const variety = varietyId ? getVariety(varietyId) : null;
|
||||
const defaultBoxes = variety?.default_seedling_boxes_per_tan ?? '0';
|
||||
return {
|
||||
seedling_boxes_per_tan: String(defaultBoxes),
|
||||
seed_grams_per_box: nextDefaultSeedGramsPerBox,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [cropsRes, fieldsRes] = await Promise.all([
|
||||
api.get('/plans/crops/'),
|
||||
api.get('/fields/?ordering=display_order,id'),
|
||||
]);
|
||||
setCrops(cropsRes.data);
|
||||
setAllFields(fieldsRes.data);
|
||||
|
||||
if (!isNew && planId) {
|
||||
const planRes = await api.get(`/plans/rice-transplant-plans/${planId}/`);
|
||||
const plan: RiceTransplantPlan = planRes.data;
|
||||
setName(plan.name);
|
||||
setYear(plan.year);
|
||||
setVarietyId(plan.variety);
|
||||
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);
|
||||
setEntries(
|
||||
plan.entries.reduce((acc: EntryMap, entry) => {
|
||||
acc[entry.field] = {
|
||||
seedling_boxes_per_tan: String(entry.seedling_boxes_per_tan),
|
||||
seed_grams_per_box: String(entry.seed_grams_per_box),
|
||||
};
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('データの読み込みに失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [isNew, planId]);
|
||||
|
||||
useEffect(() => {
|
||||
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 nextCandidates: Field[] = res.data;
|
||||
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);
|
||||
setError('候補圃場の取得に失敗しました。');
|
||||
}
|
||||
};
|
||||
fetchCandidates();
|
||||
}, [varietyId, year, isNew, loading]);
|
||||
|
||||
const updateEntry = (fieldId: number, key: keyof EntryInput, value: string) => {
|
||||
setEntries((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: {
|
||||
...(prev[fieldId] ?? initializeEntry(fieldId)),
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const applyDefaultsToSelected = () => {
|
||||
setEntries((prev) => {
|
||||
const next = { ...prev };
|
||||
selectedFields.forEach((field) => {
|
||||
next[field.id] = initializeEntry(field.id, defaultSeedGramsPerBox);
|
||||
});
|
||||
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 removeField = (fieldId: number) => {
|
||||
setSelectedFields((prev) => prev.filter((field) => field.id !== fieldId));
|
||||
};
|
||||
|
||||
const fieldRows = useMemo(
|
||||
() => selectedFields.map((field) => ({ field, entry: entries[field.id] ?? initializeEntry(field.id) })),
|
||||
[selectedFields, entries, varietyId, defaultSeedGramsPerBox]
|
||||
);
|
||||
|
||||
const calculateBoxes = (field: Field, entry: EntryInput) => {
|
||||
const areaTan = parseFloat(field.area_tan || '0');
|
||||
const boxesPerTan = parseFloat(entry.seedling_boxes_per_tan || '0');
|
||||
return areaTan * boxesPerTan;
|
||||
};
|
||||
|
||||
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 cropSeedInventoryKg = parseFloat(getSelectedCrop()?.seed_inventory_kg ?? '0');
|
||||
const remainingSeedKg = cropSeedInventoryKg - totalSeedKg;
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
if (!name.trim()) {
|
||||
setError('計画名を入力してください。');
|
||||
return;
|
||||
}
|
||||
if (!varietyId) {
|
||||
setError('品種を選択してください。');
|
||||
return;
|
||||
}
|
||||
if (selectedFields.length === 0) {
|
||||
setError('圃場を1つ以上選択してください。');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
year,
|
||||
variety: varietyId,
|
||||
default_seed_grams_per_box: defaultSeedGramsPerBox,
|
||||
notes,
|
||||
entries: selectedFields.map((field) => ({
|
||||
field_id: field.id,
|
||||
seedling_boxes_per_tan: entries[field.id]?.seedling_boxes_per_tan ?? initializeEntry(field.id).seedling_boxes_per_tan,
|
||||
seed_grams_per_box: entries[field.id]?.seed_grams_per_box ?? defaultSeedGramsPerBox,
|
||||
})),
|
||||
};
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
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-4">
|
||||
<div>
|
||||
<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={varietyId}
|
||||
onChange={(e) => setVarietyId(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>
|
||||
{crops.map((crop) => (
|
||||
<optgroup key={crop.id} label={crop.name}>
|
||||
{crop.varieties.map((variety) => (
|
||||
<option key={variety.id} value={variety.id}>
|
||||
{variety.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</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 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</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(2)}枚</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>
|
||||
</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="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">種もみ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">種もみkg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{fieldRows.map(({ field, entry }) => (
|
||||
<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">
|
||||
<input
|
||||
value={entry.seedling_boxes_per_tan}
|
||||
onChange={(e) => updateEntry(field.id, 'seedling_boxes_per_tan', 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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/rice-transplant/new/page.tsx
Normal file
5
frontend/src/app/rice-transplant/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import RiceTransplantEditPage from '../_components/RiceTransplantEditPage';
|
||||
|
||||
export default function NewRiceTransplantPage() {
|
||||
return <RiceTransplantEditPage />;
|
||||
}
|
||||
159
frontend/src/app/rice-transplant/page.tsx
Normal file
159
frontend/src/app/rice-transplant/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Pencil, Plus, Sprout, Trash2 } from 'lucide-react';
|
||||
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { RiceTransplantPlan } from '@/types';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
export default function RiceTransplantPage() {
|
||||
const router = useRouter();
|
||||
const [year, setYear] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('riceTransplantYear');
|
||||
if (saved) return parseInt(saved, 10);
|
||||
}
|
||||
return currentYear;
|
||||
});
|
||||
const [plans, setPlans] = useState<RiceTransplantPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get(`/plans/rice-transplant-plans/?year=${year}`);
|
||||
setPlans(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('田植え計画の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('riceTransplantYear', String(year));
|
||||
fetchPlans();
|
||||
}, [year]);
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
setError(null);
|
||||
if (!confirm(`「${name}」を削除しますか?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.delete(`/plans/rice-transplant-plans/${id}/`);
|
||||
await fetchPlans();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(`「${name}」の削除に失敗しました。`);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<Sprout className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-800">田植え計画</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/rice-transplant/new')}
|
||||
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-white hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規作成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value, 10))}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
>
|
||||
{years.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}年度
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500">読み込み中...</p>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center text-gray-400 shadow">
|
||||
<Sprout className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||||
<p>{year}年度の田植え計画はありません</p>
|
||||
</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-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">残在庫見込み</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{plans.map((plan) => (
|
||||
<tr key={plan.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{plan.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{plan.crop_name} / {plan.variety_name}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}筆</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.total_seedling_boxes}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.total_seed_kg}kg</td>
|
||||
<td className={`px-4 py-3 text-right tabular-nums ${parseFloat(plan.remaining_seed_kg) < 0 ? 'text-red-600' : 'text-emerald-700'}`}>
|
||||
{plan.remaining_seed_kg}kg
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => router.push(`/rice-transplant/${plan.id}/edit`)}
|
||||
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(plan.id, plan.name)}
|
||||
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user