テスト 結果 確定取消 API ✅ is_confirmed: false, confirmed_at: null USE トランザクション削除 ✅ current_stock が 27.5→32 に復帰 引当再作成 ✅ reserved_stock = 5.000 に復帰 追加した変更: stock_service.py:81-93 — unconfirm_spreading(): USE削除→確定フラグリセット→引当再作成 fertilizer/views.py — unconfirm アクション(POST /api/fertilizer/plans/{id}/unconfirm/) fertilizer/page.tsx — 一覧に「確定取消」ボタン(確定済み計画のみ表示) FertilizerEditPage.tsx — 編集画面ヘッダーに「確定取消」ボタン + 在庫情報再取得
260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Plus, Pencil, Trash2, FileDown, Sprout, BadgeCheck, Undo2 } from 'lucide-react';
|
|
|
|
import ConfirmSpreadingModal from './_components/ConfirmSpreadingModal';
|
|
import Navbar from '@/components/Navbar';
|
|
import { api } from '@/lib/api';
|
|
import { FertilizationPlan } from '@/types';
|
|
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
export default function FertilizerPage() {
|
|
const router = useRouter();
|
|
const [year, setYear] = useState<number>(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const saved = localStorage.getItem('fertilizerYear');
|
|
if (saved) return parseInt(saved);
|
|
}
|
|
return currentYear;
|
|
});
|
|
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [confirmTarget, setConfirmTarget] = useState<FertilizationPlan | null>(null);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('fertilizerYear', String(year));
|
|
fetchPlans();
|
|
}, [year]);
|
|
|
|
const fetchPlans = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await api.get(`/fertilizer/plans/?year=${year}`);
|
|
setPlans(res.data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number, name: string) => {
|
|
setDeleteError(null);
|
|
setActionError(null);
|
|
try {
|
|
await api.delete(`/fertilizer/plans/${id}/`);
|
|
await fetchPlans();
|
|
} catch (e) {
|
|
console.error(e);
|
|
setDeleteError(`「${name}」の削除に失敗しました`);
|
|
}
|
|
};
|
|
|
|
const handleUnconfirm = async (id: number, name: string) => {
|
|
setActionError(null);
|
|
try {
|
|
await api.post(`/fertilizer/plans/${id}/unconfirm/`);
|
|
await fetchPlans();
|
|
} catch (e) {
|
|
console.error(e);
|
|
setActionError(`「${name}」の確定取消に失敗しました`);
|
|
}
|
|
};
|
|
|
|
const handlePdf = async (id: number, name: string) => {
|
|
setActionError(null);
|
|
try {
|
|
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
|
|
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `施肥計画_${year}_${name}.pdf`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
console.error(e);
|
|
setActionError('PDF出力に失敗しました');
|
|
}
|
|
};
|
|
|
|
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<Navbar />
|
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<Sprout className="h-6 w-6 text-green-600" />
|
|
<h1 className="text-2xl font-bold text-gray-800">施肥計画</h1>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => router.push('/fertilizer/masters')}
|
|
className="px-4 py-2 text-sm border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-100"
|
|
>
|
|
肥料マスタ
|
|
</button>
|
|
<button
|
|
onClick={() => router.push('/fertilizer/new')}
|
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
新規作成
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 年度セレクタ */}
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<label className="text-sm font-medium text-gray-700">年度:</label>
|
|
<select
|
|
value={year}
|
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
|
className="border border-gray-300 rounded-lg 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>
|
|
|
|
{deleteError && (
|
|
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
|
|
<span className="font-bold shrink-0">⚠</span>
|
|
<span>{deleteError}</span>
|
|
<button onClick={() => setDeleteError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600">✕</button>
|
|
</div>
|
|
)}
|
|
|
|
{actionError && (
|
|
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
|
|
<span className="font-bold shrink-0">⚠</span>
|
|
<span>{actionError}</span>
|
|
<button onClick={() => setActionError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600">✕</button>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<p className="text-gray-500">読み込み中...</p>
|
|
) : plans.length === 0 ? (
|
|
<div className="bg-white rounded-lg shadow p-12 text-center text-gray-400">
|
|
<Sprout className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
|
<p>{year}年度の施肥計画はありません</p>
|
|
<button
|
|
onClick={() => router.push('/fertilizer/new')}
|
|
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
|
|
>
|
|
最初の計画を作成する
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-gray-50 border-b">
|
|
<tr>
|
|
<th className="text-left px-4 py-3 font-medium text-gray-700">計画名</th>
|
|
<th className="text-left px-4 py-3 font-medium text-gray-700">作物 / 品種</th>
|
|
<th className="text-left px-4 py-3 font-medium text-gray-700">状態</th>
|
|
<th className="text-right px-4 py-3 font-medium text-gray-700">圃場数</th>
|
|
<th className="text-right px-4 py-3 font-medium text-gray-700">肥料種数</th>
|
|
<th className="px-4 py-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{plans.map((plan) => (
|
|
<tr
|
|
key={plan.id}
|
|
className={plan.is_confirmed ? 'bg-sky-50 hover:bg-sky-100/60' : 'hover:bg-gray-50'}
|
|
>
|
|
<td className="px-4 py-3 font-medium">
|
|
<div className="flex items-center gap-2">
|
|
<span>{plan.name}</span>
|
|
{plan.is_confirmed && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">
|
|
<BadgeCheck className="h-3.5 w-3.5" />
|
|
確定済み
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-600">
|
|
{plan.crop_name} / {plan.variety_name}
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-600">
|
|
{plan.is_confirmed
|
|
? `散布確定 ${plan.confirmed_at ? new Date(plan.confirmed_at).toLocaleString('ja-JP') : ''}`
|
|
: '未確定'}
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}筆</td>
|
|
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}種</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2 justify-end">
|
|
{!plan.is_confirmed ? (
|
|
<button
|
|
onClick={() => setConfirmTarget(plan)}
|
|
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-emerald-300 rounded hover:bg-emerald-50 text-emerald-700"
|
|
title="散布確定"
|
|
>
|
|
<BadgeCheck className="h-3.5 w-3.5" />
|
|
散布確定
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => handleUnconfirm(plan.id, plan.name)}
|
|
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-amber-300 rounded hover:bg-amber-50 text-amber-700"
|
|
title="確定取消"
|
|
>
|
|
<Undo2 className="h-3.5 w-3.5" />
|
|
確定取消
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handlePdf(plan.id, plan.name)}
|
|
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"
|
|
title="PDF出力"
|
|
>
|
|
<FileDown className="h-3.5 w-3.5" />
|
|
PDF
|
|
</button>
|
|
<button
|
|
onClick={() => router.push(`/fertilizer/${plan.id}/edit`)}
|
|
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-blue-300 rounded hover:bg-blue-50 text-blue-700"
|
|
title="編集"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
編集
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(plan.id, plan.name)}
|
|
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-red-300 rounded hover:bg-red-50 text-red-600"
|
|
title="削除"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
削除
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<ConfirmSpreadingModal
|
|
isOpen={confirmTarget !== null}
|
|
plan={confirmTarget}
|
|
onClose={() => setConfirmTarget(null)}
|
|
onConfirmed={fetchPlans}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|