施肥計画機能を追加(年度×品種単位のマトリクス管理)
- 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:
5
frontend/src/app/fertilizer/[id]/edit/page.tsx
Normal file
5
frontend/src/app/fertilizer/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import FertilizerEditPage from '../../_components/FertilizerEditPage';
|
||||
|
||||
export default function EditFertilizerPage({ params }: { params: { id: string } }) {
|
||||
return <FertilizerEditPage planId={parseInt(params.id)} />;
|
||||
}
|
||||
606
frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx
Normal file
606
frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx
Normal file
@@ -0,0 +1,606 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronLeft, Plus, X, Calculator, Save, FileDown } from 'lucide-react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Fertilizer, FertilizationPlan, Crop, Field } from '@/types';
|
||||
|
||||
type CalcMethod = 'per_tan' | 'even' | 'nitrogen';
|
||||
|
||||
interface CalcSetting {
|
||||
fertilizer_id: number;
|
||||
method: CalcMethod;
|
||||
param: string;
|
||||
}
|
||||
|
||||
// matrix: field_id → fertilizer_id → bags
|
||||
type Matrix = Record<number, Record<number, string>>;
|
||||
|
||||
const METHOD_LABELS: Record<CalcMethod, string> = {
|
||||
per_tan: '反当袋数',
|
||||
even: '均等配分',
|
||||
nitrogen: '反当チッソ',
|
||||
};
|
||||
|
||||
const METHOD_UNIT: Record<CalcMethod, string> = {
|
||||
per_tan: '袋/反',
|
||||
even: '袋(総数)',
|
||||
nitrogen: 'kg/反 (N)',
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||
const router = useRouter();
|
||||
const isNew = !planId;
|
||||
|
||||
// ─── ヘッダー情報
|
||||
const [name, setName] = useState('');
|
||||
const [year, setYear] = useState(currentYear);
|
||||
const [varietyId, setVarietyId] = useState<number | ''>('');
|
||||
|
||||
// ─── マスタデータ
|
||||
const [crops, setCrops] = useState<Crop[]>([]);
|
||||
const [allFertilizers, setAllFertilizers] = useState<Fertilizer[]>([]);
|
||||
|
||||
// ─── 圃場
|
||||
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
|
||||
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
|
||||
const [showFieldPicker, setShowFieldPicker] = useState(false);
|
||||
const [allFields, setAllFields] = useState<Field[]>([]);
|
||||
|
||||
// ─── 肥料(計画に使う肥料)
|
||||
const [planFertilizers, setPlanFertilizers] = useState<Fertilizer[]>([]);
|
||||
const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([]);
|
||||
const [showFertPicker, setShowFertPicker] = useState(false);
|
||||
|
||||
// ─── マトリクス(袋数)
|
||||
const [matrix, setMatrix] = useState<Matrix>({});
|
||||
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// ─── 初期データ取得
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const [cropsRes, fertsRes, fieldsRes] = await Promise.all([
|
||||
api.get('/plans/crops/'),
|
||||
api.get('/fertilizer/fertilizers/'),
|
||||
api.get('/fields/?ordering=display_order,id'),
|
||||
]);
|
||||
setCrops(cropsRes.data);
|
||||
setAllFertilizers(fertsRes.data);
|
||||
setAllFields(fieldsRes.data);
|
||||
|
||||
if (!isNew && planId) {
|
||||
const planRes = await api.get(`/fertilizer/plans/${planId}/`);
|
||||
const plan: FertilizationPlan = planRes.data;
|
||||
setName(plan.name);
|
||||
setYear(plan.year);
|
||||
setVarietyId(plan.variety);
|
||||
|
||||
// エントリからマトリクス・肥料リストを復元
|
||||
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
|
||||
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
|
||||
setPlanFertilizers(ferts);
|
||||
setCalcSettings(ferts.map((f: Fertilizer) => ({ fertilizer_id: f.id, method: 'per_tan' as CalcMethod, param: '' })));
|
||||
|
||||
const fieldIds = Array.from(new Set(plan.entries.map((e) => e.field)));
|
||||
const fields = fieldsRes.data.filter((f: Field) => fieldIds.includes(f.id));
|
||||
setSelectedFields(fields);
|
||||
|
||||
const newMatrix: Matrix = {};
|
||||
plan.entries.forEach((e) => {
|
||||
if (!newMatrix[e.field]) newMatrix[e.field] = {};
|
||||
newMatrix[e.field][e.fertilizer] = String(e.bags);
|
||||
});
|
||||
setMatrix(newMatrix);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status?: number; data?: unknown } };
|
||||
console.error('初期データ取得エラー:', err);
|
||||
alert(`データの読み込みに失敗しました (${err.response?.status ?? 'network error'})\n${JSON.stringify(err.response?.data ?? '')}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [planId, isNew]);
|
||||
|
||||
// ─── 品種変更時: 候補圃場を取得して selectedFields をリセット
|
||||
const fetchCandidates = useCallback(async (y: number, vId: number) => {
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/candidate_fields/?year=${y}&variety_id=${vId}`);
|
||||
const candidates: Field[] = res.data;
|
||||
setCandidateFields(candidates);
|
||||
// 既存選択を候補で上書き(新規作成時のみ)
|
||||
if (isNew) setSelectedFields(candidates);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [isNew]);
|
||||
|
||||
useEffect(() => {
|
||||
if (varietyId && year) {
|
||||
fetchCandidates(year, varietyId as number);
|
||||
}
|
||||
}, [varietyId, year, fetchCandidates]);
|
||||
|
||||
// ─── 肥料追加
|
||||
const addFertilizer = (fert: Fertilizer) => {
|
||||
if (planFertilizers.find((f) => f.id === fert.id)) return;
|
||||
setPlanFertilizers((prev) => [...prev, fert]);
|
||||
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
|
||||
setShowFertPicker(false);
|
||||
};
|
||||
|
||||
const removeFertilizer = (id: number) => {
|
||||
if (!confirm('この肥料を計画から削除しますか?')) return;
|
||||
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
|
||||
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
|
||||
setMatrix((prev) => {
|
||||
const next = { ...prev };
|
||||
Object.keys(next).forEach((fid) => {
|
||||
const row = { ...next[Number(fid)] };
|
||||
delete row[id];
|
||||
next[Number(fid)] = row;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ─── 圃場追加・削除
|
||||
const addField = (field: Field) => {
|
||||
if (selectedFields.find((f) => f.id === field.id)) return;
|
||||
setSelectedFields((prev) => [...prev, field]);
|
||||
setShowFieldPicker(false);
|
||||
};
|
||||
|
||||
const removeField = (id: number) => {
|
||||
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
|
||||
setMatrix((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ─── 自動計算
|
||||
const runCalc = async (setting: CalcSetting) => {
|
||||
if (!setting.param) return alert('パラメータを入力してください');
|
||||
if (selectedFields.length === 0) return alert('対象圃場を選択してください');
|
||||
|
||||
try {
|
||||
const res = await api.post('/fertilizer/calculate/', {
|
||||
method: setting.method,
|
||||
param: parseFloat(setting.param),
|
||||
fertilizer_id: setting.fertilizer_id,
|
||||
field_ids: selectedFields.map((f) => f.id),
|
||||
});
|
||||
const results: { field_id: number; bags: number }[] = res.data;
|
||||
setMatrix((prev) => {
|
||||
const next = { ...prev };
|
||||
results.forEach(({ field_id, bags }) => {
|
||||
if (!next[field_id]) next[field_id] = {};
|
||||
next[field_id][setting.fertilizer_id] = String(bags);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
alert(err.response?.data?.error ?? '計算に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
const updateCalcSetting = (fertId: number, key: keyof CalcSetting, value: string) => {
|
||||
setCalcSettings((prev) =>
|
||||
prev.map((s) => (s.fertilizer_id === fertId ? { ...s, [key]: value } : s))
|
||||
);
|
||||
};
|
||||
|
||||
// ─── セル更新
|
||||
const updateCell = (fieldId: number, fertId: number, value: string) => {
|
||||
setMatrix((prev) => {
|
||||
const next = { ...prev };
|
||||
if (!next[fieldId]) next[fieldId] = {};
|
||||
next[fieldId][fertId] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ─── 保存
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) return alert('計画名を入力してください');
|
||||
if (!varietyId) return alert('品種を選択してください');
|
||||
if (selectedFields.length === 0) return alert('圃場を1つ以上選択してください');
|
||||
|
||||
const entries: { field_id: number; fertilizer_id: number; bags: number }[] = [];
|
||||
selectedFields.forEach((field) => {
|
||||
planFertilizers.forEach((fert) => {
|
||||
const v = matrix[field.id]?.[fert.id];
|
||||
if (v && parseFloat(v) > 0) {
|
||||
entries.push({ field_id: field.id, fertilizer_id: fert.id, bags: parseFloat(v) });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { name, year, variety: varietyId, entries };
|
||||
if (isNew) {
|
||||
await api.post('/fertilizer/plans/', payload);
|
||||
} else {
|
||||
await api.put(`/fertilizer/plans/${planId}/`, payload);
|
||||
}
|
||||
router.push('/fertilizer');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: unknown } };
|
||||
console.error(err);
|
||||
alert('保存に失敗しました: ' + JSON.stringify(err.response?.data));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PDF出力
|
||||
const handlePdf = async () => {
|
||||
if (!planId) return;
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/plans/${planId}/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) {
|
||||
alert('PDF出力に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 集計
|
||||
const rowTotal = (fieldId: number) =>
|
||||
planFertilizers.reduce((sum, f) => {
|
||||
const v = parseFloat(matrix[fieldId]?.[f.id] ?? '0') || 0;
|
||||
return sum + v;
|
||||
}, 0);
|
||||
|
||||
const colTotal = (fertId: number) =>
|
||||
selectedFields.reduce((sum, f) => {
|
||||
const v = parseFloat(matrix[f.id]?.[fertId] ?? '0') || 0;
|
||||
return sum + v;
|
||||
}, 0);
|
||||
|
||||
const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 0);
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||||
const availableFerts = allFertilizers.filter((f) => !planFertilizers.find((pf) => pf.id === f.id));
|
||||
const unselectedFields = allFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="max-w-7xl mx-auto px-4 py-8 text-gray-500">読み込み中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* ヘッダー */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-gray-800">
|
||||
{isNew ? '施肥計画 新規作成' : '施肥計画 編集'}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isNew && (
|
||||
<button
|
||||
onClick={handlePdf}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<FileDown className="h-4 w-4" />
|
||||
PDF出力
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基本情報 */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||||
<div className="flex-1 min-w-48">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">計画名</label>
|
||||
<input
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例: 2025年度 コシヒカリ 元肥"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">年度</label>
|
||||
<select
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||
>
|
||||
{years.map((y) => <option key={y} value={y}>{y}年度</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="min-w-48">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">品種</label>
|
||||
<select
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
value={varietyId}
|
||||
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
|
||||
>
|
||||
<option value="">品種を選択</option>
|
||||
{crops.map((crop) => (
|
||||
<optgroup key={crop.id} label={crop.name}>
|
||||
{crop.varieties.map((v) => (
|
||||
<option key={v.id} value={v.id}>{v.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 対象圃場 */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700">
|
||||
対象圃場
|
||||
<span className="ml-2 text-gray-400 font-normal">
|
||||
{selectedFields.length}筆 /
|
||||
{selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)}反
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowFieldPicker(true)}
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />圃場を追加
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedFields.length === 0 && (
|
||||
<p className="text-sm text-gray-400">
|
||||
品種を選択すると作付け計画から圃場が自動抽出されます
|
||||
</p>
|
||||
)}
|
||||
{selectedFields.map((f) => (
|
||||
<span
|
||||
key={f.id}
|
||||
className="flex items-center gap-1 bg-green-50 border border-green-200 rounded-full px-3 py-1 text-xs text-green-800"
|
||||
>
|
||||
{f.name}({f.area_tan}反)
|
||||
<button onClick={() => removeField(f.id)} className="text-green-400 hover:text-red-500">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自動計算設定パネル */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700">自動計算設定</h2>
|
||||
<button
|
||||
onClick={() => setShowFertPicker(true)}
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />肥料を追加
|
||||
</button>
|
||||
</div>
|
||||
{planFertilizers.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">肥料を追加してください</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{planFertilizers.map((fert) => {
|
||||
const setting = calcSettings.find((s) => s.fertilizer_id === fert.id);
|
||||
if (!setting) return null;
|
||||
return (
|
||||
<div key={fert.id} className="flex items-center gap-3 py-2 border-b last:border-b-0">
|
||||
<span className="font-medium text-sm w-40 truncate" title={fert.name}>
|
||||
{fert.name}
|
||||
</span>
|
||||
<select
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
value={setting.method}
|
||||
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
|
||||
>
|
||||
{Object.entries(METHOD_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs w-24 text-right"
|
||||
value={setting.param}
|
||||
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
|
||||
placeholder="値"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
|
||||
<button
|
||||
onClick={() => runCalc(setting)}
|
||||
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100"
|
||||
>
|
||||
<Calculator className="h-3 w-3" />計算
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeFertilizer(fert.id)}
|
||||
className="ml-auto text-gray-300 hover:text-red-500"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* マトリクス表 */}
|
||||
{selectedFields.length > 0 && planFertilizers.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">圃場名</th>
|
||||
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">面積(反)</th>
|
||||
{planFertilizers.map((f) => (
|
||||
<th key={f.id} className="text-center px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">
|
||||
{f.name}
|
||||
<span className="block text-xs font-normal text-gray-400">(袋)</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">合計袋数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedFields.map((field) => (
|
||||
<tr key={field.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 border border-gray-200 whitespace-nowrap">{field.name}</td>
|
||||
<td className="px-3 py-2 border border-gray-200 text-right text-gray-600">{field.area_tan}</td>
|
||||
{planFertilizers.map((fert) => (
|
||||
<td key={fert.id} className="px-2 py-1 border border-gray-200">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="w-full text-right border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-green-400 rounded px-1 py-1"
|
||||
value={matrix[field.id]?.[fert.id] ?? ''}
|
||||
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 border border-gray-200 text-right font-medium">
|
||||
{rowTotal(field.id) > 0 ? rowTotal(field.id).toFixed(2) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td className="px-4 py-2 border border-gray-200">合計</td>
|
||||
<td className="px-3 py-2 border border-gray-200 text-right text-gray-500">
|
||||
{selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)}
|
||||
</td>
|
||||
{planFertilizers.map((f) => (
|
||||
<td key={f.id} className="px-3 py-2 border border-gray-200 text-right">
|
||||
{colTotal(f.id) > 0 ? colTotal(f.id).toFixed(2) : '-'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 border border-gray-200 text-right text-green-700">
|
||||
{grandTotal > 0 ? grandTotal.toFixed(2) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 圃場選択ピッカー */}
|
||||
{showFieldPicker && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="font-bold">圃場を追加</h3>
|
||||
<button onClick={() => setShowFieldPicker(false)}><X className="h-5 w-5 text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-2">
|
||||
{candidateFields.length > 0 && (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 px-2 py-1">作付け計画から({year}年度 / 選択品種)</p>
|
||||
{candidateFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id)).map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => addField(f)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between"
|
||||
>
|
||||
<span>{f.name}</span>
|
||||
<span className="text-gray-400">{f.area_tan}反</span>
|
||||
</button>
|
||||
))}
|
||||
<hr className="my-2" />
|
||||
<p className="text-xs text-gray-500 px-2 py-1">その他の圃場</p>
|
||||
</>
|
||||
)}
|
||||
{unselectedFields.filter((f) => !candidateFields.find((cf) => cf.id === f.id)).map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => addField(f)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between"
|
||||
>
|
||||
<span>{f.name}</span>
|
||||
<span className="text-gray-400">{f.area_tan}反</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 肥料選択ピッカー */}
|
||||
{showFertPicker && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="font-bold">肥料を追加</h3>
|
||||
<button onClick={() => setShowFertPicker(false)}><X className="h-5 w-5 text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-2">
|
||||
{availableFerts.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 px-3 py-4">追加できる肥料がありません</p>
|
||||
) : (
|
||||
availableFerts.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => addFertilizer(f)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm"
|
||||
>
|
||||
<span className="font-medium">{f.name}</span>
|
||||
{f.maker && <span className="ml-2 text-gray-400 text-xs">{f.maker}</span>}
|
||||
{f.nitrogen_pct && (
|
||||
<span className="ml-2 text-blue-500 text-xs">N:{f.nitrogen_pct}%</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<div className="border-t mt-2 pt-2">
|
||||
<button
|
||||
onClick={() => { setShowFertPicker(false); router.push('/fertilizer/masters'); }}
|
||||
className="w-full text-left px-3 py-2 text-xs text-green-600 hover:bg-green-50 rounded"
|
||||
>
|
||||
+ 新しい肥料をマスタに登録する
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
316
frontend/src/app/fertilizer/masters/page.tsx
Normal file
316
frontend/src/app/fertilizer/masters/page.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, Pencil, Trash2, ChevronLeft, Check, X } from 'lucide-react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Fertilizer } from '@/types';
|
||||
|
||||
const emptyForm = (): Omit<Fertilizer, 'id'> => ({
|
||||
name: '',
|
||||
maker: null,
|
||||
capacity_kg: null,
|
||||
nitrogen_pct: null,
|
||||
phosphorus_pct: null,
|
||||
potassium_pct: null,
|
||||
notes: null,
|
||||
});
|
||||
|
||||
export default function FertilizerMastersPage() {
|
||||
const router = useRouter();
|
||||
const [fertilizers, setFertilizers] = useState<Fertilizer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
|
||||
const [form, setForm] = useState(emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFertilizers();
|
||||
}, []);
|
||||
|
||||
const fetchFertilizers = async () => {
|
||||
try {
|
||||
const res = await api.get('/fertilizer/fertilizers/');
|
||||
setFertilizers(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startNew = () => {
|
||||
setForm(emptyForm());
|
||||
setEditingId('new');
|
||||
};
|
||||
|
||||
const startEdit = (f: Fertilizer) => {
|
||||
setForm({
|
||||
name: f.name,
|
||||
maker: f.maker,
|
||||
capacity_kg: f.capacity_kg,
|
||||
nitrogen_pct: f.nitrogen_pct,
|
||||
phosphorus_pct: f.phosphorus_pct,
|
||||
potassium_pct: f.potassium_pct,
|
||||
notes: f.notes,
|
||||
});
|
||||
setEditingId(f.id);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm());
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) return alert('肥料名を入力してください');
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
maker: form.maker || null,
|
||||
capacity_kg: form.capacity_kg || null,
|
||||
nitrogen_pct: form.nitrogen_pct || null,
|
||||
phosphorus_pct: form.phosphorus_pct || null,
|
||||
potassium_pct: form.potassium_pct || null,
|
||||
notes: form.notes || null,
|
||||
};
|
||||
if (editingId === 'new') {
|
||||
await api.post('/fertilizer/fertilizers/', payload);
|
||||
} else {
|
||||
await api.put(`/fertilizer/fertilizers/${editingId}/`, payload);
|
||||
}
|
||||
await fetchFertilizers();
|
||||
setEditingId(null);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: unknown } };
|
||||
console.error(err);
|
||||
alert('保存に失敗しました: ' + JSON.stringify(err.response?.data));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
if (!confirm(`「${name}」を削除しますか?`)) return;
|
||||
try {
|
||||
await api.delete(`/fertilizer/fertilizers/${id}/`);
|
||||
await fetchFertilizers();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('削除に失敗しました(施肥計画で使用中の肥料は削除できません)');
|
||||
}
|
||||
};
|
||||
|
||||
const setField = (key: keyof typeof form, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value || null }));
|
||||
};
|
||||
|
||||
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">
|
||||
<button
|
||||
onClick={() => router.push('/fertilizer')}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-800">肥料マスタ</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={startNew}
|
||||
disabled={editingId !== null}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規追加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500">読み込み中...</p>
|
||||
) : (
|
||||
<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">1袋(kg)</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="text-right 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="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{editingId === 'new' && (
|
||||
<EditRow
|
||||
form={form}
|
||||
setField={setField}
|
||||
onSave={handleSave}
|
||||
onCancel={cancelEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
{fertilizers.map((f) =>
|
||||
editingId === f.id ? (
|
||||
<EditRow
|
||||
key={f.id}
|
||||
form={form}
|
||||
setField={setField}
|
||||
onSave={handleSave}
|
||||
onCancel={cancelEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
) : (
|
||||
<tr key={f.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{f.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{f.maker ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{f.capacity_kg ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{f.nitrogen_pct ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{f.phosphorus_pct ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{f.potassium_pct ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 max-w-xs truncate">{f.notes ?? '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => startEdit(f)}
|
||||
disabled={editingId !== null}
|
||||
className="text-gray-400 hover:text-blue-600 disabled:opacity-30"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(f.id, f.name)}
|
||||
disabled={editingId !== null}
|
||||
className="text-gray-400 hover:text-red-600 disabled:opacity-30"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{fertilizers.length === 0 && editingId === null && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
肥料が登録されていません
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditRow({
|
||||
form,
|
||||
setField,
|
||||
onSave,
|
||||
onCancel,
|
||||
saving,
|
||||
}: {
|
||||
form: Omit<Fertilizer, 'id'>;
|
||||
setField: (key: keyof Omit<Fertilizer, 'id'>, value: string) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const inputCls = 'w-full border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500';
|
||||
const numCls = inputCls + ' text-right';
|
||||
|
||||
return (
|
||||
<tr className="bg-green-50">
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={form.name}
|
||||
onChange={(e) => setField('name', e.target.value)}
|
||||
placeholder="肥料名(必須)"
|
||||
autoFocus
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={form.maker ?? ''}
|
||||
onChange={(e) => setField('maker', e.target.value)}
|
||||
placeholder="メーカー"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={numCls}
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={form.capacity_kg ?? ''}
|
||||
onChange={(e) => setField('capacity_kg', e.target.value)}
|
||||
placeholder="kg"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={numCls}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.nitrogen_pct ?? ''}
|
||||
onChange={(e) => setField('nitrogen_pct', e.target.value)}
|
||||
placeholder="%"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={numCls}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.phosphorus_pct ?? ''}
|
||||
onChange={(e) => setField('phosphorus_pct', e.target.value)}
|
||||
placeholder="%"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={numCls}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.potassium_pct ?? ''}
|
||||
onChange={(e) => setField('potassium_pct', e.target.value)}
|
||||
placeholder="%"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={form.notes ?? ''}
|
||||
onChange={(e) => setField('notes', e.target.value)}
|
||||
placeholder="備考"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="text-green-600 hover:text-green-800 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/fertilizer/new/page.tsx
Normal file
5
frontend/src/app/fertilizer/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import FertilizerEditPage from '../_components/FertilizerEditPage';
|
||||
|
||||
export default function NewFertilizerPage() {
|
||||
return <FertilizerEditPage />;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud } from 'lucide-react';
|
||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout } from 'lucide-react';
|
||||
import { logout } from '@/lib/api';
|
||||
|
||||
export default function Navbar() {
|
||||
@@ -111,6 +111,17 @@ export default function Navbar() {
|
||||
<Cloud className="h-4 w-4 mr-2" />
|
||||
気象
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/fertilizer')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
pathname?.startsWith('/fertilizer')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Sprout className="h-4 w-4 mr-2" />
|
||||
施肥計画
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
|
||||
@@ -56,6 +56,41 @@ export interface Plan {
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface Fertilizer {
|
||||
id: number;
|
||||
name: string;
|
||||
maker: string | null;
|
||||
capacity_kg: string | null;
|
||||
nitrogen_pct: string | null;
|
||||
phosphorus_pct: string | null;
|
||||
potassium_pct: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface FertilizationEntry {
|
||||
id?: number;
|
||||
field: number;
|
||||
field_name?: string;
|
||||
field_area_tan?: string;
|
||||
fertilizer: number;
|
||||
fertilizer_name?: string;
|
||||
bags: number;
|
||||
}
|
||||
|
||||
export interface FertilizationPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
year: number;
|
||||
variety: number;
|
||||
variety_name: string;
|
||||
crop_name: string;
|
||||
entries: FertilizationEntry[];
|
||||
field_count: number;
|
||||
fertilizer_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MailSender {
|
||||
id: number;
|
||||
type: 'address' | 'domain';
|
||||
|
||||
Reference in New Issue
Block a user