Files
keinasystem/frontend/src/app/fertilizer/spreading/page.tsx

748 lines
30 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, useSearchParams } from 'next/navigation';
import { ChevronLeft, Pencil, Plus, Save, Sprout, Trash2, X } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import {
DeliveryPlan,
FertilizationPlan,
SpreadingCandidate,
SpreadingSession,
} from '@/types';
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_KEY = 'spreadingYear';
type SourceType = 'delivery' | 'plan' | 'year';
type FormState = {
date: string;
name: string;
notes: string;
itemValues: Record<string, string>;
};
type MatrixField = {
id: number;
name: string;
area_tan: string;
};
type MatrixFertilizer = {
id: number;
name: string;
};
const candidateKey = (fieldId: number, fertilizerId: number) => `${fieldId}:${fertilizerId}`;
const toNumber = (value: string | number | null | undefined) => {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
};
const formatDisplay = (value: string | number | null | undefined) => {
const num = toNumber(value);
if (Number.isInteger(num)) {
return String(num);
}
return num.toFixed(4).replace(/\.?0+$/, '');
};
const formatInputValue = (value: number) => {
if (value <= 0) return '0';
return value.toFixed(2).replace(/\.?0+$/, '');
};
const getDefaultDate = (year: number) => {
const today = new Date();
if (today.getFullYear() !== year) {
return `${year}-01-01`;
}
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const getSourceType = (deliveryPlanId: number | null, fertilizationPlanId: number | null): SourceType => {
if (deliveryPlanId) return 'delivery';
if (fertilizationPlanId) return 'plan';
return 'year';
};
const buildCreateInitialValues = (rows: SpreadingCandidate[], sourceType: SourceType) => {
const values: Record<string, string> = {};
rows.forEach((candidate) => {
let base = 0;
if (sourceType === 'delivery') {
base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other);
} else if (sourceType === 'plan') {
base = toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other);
} else {
base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other);
}
values[candidateKey(candidate.field, candidate.fertilizer)] = formatInputValue(Math.max(base, 0));
});
return values;
};
export default function SpreadingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryYear = Number(searchParams.get('year') || '0') || null;
const deliveryPlanId = Number(searchParams.get('delivery_plan') || '0') || null;
const fertilizationPlanId = Number(searchParams.get('plan') || '0') || null;
const sourceType = getSourceType(deliveryPlanId, fertilizationPlanId);
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
}
return CURRENT_YEAR;
});
const [sessions, setSessions] = useState<SpreadingSession[]>([]);
const [candidates, setCandidates] = useState<SpreadingCandidate[]>([]);
const [loading, setLoading] = useState(true);
const [formLoading, setFormLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingSessionId, setEditingSessionId] = useState<number | null>(null);
const [form, setForm] = useState<FormState | null>(null);
const [openedFromQuery, setOpenedFromQuery] = useState(false);
const [openedFromSource, setOpenedFromSource] = useState(false);
const [sourceName, setSourceName] = useState<string | null>(null);
useEffect(() => {
if (queryYear && queryYear !== year) {
setYear(queryYear);
}
}, [queryYear, year]);
useEffect(() => {
localStorage.setItem(YEAR_KEY, String(year));
void fetchSessions();
setForm(null);
setEditingSessionId(null);
setOpenedFromQuery(false);
setOpenedFromSource(false);
}, [year]);
useEffect(() => {
const loadSource = async () => {
if (deliveryPlanId) {
try {
const res = await api.get(`/fertilizer/delivery/${deliveryPlanId}/`);
const plan: DeliveryPlan = res.data;
setSourceName(plan.name);
return;
} catch (e) {
console.error(e);
setSourceName(`運搬計画 #${deliveryPlanId}`);
return;
}
}
if (fertilizationPlanId) {
try {
const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`);
const plan: FertilizationPlan = res.data;
setSourceName(plan.name);
return;
} catch (e) {
console.error(e);
setSourceName(`施肥計画 #${fertilizationPlanId}`);
return;
}
}
setSourceName(null);
};
void loadSource();
}, [deliveryPlanId, fertilizationPlanId]);
useEffect(() => {
const sessionParam = searchParams.get('session');
if (!sessionParam || openedFromQuery || sessions.length === 0) {
return;
}
const targetId = Number(sessionParam);
if (!targetId) {
return;
}
const target = sessions.find((session) => session.id === targetId);
if (target) {
void openEditor(target);
setOpenedFromQuery(true);
}
}, [openedFromQuery, searchParams, sessions]);
useEffect(() => {
const sessionParam = searchParams.get('session');
if (sessionParam || sourceType === 'year' || openedFromSource || form || formLoading) {
return;
}
void startCreate();
setOpenedFromSource(true);
}, [form, formLoading, openedFromSource, searchParams, sourceType]);
const fetchSessions = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get(`/fertilizer/spreading/?year=${year}`);
setSessions(res.data);
} catch (e) {
console.error(e);
setError('散布実績の読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
const loadCandidates = async (sessionId?: number) => {
const params = new URLSearchParams({ year: String(year) });
if (sessionId) {
params.set('session_id', String(sessionId));
}
if (deliveryPlanId) {
params.set('delivery_plan_id', String(deliveryPlanId));
}
if (fertilizationPlanId) {
params.set('plan_id', String(fertilizationPlanId));
}
const res = await api.get(`/fertilizer/spreading/candidates/?${params.toString()}`);
setCandidates(res.data);
return res.data as SpreadingCandidate[];
};
const startCreate = async () => {
setFormLoading(true);
setError(null);
try {
const loaded = await loadCandidates();
setEditingSessionId(null);
setForm({
date: getDefaultDate(year),
name: '',
notes: '',
itemValues: buildCreateInitialValues(loaded, sourceType),
});
} catch (e) {
console.error(e);
setError('散布候補の読み込みに失敗しました。');
} finally {
setFormLoading(false);
}
};
const openEditor = async (session: SpreadingSession) => {
setFormLoading(true);
setError(null);
try {
await loadCandidates(session.id);
const itemValues = session.items.reduce<Record<string, string>>((acc, item) => {
acc[candidateKey(item.field, item.fertilizer)] = String(item.actual_bags);
return acc;
}, {});
setEditingSessionId(session.id);
setForm({
date: session.date,
name: session.name,
notes: session.notes,
itemValues,
});
} catch (e) {
console.error(e);
setError('散布候補の読み込みに失敗しました。');
} finally {
setFormLoading(false);
}
};
const closeEditor = () => {
setEditingSessionId(null);
setForm(null);
setCandidates([]);
};
const candidateMap = useMemo(() => {
const map = new Map<string, SpreadingCandidate>();
candidates.forEach((candidate) => {
map.set(candidateKey(candidate.field, candidate.fertilizer), candidate);
});
return map;
}, [candidates]);
const matrixFields = useMemo<MatrixField[]>(() => {
const map = new Map<number, MatrixField>();
candidates.forEach((candidate) => {
if (!map.has(candidate.field)) {
map.set(candidate.field, {
id: candidate.field,
name: candidate.field_name,
area_tan: candidate.field_area_tan,
});
}
});
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja'));
}, [candidates]);
const matrixFertilizers = useMemo<MatrixFertilizer[]>(() => {
const map = new Map<number, MatrixFertilizer>();
candidates.forEach((candidate) => {
if (!map.has(candidate.fertilizer)) {
map.set(candidate.fertilizer, {
id: candidate.fertilizer,
name: candidate.fertilizer_name,
});
}
});
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja'));
}, [candidates]);
const handleItemChange = (fieldId: number, fertilizerId: number, value: string) => {
if (!form) return;
const key = candidateKey(fieldId, fertilizerId);
setForm({
...form,
itemValues: {
...form.itemValues,
[key]: value,
},
});
};
const getCellValue = (fieldId: number, fertilizerId: number) => {
if (!form) return '';
return form.itemValues[candidateKey(fieldId, fertilizerId)] ?? '0';
};
const selectedRows = useMemo(() => {
if (!form) return [];
return candidates.filter((candidate) => {
const value = toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0');
return value > 0;
});
}, [candidates, form]);
const getRowTotal = (fieldId: number) => {
if (!form) return 0;
return matrixFertilizers.reduce((sum, fertilizer) => {
const candidate = candidateMap.get(candidateKey(fieldId, fertilizer.id));
if (!candidate) return sum;
return sum + toNumber(getCellValue(fieldId, fertilizer.id));
}, 0);
};
const getColumnTotal = (fertilizerId: number) => {
if (!form) return 0;
return matrixFields.reduce((sum, field) => {
const candidate = candidateMap.get(candidateKey(field.id, fertilizerId));
if (!candidate) return sum;
return sum + toNumber(getCellValue(field.id, fertilizerId));
}, 0);
};
const totalInputBags = selectedRows.reduce((sum, candidate) => {
return sum + toNumber(form?.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0');
}, 0);
const handleSave = async () => {
if (!form) return;
setError(null);
if (!form.date) {
setError('散布日を入力してください。');
return;
}
const items = selectedRows.map((candidate) => ({
field_id: candidate.field,
fertilizer_id: candidate.fertilizer,
actual_bags: toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0'),
planned_bags_snapshot: toNumber(candidate.planned_bags),
delivered_bags_snapshot: toNumber(candidate.delivered_bags),
}));
if (items.length === 0) {
setError('散布実績を1件以上入力してください。');
return;
}
setSaving(true);
try {
const payload = {
year,
date: form.date,
name: form.name,
notes: form.notes,
items,
};
if (editingSessionId) {
await api.put(`/fertilizer/spreading/${editingSessionId}/`, payload);
} else {
await api.post('/fertilizer/spreading/', payload);
}
await fetchSessions();
closeEditor();
} catch (e) {
console.error(e);
setError('散布実績の保存に失敗しました。');
} finally {
setSaving(false);
}
};
const handleDelete = async (sessionId: number) => {
setError(null);
try {
await api.delete(`/fertilizer/spreading/${sessionId}/`);
await fetchSessions();
if (editingSessionId === sessionId) {
closeEditor();
}
} catch (e) {
console.error(e);
setError('散布実績の削除に失敗しました。');
}
};
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
const sourceSummary =
sourceType === 'delivery'
? '初期値は運搬計画値から散布済を引いた値です。'
: sourceType === 'plan'
? '初期値は施肥計画値から散布済を引いた値です。'
: '初期値は運搬済みから散布済を引いた値です。';
const sourceLabel =
sourceType === 'delivery'
? '運搬計画を選択した状態です'
: sourceType === 'plan'
? '施肥計画を選択した状態です'
: null;
const clearFilterHref = `/fertilizer/spreading?year=${year}`;
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="mx-auto max-w-7xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
<ChevronLeft className="h-5 w-5" />
</button>
<Sprout className="h-6 w-6 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<button
onClick={() => void startCreate()}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-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(Number(e.target.value))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
{sourceLabel && (
<div className="mb-6 flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3">
<div>
<div className="text-sm font-medium text-emerald-900">{sourceLabel}</div>
<div className="mt-1 text-sm text-emerald-700">
{sourceName ?? (sourceType === 'delivery' ? `運搬計画 #${deliveryPlanId}` : `施肥計画 #${fertilizationPlanId}`)}
{' '}
</div>
<div className="mt-1 text-xs text-emerald-700">{sourceSummary}</div>
</div>
<button
onClick={() => router.push(clearFilterHref)}
className="flex items-center gap-1 rounded border border-emerald-300 px-3 py-1.5 text-xs text-emerald-700 hover:bg-emerald-100"
>
<X className="h-3.5 w-3.5" />
</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>
)}
{(form || formLoading) && (
<section className="mb-8 rounded-lg border border-emerald-200 bg-white shadow-sm">
<div className="border-b border-emerald-100 px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900">
{editingSessionId ? '散布実績を編集' : '散布実績を登録'}
</h2>
<p className="mt-1 text-sm text-gray-500">
×
</p>
<p className="mt-1 text-xs text-gray-500">{sourceSummary}</p>
</div>
{formLoading || !form ? (
<div className="px-5 py-8 text-sm text-gray-500">...</div>
) : (
<div className="space-y-5 px-5 py-5">
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: 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"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="例: 3/17 元肥散布"
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>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="任意"
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>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-gray-50">
<tr>
<th className="w-48 border border-gray-200 px-4 py-3 text-left font-medium text-gray-700">
</th>
{matrixFertilizers.map((fertilizer) => (
<th
key={fertilizer.id}
className="min-w-[220px] border border-gray-200 px-3 py-3 text-center font-medium text-gray-700"
>
<div>{fertilizer.name}</div>
<div className="mt-1 text-[11px] font-normal text-gray-400">
{formatDisplay(getColumnTotal(fertilizer.id))}
</div>
</th>
))}
<th className="w-28 border border-gray-200 px-3 py-3 text-right font-medium text-gray-700">
</th>
</tr>
</thead>
<tbody>
{matrixFields.length === 0 ? (
<tr>
<td
colSpan={matrixFertilizers.length + 2}
className="border border-gray-200 px-4 py-8 text-center text-gray-400"
>
</td>
</tr>
) : (
matrixFields.map((field) => (
<tr key={field.id} className="hover:bg-gray-50">
<td className="border border-gray-200 px-4 py-3 align-top">
<div className="font-medium text-gray-900">{field.name}</div>
<div className="text-xs text-gray-400">{field.area_tan}</div>
</td>
{matrixFertilizers.map((fertilizer) => {
const candidate = candidateMap.get(candidateKey(field.id, fertilizer.id));
if (!candidate) {
return (
<td
key={fertilizer.id}
className="border border-gray-200 bg-gray-50 px-3 py-3 text-center text-xs text-gray-300"
>
-
</td>
);
}
return (
<td key={fertilizer.id} className="border border-gray-200 px-3 py-3 align-top">
<div className="flex items-center justify-between gap-3">
<div className="grid flex-1 grid-cols-2 gap-x-3 gap-y-1 text-[11px] leading-5 text-gray-500">
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400"></span>
<span>{formatDisplay(candidate.planned_bags)}</span>
</div>
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400">
{sourceType === 'plan' ? '計画残' : '未散布'}
</span>
<span>
{formatDisplay(
sourceType === 'plan'
? Math.max(toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other), 0)
: Math.max(toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other), 0)
)}
</span>
</div>
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400"></span>
<span>{formatDisplay(candidate.delivered_bags)}</span>
</div>
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400"></span>
<span>{formatDisplay(candidate.spread_bags_other)}</span>
</div>
</div>
<input
type="number"
step="0.1"
value={getCellValue(field.id, fertilizer.id)}
onChange={(e) => handleItemChange(field.id, fertilizer.id, e.target.value)}
className="w-20 shrink-0 rounded border border-gray-300 px-2 py-1.5 text-right text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</td>
);
})}
<td className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700">
{formatDisplay(getRowTotal(field.id))}
</td>
</tr>
))
)}
</tbody>
{matrixFields.length > 0 && (
<tfoot className="bg-gray-50">
<tr>
<td className="border border-gray-200 px-4 py-3 font-medium text-gray-700"></td>
{matrixFertilizers.map((fertilizer) => (
<td
key={fertilizer.id}
className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700"
>
{formatDisplay(getColumnTotal(fertilizer.id))}
</td>
))}
<td className="border border-gray-200 px-3 py-3 text-right font-bold text-green-700">
{formatDisplay(totalInputBags)}
</td>
</tr>
</tfoot>
)}
</table>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
{selectedRows.length} / {formatDisplay(totalInputBags)}
</p>
<div className="flex items-center gap-3">
<button
onClick={closeEditor}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
</button>
<button
onClick={() => void handleSave()}
disabled={saving}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
</section>
)}
<section className="rounded-lg bg-white shadow-sm">
<div className="border-b px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900"></h2>
</div>
{loading ? (
<div className="px-5 py-8 text-sm text-gray-500">...</div>
) : sessions.length === 0 ? (
<div className="px-5 py-8 text-sm text-gray-400"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="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" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sessions.map((session) => {
const totalBags = session.items.reduce((sum, item) => sum + toNumber(item.actual_bags), 0);
return (
<tr key={session.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-700">{session.date}</td>
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{session.name || '名称なし'}</div>
{session.notes && <div className="text-xs text-gray-400">{session.notes}</div>}
</td>
<td className="px-4 py-3 text-right text-gray-600">{session.items.length}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{formatDisplay(totalBags)}</td>
<td className="px-4 py-3 text-right text-gray-600">
{session.work_record_id ? `#${session.work_record_id}` : '-'}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => void openEditor(session)}
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={() => void handleDelete(session.id)}
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>
)}
</section>
</main>
</div>
);
}