440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { Suspense, useEffect, useMemo, useState } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { ChevronLeft, PencilLine, Plus, Save, Trash2 } from 'lucide-react';
|
|
|
|
import Navbar from '@/components/Navbar';
|
|
import { api } from '@/lib/api';
|
|
import { LeveeWorkCandidate, LeveeWorkSession } from '@/types';
|
|
|
|
const CURRENT_YEAR = new Date().getFullYear();
|
|
const YEAR_KEY = 'leveeWorkYear';
|
|
|
|
type FormState = {
|
|
date: string;
|
|
title: string;
|
|
notes: string;
|
|
selectedFieldIds: Set<number>;
|
|
};
|
|
|
|
const extractErrorMessage = (error: any) => {
|
|
const data = error?.response?.data;
|
|
if (!data) return '保存に失敗しました。';
|
|
if (typeof data.detail === 'string') return data.detail;
|
|
if (Array.isArray(data.year) && data.year[0]) return data.year[0];
|
|
if (Array.isArray(data.items) && data.items[0]) return data.items[0];
|
|
if (typeof data.items === 'string') return data.items;
|
|
return '保存に失敗しました。';
|
|
};
|
|
|
|
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}`;
|
|
};
|
|
|
|
export default function LeveeWorkPage() {
|
|
return (
|
|
<Suspense fallback={<div className="min-h-screen bg-gray-50"><Navbar /><div className="mx-auto max-w-7xl px-4 py-8 text-gray-500">読み込み中...</div></div>}>
|
|
<LeveeWorkPageContent />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function LeveeWorkPageContent() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
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<LeveeWorkSession[]>([]);
|
|
const [candidates, setCandidates] = useState<LeveeWorkCandidate[]>([]);
|
|
const [form, setForm] = useState<FormState | null>(null);
|
|
const [editingSessionId, setEditingSessionId] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [formLoading, setFormLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [openedFromQuery, setOpenedFromQuery] = useState(false);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(YEAR_KEY, String(year));
|
|
void fetchSessions();
|
|
setForm(null);
|
|
setEditingSessionId(null);
|
|
setOpenedFromQuery(false);
|
|
}, [year]);
|
|
|
|
useEffect(() => {
|
|
const sessionParam = Number(searchParams.get('session') || '0') || null;
|
|
if (!sessionParam || openedFromQuery || sessions.length === 0) {
|
|
return;
|
|
}
|
|
const target = sessions.find((session) => session.id === sessionParam);
|
|
if (target) {
|
|
void openEditor(target);
|
|
setOpenedFromQuery(true);
|
|
}
|
|
}, [openedFromQuery, searchParams, sessions]);
|
|
|
|
const fetchSessions = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await api.get(`/levee-work/sessions/?year=${year}`);
|
|
setSessions(res.data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError('畔塗記録の読み込みに失敗しました。');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadCandidates = async () => {
|
|
const res = await api.get(`/levee-work/candidates/?year=${year}`);
|
|
setCandidates(res.data);
|
|
return res.data as LeveeWorkCandidate[];
|
|
};
|
|
|
|
const startCreate = async () => {
|
|
setFormLoading(true);
|
|
setError(null);
|
|
try {
|
|
const loaded = await loadCandidates();
|
|
setEditingSessionId(null);
|
|
setForm({
|
|
date: getDefaultDate(year),
|
|
title: '水稲畔塗',
|
|
notes: '',
|
|
selectedFieldIds: new Set(loaded.filter((candidate) => candidate.selected).map((candidate) => candidate.field_id)),
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError('候補圃場の読み込みに失敗しました。');
|
|
} finally {
|
|
setFormLoading(false);
|
|
}
|
|
};
|
|
|
|
const openEditor = async (session: LeveeWorkSession) => {
|
|
setFormLoading(true);
|
|
setError(null);
|
|
try {
|
|
const loaded = await loadCandidates();
|
|
const selectedIds = new Set(session.items.map((item) => item.field));
|
|
const fallbackSelected = loaded.filter((candidate) => candidate.selected).map((candidate) => candidate.field_id);
|
|
setEditingSessionId(session.id);
|
|
setForm({
|
|
date: session.date,
|
|
title: session.title,
|
|
notes: session.notes,
|
|
selectedFieldIds: selectedIds.size > 0 ? selectedIds : new Set(fallbackSelected),
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError('編集用データの読み込みに失敗しました。');
|
|
} finally {
|
|
setFormLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleToggleField = (fieldId: number) => {
|
|
if (!form) return;
|
|
const next = new Set(form.selectedFieldIds);
|
|
if (next.has(fieldId)) {
|
|
next.delete(fieldId);
|
|
} else {
|
|
next.add(fieldId);
|
|
}
|
|
setForm({ ...form, selectedFieldIds: next });
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
if (!form) return;
|
|
setForm({
|
|
...form,
|
|
selectedFieldIds: new Set(candidates.map((candidate) => candidate.field_id)),
|
|
});
|
|
};
|
|
|
|
const handleClearAll = () => {
|
|
if (!form) return;
|
|
setForm({ ...form, selectedFieldIds: new Set() });
|
|
};
|
|
|
|
const selectedCount = form?.selectedFieldIds.size ?? 0;
|
|
|
|
const selectedCandidates = useMemo(() => {
|
|
if (!form) return [];
|
|
return candidates.filter((candidate) => form.selectedFieldIds.has(candidate.field_id));
|
|
}, [candidates, form]);
|
|
|
|
const handleSave = async () => {
|
|
if (!form) return;
|
|
if (selectedCount === 0) {
|
|
setError('対象圃場を1件以上選択してください。');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const payload = {
|
|
year,
|
|
date: form.date,
|
|
title: form.title,
|
|
notes: form.notes,
|
|
items: selectedCandidates.map((candidate) => ({
|
|
field: candidate.field_id,
|
|
plan: candidate.plan_id,
|
|
})),
|
|
};
|
|
if (editingSessionId) {
|
|
await api.put(`/levee-work/sessions/${editingSessionId}/`, payload);
|
|
} else {
|
|
await api.post('/levee-work/sessions/', payload);
|
|
}
|
|
await fetchSessions();
|
|
await startCreate();
|
|
} catch (e: any) {
|
|
console.error(e);
|
|
setError(extractErrorMessage(e));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!editingSessionId) return;
|
|
if (!window.confirm('この畔塗記録を削除しますか?')) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
await api.delete(`/levee-work/sessions/${editingSessionId}/`);
|
|
await fetchSessions();
|
|
setEditingSessionId(null);
|
|
setForm(null);
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError('削除に失敗しました。');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
|
|
|
|
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 gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={() => router.push('/workrecords')} className="text-gray-500 hover:text-gray-700">
|
|
<ChevronLeft className="h-5 w-5" />
|
|
</button>
|
|
<PencilLine className="h-6 w-6 text-amber-700" />
|
|
<h1 className="text-2xl font-bold text-gray-900">畔塗記録</h1>
|
|
</div>
|
|
<div className="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-amber-500"
|
|
>
|
|
{years.map((y) => (
|
|
<option key={y} value={y}>
|
|
{y}年度
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() => void startCreate()}
|
|
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
新規作成
|
|
</button>
|
|
</div>
|
|
</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="grid gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
|
|
<section className="overflow-hidden rounded-lg bg-white shadow-sm">
|
|
<div className="border-b bg-gray-50 px-4 py-3 text-sm font-medium text-gray-700">記録一覧</div>
|
|
{loading ? (
|
|
<div className="px-4 py-8 text-sm text-gray-500">読み込み中...</div>
|
|
) : sessions.length === 0 ? (
|
|
<div className="px-4 py-8 text-sm text-gray-400">この年度の畔塗記録はまだありません。</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100">
|
|
{sessions.map((session) => (
|
|
<button
|
|
key={session.id}
|
|
onClick={() => void openEditor(session)}
|
|
className={`block w-full px-4 py-4 text-left hover:bg-amber-50 ${
|
|
editingSessionId === session.id ? 'bg-amber-50' : ''
|
|
}`}
|
|
>
|
|
<div className="text-sm font-medium text-gray-900">{session.title}</div>
|
|
<div className="mt-1 text-sm text-gray-600">{session.date}</div>
|
|
<div className="mt-1 text-xs text-gray-500">{session.item_count}圃場</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="rounded-lg bg-white shadow-sm">
|
|
<div className="border-b bg-gray-50 px-5 py-3 text-sm font-medium text-gray-700">
|
|
{editingSessionId ? '畔塗記録を編集' : '畔塗記録を作成'}
|
|
</div>
|
|
|
|
{!form ? (
|
|
<div className="px-5 py-10 text-sm text-gray-500">
|
|
{formLoading ? 'フォームを準備中...' : '「新規作成」または既存記録の選択で編集を始められます。'}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6 px-5 py-5">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<label className="block">
|
|
<div className="mb-1 text-sm font-medium text-gray-700">日付</div>
|
|
<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-amber-500"
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<div className="mb-1 text-sm font-medium text-gray-700">タイトル</div>
|
|
<input
|
|
type="text"
|
|
value={form.title}
|
|
onChange={(e) => setForm({ ...form, title: 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-amber-500"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<label className="block">
|
|
<div className="mb-1 text-sm font-medium text-gray-700">備考</div>
|
|
<textarea
|
|
value={form.notes}
|
|
onChange={(e) => setForm({ ...form, notes: 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-amber-500"
|
|
/>
|
|
</label>
|
|
|
|
<div>
|
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-sm font-medium text-gray-900">対象圃場一覧</h2>
|
|
<p className="text-xs text-gray-500">{selectedCount} / {candidates.length} 圃場を選択中</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleSelectAll}
|
|
className="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
|
>
|
|
全選択
|
|
</button>
|
|
<button
|
|
onClick={handleClearAll}
|
|
className="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
|
>
|
|
全解除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{formLoading ? (
|
|
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-500">
|
|
候補圃場を読み込み中...
|
|
</div>
|
|
) : candidates.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-400">
|
|
この年度の水稲作付け圃場がありません。
|
|
</div>
|
|
) : (
|
|
<div className="overflow-hidden rounded-lg border border-gray-200">
|
|
<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-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-left font-medium text-gray-700">品種</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{candidates.map((candidate) => {
|
|
const checked = form.selectedFieldIds.has(candidate.field_id);
|
|
return (
|
|
<tr key={candidate.field_id} className={checked ? 'bg-amber-50/40' : ''}>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={() => handleToggleField(candidate.field_id)}
|
|
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3 font-medium text-gray-900">{candidate.field_name}</td>
|
|
<td className="px-4 py-3 text-gray-700">{candidate.field_area_tan}反</td>
|
|
<td className="px-4 py-3 text-gray-700">{candidate.group_name || '-'}</td>
|
|
<td className="px-4 py-3 text-gray-700">{candidate.variety_name || '(未設定)'}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<button
|
|
onClick={() => void handleSave()}
|
|
disabled={saving || formLoading}
|
|
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<Save className="h-4 w-4" />
|
|
保存
|
|
</button>
|
|
{editingSessionId && (
|
|
<button
|
|
onClick={() => void handleDelete()}
|
|
disabled={saving}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
削除
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|