施肥計画機能を追加(年度×品種単位のマトリクス管理)
- Backend: apps/fertilizer を新規追加 - Fertilizer(肥料マスタ)、FertilizationPlan、FertilizationEntry モデル - 肥料マスタ・施肥計画 CRUD API - 3方式の自動計算API(反当袋数・均等配分・反当チッソ成分量) - 作付け計画から圃場候補を取得する API - WeasyPrint による PDF 出力(圃場×肥料=袋数 マトリクス表) - Frontend: app/fertilizer を新規追加 - 施肥計画一覧(年度セレクタ・PDF出力・編集・削除) - 肥料マスタ管理(インライン編集) - 施肥計画編集(品種選択→圃場自動取得→肥料追加→自動計算→マトリクス手動調整) - Navbar に「施肥計画」メニューを追加(Sprout アイコン) - Cursor ルールファイル・連携ガイドを削除(Claude Code 単独運用へ) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
177
frontend/src/app/fertilizer/page.tsx
Normal file
177
frontend/src/app/fertilizer/page.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, Pencil, Trash2, FileDown, Sprout } from 'lucide-react';
|
||||
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);
|
||||
|
||||
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) => {
|
||||
if (!confirm(`「${name}」を削除しますか?`)) return;
|
||||
try {
|
||||
await api.delete(`/fertilizer/plans/${id}/`);
|
||||
await fetchPlans();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('削除に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdf = async (id: number, name: string) => {
|
||||
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);
|
||||
alert('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>
|
||||
|
||||
{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-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="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{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 text-gray-600">{plan.fertilizer_count}種</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => handlePdf(plan.id, plan.name)}
|
||||
className="text-gray-400 hover:text-blue-600"
|
||||
title="PDF出力"
|
||||
>
|
||||
<FileDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/fertilizer/${plan.id}/edit`)}
|
||||
className="text-gray-400 hover:text-green-600"
|
||||
title="編集"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(plan.id, plan.name)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
title="削除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user