施肥計画機能を追加(年度×品種単位のマトリクス管理)

- 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:
Akira
2026-03-01 12:14:29 +09:00
parent 371e40236c
commit f207f5de27
23 changed files with 1695 additions and 174 deletions

View 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>
);
}