A-6 完了。 本セッションの進捗まとめ:

タスク	内容	状態
A-3	前年度コピーボタン	 完了
A-4	品種のインライン追加・削除	 完了
A-5	PDFプレビュー機能	 完了
A-6	エクスポート機能	 完了
残りタスク:

A-2: チェックボックス・一括操作
A-1: ダッシュボード画面
A-7: 検索・フィルタ
確認ポイント:

作付け計画 (/allocation): 年度セレクタの横に「前年度コピー」「品種管理」ボタン、品種セレクトに「+ 新しい品種を追加...」
帳票出力 (/reports): 各帳票にプレビュー/ダウンロードの2ボタン
データ取込 (/import): ページ下部に「データエクスポート」(ZIPダウンロード)
This commit is contained in:
Akira
2026-02-19 12:21:17 +09:00
parent 23cb4d3118
commit 8b5e0fc66e
9 changed files with 497 additions and 128 deletions

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api';
import { Field, Crop, Plan } from '@/types';
import Navbar from '@/components/Navbar';
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown } from 'lucide-react';
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2 } from 'lucide-react';
interface SummaryItem {
cropId: number;
@@ -30,6 +30,11 @@ export default function AllocationPage() {
const [showMobileSummary, setShowMobileSummary] = useState(false);
const [expandedCrops, setExpandedCrops] = useState<Set<number>>(new Set());
const [sortType, setSortType] = useState<SortType>('custom');
const [copying, setCopying] = useState(false);
const [addingVariety, setAddingVariety] = useState<{ fieldId: number; cropId: number } | null>(null);
const [newVarietyName, setNewVarietyName] = useState('');
const [showVarietyManager, setShowVarietyManager] = useState(false);
const [managerCropId, setManagerCropId] = useState<number | null>(null);
useEffect(() => {
fetchData();
@@ -279,6 +284,63 @@ export default function AllocationPage() {
}
};
const handleAddVariety = async (fieldId: number, cropId: number) => {
const name = newVarietyName.trim();
if (!name) return;
try {
const res = await api.post('/plans/varieties/', { crop: cropId, name });
setNewVarietyName('');
setAddingVariety(null);
await fetchData(true);
// Auto-select the new variety
const plan = getPlanForField(fieldId);
if (plan) {
await api.patch(`/plans/${plan.id}/`, { variety: res.data.id });
await fetchData(true);
}
} catch (error: any) {
if (error.response?.status === 400) {
alert('この品種名は既に登録されています');
} else {
console.error('Failed to add variety:', error);
alert('品種の追加に失敗しました');
}
}
};
const handleDeleteVariety = async (varietyId: number, varietyName: string) => {
if (!confirm(`品種「${varietyName}」を削除しますか?\nこの品種が設定されている作付け計画がある場合、削除できません。`)) return;
try {
await api.delete(`/plans/varieties/${varietyId}/`);
await fetchData(true);
} catch (error: any) {
console.error('Failed to delete variety:', error);
alert('品種の削除に失敗しました。使用中の品種は削除できません。');
}
};
const handleCopyFromPreviousYear = async () => {
const fromYear = year - 1;
if (!confirm(`${fromYear}年度の作付け計画を${year}年度にコピーします。\n既に設定済みの圃場はスキップされます。\n実行しますか`)) return;
setCopying(true);
try {
const res = await api.post('/plans/copy_from_previous_year/', {
from_year: fromYear,
to_year: year,
});
alert(res.data.message || 'コピーが完了しました');
await fetchData();
} catch (error: any) {
console.error('Failed to copy:', error);
alert(error.response?.data?.error || 'コピーに失敗しました');
} finally {
setCopying(false);
}
};
const getVarietiesForCrop = (cropId: number) => {
const crop = crops.find((c) => c.id === cropId);
return crop?.varieties || [];
@@ -436,6 +498,25 @@ export default function AllocationPage() {
<option value={2026}>2026</option>
<option value={2027}>2027</option>
</select>
<button
onClick={handleCopyFromPreviousYear}
disabled={copying}
className="flex items-center px-3 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 text-gray-700"
title={`${year - 1}年度の計画をコピー`}
>
<Copy className="h-4 w-4 mr-1" />
{copying ? 'コピー中...' : '前年度コピー'}
</button>
<button
onClick={() => { setShowVarietyManager(true); setManagerCropId(crops[0]?.id || null); }}
className="flex items-center px-3 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 text-gray-700"
title="品種管理"
>
<Settings className="h-4 w-4 mr-1" />
</button>
</div>
</div>
@@ -557,23 +638,56 @@ export default function AllocationPage() {
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={selectedVarietyId || ''}
onChange={(e) =>
handleVarietyChange(field.id, e.target.value)
}
disabled={
saving === field.id || !selectedCropId
}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
>
<option value=""></option>
{getVarietiesForCrop(selectedCropId).map((variety) => (
<option key={variety.id} value={variety.id}>
{variety.name}
</option>
))}
</select>
{addingVariety?.fieldId === field.id ? (
<div className="flex items-center gap-1">
<input
type="text"
value={newVarietyName}
onChange={(e) => setNewVarietyName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddVariety(field.id, addingVariety.cropId);
if (e.key === 'Escape') { setAddingVariety(null); setNewVarietyName(''); }
}}
placeholder="品種名を入力"
className="px-2 py-1.5 border border-green-400 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 w-32"
autoFocus
/>
<button
onClick={() => handleAddVariety(field.id, addingVariety.cropId)}
className="px-2 py-1.5 bg-green-600 text-white rounded text-xs hover:bg-green-700"
>
</button>
<button
onClick={() => { setAddingVariety(null); setNewVarietyName(''); }}
className="px-2 py-1.5 text-gray-500 hover:text-gray-700 text-xs"
>
</button>
</div>
) : (
<select
value={selectedVarietyId || ''}
onChange={(e) => {
if (e.target.value === '__add__') {
setAddingVariety({ fieldId: field.id, cropId: selectedCropId });
setNewVarietyName('');
} else {
handleVarietyChange(field.id, e.target.value);
}
}}
disabled={saving === field.id || !selectedCropId}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
>
<option value=""></option>
{getVarietiesForCrop(selectedCropId).map((variety) => (
<option key={variety.id} value={variety.id}>
{variety.name}
</option>
))}
{selectedCropId > 0 && <option value="__add__">+ ...</option>}
</select>
)}
</td>
<td className="px-6 py-4">
<input
@@ -683,6 +797,104 @@ export default function AllocationPage() {
</div>
</div>
)}
{/* 品種管理モーダル */}
{showVarietyManager && (
<div className="fixed inset-0 bg-black/50 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="text-lg font-bold text-gray-900"></h3>
<button onClick={() => setShowVarietyManager(false)} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 border-b">
<label className="text-sm text-gray-600 mr-2">:</label>
<select
value={managerCropId || ''}
onChange={(e) => setManagerCropId(parseInt(e.target.value) || null)}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
{crops.map((crop) => (
<option key={crop.id} value={crop.id}>{crop.name}</option>
))}
</select>
</div>
<div className="flex-1 overflow-y-auto p-4">
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
<ul className="space-y-2">
{getVarietiesForCrop(managerCropId).map((v) => (
<li key={v.id} className="flex items-center justify-between p-2 rounded hover:bg-gray-50">
<span className="text-sm text-gray-900">{v.name}</span>
<button
onClick={() => handleDeleteVariety(v.id, v.name)}
className="text-red-400 hover:text-red-600 p-1"
title="削除"
>
<Trash2 className="h-4 w-4" />
</button>
</li>
))}
</ul>
) : (
<p className="text-gray-500 text-sm text-center py-4"></p>
)}
</div>
<div className="p-4 border-t">
<VarietyAddForm cropId={managerCropId} onAdd={async (name) => {
if (!managerCropId) return;
try {
await api.post('/plans/varieties/', { crop: managerCropId, name });
await fetchData(true);
} catch (error: any) {
if (error.response?.status === 400) {
alert('この品種名は既に登録されています');
} else {
alert('品種の追加に失敗しました');
}
}
}} />
</div>
</div>
</div>
)}
</div>
);
}
function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name: string) => Promise<void> }) {
const [name, setName] = useState('');
const [adding, setAdding] = useState(false);
const handleSubmit = async () => {
const trimmed = name.trim();
if (!trimmed) return;
setAdding(true);
await onAdd(trimmed);
setName('');
setAdding(false);
};
return (
<div className="flex items-center gap-2">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
placeholder="新しい品種名"
disabled={!cropId || adding}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50"
/>
<button
onClick={handleSubmit}
disabled={!cropId || !name.trim() || adding}
className="px-3 py-2 bg-green-600 text-white rounded-md text-sm hover:bg-green-700 disabled:opacity-50"
>
{adding ? '追加中...' : '追加'}
</button>
</div>
);
}