分配計画機能を実装
施肥計画の圃場を配置場所単位でグループ化し、グループ×肥料の集計表を 表示・PDF出力できる機能を追加。 - Backend: DistributionPlan/Group/GroupField モデル (migration 0003) - API: GET/POST/PUT/DELETE/PDF (/api/fertilizer/distribution/) - Frontend: 一覧・新規作成・編集画面 (/distribution) - Navbar に分配計画メニューを追加 - 集計プレビューはクライアントサイド計算(API不要) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
180
frontend/src/app/distribution/page.tsx
Normal file
180
frontend/src/app/distribution/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FlaskConical, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { DistributionPlanListItem } from '@/types';
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
const YEAR_KEY = 'distributionYear';
|
||||
|
||||
export default function DistributionListPage() {
|
||||
const router = useRouter();
|
||||
const [year, setYear] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
|
||||
}
|
||||
return CURRENT_YEAR;
|
||||
});
|
||||
const [plans, setPlans] = useState<DistributionPlanListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(YEAR_KEY, String(year));
|
||||
fetchPlans();
|
||||
}, [year]);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/distribution/?year=${year}`);
|
||||
setPlans(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.delete(`/fertilizer/distribution/${id}/`);
|
||||
setPlans(prev => prev.filter(p => p.id !== id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setDeleteError('削除できませんでした。');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdf = async (id: number, planName: string) => {
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/distribution/${id}/pdf/`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `distribution_${planName}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<main className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<FlaskConical className="h-7 w-7 text-green-700" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">分配計画</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/distribution/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規作成
|
||||
</button>
|
||||
</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(Number(e.target.value))}
|
||||
className="border border-gray-300 rounded-md px-3 py-1.5 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="flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-md px-4 py-3 mb-4 text-sm">
|
||||
<span className="flex-1">{deleteError}</span>
|
||||
<button onClick={() => setDeleteError(null)}><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500 text-sm">読み込み中...</p>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<FlaskConical className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-lg font-medium mb-1">{year}年の分配計画はありません</p>
|
||||
<p className="text-sm mb-6">施肥計画を元に分配計画を作成できます</p>
|
||||
<button
|
||||
onClick={() => router.push('/distribution/new')}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
|
||||
>
|
||||
+ 新規作成
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">計画名</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">施肥計画</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">作物/品種</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">グループ数</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">圃場数</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="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{plan.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{plan.fertilization_plan_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{plan.crop_name} / {plan.variety_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.group_count}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.field_count}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<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(`/distribution/${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)}
|
||||
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>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user