Files
keinasystem/frontend/src/app/allocation/page.tsx
2026-04-05 16:55:44 +09:00

1236 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
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, Copy, Settings, Trash2, CheckSquare, Search, History } from 'lucide-react';
interface SummaryItem {
cropId: number;
cropName: string;
areaTan: number;
varieties: {
varietyId: number | null;
varietyName: string;
areaTan: number;
}[];
}
type SortType = 'custom' | 'group' | 'crop';
export default function AllocationPage() {
const [fields, setFields] = useState<Field[]>([]);
const [crops, setCrops] = useState<Crop[]>([]);
const [plans, setPlans] = useState<Plan[]>([]);
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('allocationYear');
if (saved) return parseInt(saved);
}
return new Date().getFullYear();
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<number | null>(null);
const [showSidebar, setShowSidebar] = useState(true);
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);
const [selectedFields, setSelectedFields] = useState<Set<number>>(new Set());
const [bulkCropId, setBulkCropId] = useState<number | 0>(0);
const [bulkVarietyId, setBulkVarietyId] = useState<number | 0>(0);
const [bulkUpdating, setBulkUpdating] = useState(false);
const [searchText, setSearchText] = useState('');
const [filterCropId, setFilterCropId] = useState<number | 0>(0);
const [filterUnassigned, setFilterUnassigned] = useState(false);
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(null), 4000);
return () => window.clearTimeout(timer);
}, [toast]);
useEffect(() => {
localStorage.setItem('allocationYear', String(year));
fetchData();
}, [year]);
const currentYear = new Date().getFullYear();
const isPastYear = year < currentYear;
const fetchData = async (background = false) => {
if (!background) setLoading(true);
try {
const [fieldsRes, cropsRes, plansRes] = await Promise.all([
api.get('/fields/?ordering=group_name,display_order,id'),
api.get('/plans/crops/'),
api.get(`/plans/?year=${year}`),
]);
setFields(fieldsRes.data);
setCrops(cropsRes.data);
setPlans(plansRes.data);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
if (!background) setLoading(false);
}
};
const summary = useMemo(() => {
const cropSummary = new Map<number, SummaryItem>();
let totalAreaTan = 0;
let unassignedAreaTan = 0;
fields.forEach((field) => {
const area = parseFloat(field.area_tan) || 0;
totalAreaTan += area;
const plan = plans.find((p) => p.field === field.id);
if (!plan?.crop) {
unassignedAreaTan += area;
return;
}
if (!cropSummary.has(plan.crop)) {
const crop = crops.find((c) => c.id === plan.crop);
cropSummary.set(plan.crop, {
cropId: plan.crop,
cropName: crop?.name || '不明',
areaTan: 0,
varieties: [],
});
}
const item = cropSummary.get(plan.crop)!;
item.areaTan += area;
const varietyId = plan.variety;
const varietyName = plan.variety_name || '(品種未選択)';
let varietyItem = item.varieties.find((v) => v.varietyId === varietyId);
if (!varietyItem) {
varietyItem = { varietyId, varietyName, areaTan: 0 };
item.varieties.push(varietyItem);
}
varietyItem.areaTan += area;
});
return {
totalAreaTan,
unassignedAreaTan,
items: Array.from(cropSummary.values()),
};
}, [fields, plans, crops]);
const sortedFields = useMemo(() => {
const sorted = [...fields];
if (sortType === 'custom') {
sorted.sort((a, b) => {
const orderA = a.display_order ?? 0;
const orderB = b.display_order ?? 0;
return orderA - orderB;
});
} else if (sortType === 'group') {
sorted.sort((a, b) => {
const groupA = a.group_name || '';
const groupB = b.group_name || '';
if (groupA !== groupB) return groupA.localeCompare(groupB);
const orderA = a.display_order ?? 0;
const orderB = b.display_order ?? 0;
return orderA - orderB;
});
} else if (sortType === 'crop') {
sorted.sort((a, b) => {
const planA = plans.find((p) => p.field === a.id);
const planB = plans.find((p) => p.field === b.id);
const cropA = planA?.crop || 0;
const cropB = planB?.crop || 0;
if (cropA !== cropB) return cropA - cropB;
const orderA = a.display_order ?? 0;
const orderB = b.display_order ?? 0;
return orderA - orderB;
});
}
return sorted;
}, [fields, sortType, plans]);
const filteredFields = useMemo(() => {
let result = sortedFields;
if (searchText) {
const query = searchText.toLowerCase();
result = result.filter(
(f) =>
f.name.toLowerCase().includes(query) ||
(f.address && f.address.toLowerCase().includes(query))
);
}
if (filterCropId) {
result = result.filter((f) => {
const plan = plans.find((p) => p.field === f.id);
return plan?.crop === filterCropId;
});
}
if (filterUnassigned) {
result = result.filter((f) => {
const plan = plans.find((p) => p.field === f.id);
return !plan?.crop;
});
}
return result;
}, [sortedFields, searchText, filterCropId, filterUnassigned, plans]);
const groupOptions = useMemo(() => {
const groups = new Set<string>();
fields.forEach(f => {
if (f.group_name) groups.add(f.group_name);
});
return Array.from(groups).sort();
}, [fields]);
const getPlanForField = (fieldId: number): Plan | undefined => {
return plans.find((p) => p.field === fieldId);
};
const handleCropChange = async (fieldId: number, cropId: string) => {
const crop = parseInt(cropId);
if (!crop) return;
const existingPlan = getPlanForField(fieldId);
setSaving(fieldId);
try {
if (existingPlan) {
await api.patch(`/plans/${existingPlan.id}/`, {
crop,
variety: null,
notes: existingPlan.notes,
});
} else {
await api.post('/plans/', {
field: fieldId,
year,
crop,
variety: null,
notes: '',
});
}
await fetchData(true);
} catch (error) {
console.error('Failed to save crop:', error);
} finally {
setSaving(null);
}
};
const handleVarietyChange = async (fieldId: number, varietyId: string) => {
const variety = parseInt(varietyId) || null;
const existingPlan = getPlanForField(fieldId);
if (!existingPlan || !existingPlan.crop) return;
if ((existingPlan.variety || null) === variety) return;
const nextVarietyName =
variety === null
? '(品種未選択)'
: getVarietiesForCrop(existingPlan.crop).find((item) => item.id === variety)?.name || '不明';
const currentVarietyName = existingPlan.variety_name || '(品種未選択)';
const shouldProceed = confirm(
[
`品種を「${currentVarietyName}」から「${nextVarietyName}」へ変更します。`,
'施肥計画・田植え計画の関連エントリが自動で移動する場合があります。',
'実行しますか?',
].join('\n')
);
if (!shouldProceed) return;
setSaving(fieldId);
try {
const res = await api.patch(`/plans/${existingPlan.id}/`, {
variety,
notes: existingPlan.notes,
});
const updatedPlan: Plan = res.data;
const movedCount = updatedPlan.latest_variety_change?.fertilizer_moved_entry_count ?? 0;
setToast({
type: 'success',
message:
movedCount > 0
? `品種を変更し、施肥計画 ${movedCount} 件を移動しました。`
: '品種を変更しました。関連する施肥計画の移動はありませんでした。',
});
await fetchData(true);
} catch (error) {
console.error('Failed to save variety:', error);
setToast({
type: 'error',
message: '品種変更に失敗しました。',
});
} finally {
setSaving(null);
}
};
const handleNotesChange = async (fieldId: number, notes: string) => {
const existingPlan = getPlanForField(fieldId);
if (!existingPlan) return;
setSaving(fieldId);
try {
await api.patch(`/plans/${existingPlan.id}/`, { notes });
await fetchData(true);
} catch (error) {
console.error('Failed to save notes:', error);
} finally {
setSaving(null);
}
};
const handleGroupChange = async (fieldId: number, groupName: string) => {
setFields(prev => prev.map(f =>
f.id === fieldId ? { ...f, group_name: groupName || null } : f
));
try {
await api.patch(`/fields/${fieldId}/`, {
group_name: groupName || null
});
} catch (error) {
console.error('Failed to save group:', error);
await fetchData(true);
}
};
const moveUp = async (index: number) => {
if (index === 0) return;
const current = sortedFields[index];
const prev = sortedFields[index - 1];
setSaving(current.id);
setSaving(prev.id);
try {
const newOrderCurrent = prev.display_order ?? (index - 1);
const newOrderPrev = current.display_order ?? index;
await Promise.all([
api.patch(`/fields/${current.id}/`, { display_order: newOrderCurrent }),
api.patch(`/fields/${prev.id}/`, { display_order: newOrderPrev })
]);
await fetchData(true);
} catch (error) {
console.error('Failed to move up:', error);
} finally {
setSaving(null);
}
};
const moveDown = async (index: number) => {
if (index === sortedFields.length - 1) return;
const current = sortedFields[index];
const next = sortedFields[index + 1];
setSaving(current.id);
setSaving(next.id);
try {
const newOrderCurrent = next.display_order ?? (index + 1);
const newOrderPrev = current.display_order ?? index;
await Promise.all([
api.patch(`/fields/${current.id}/`, { display_order: newOrderCurrent }),
api.patch(`/fields/${next.id}/`, { display_order: newOrderPrev })
]);
await fetchData(true);
} catch (error) {
console.error('Failed to move down:', error);
} finally {
setSaving(null);
}
};
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 handleUpdateVarietyDefaultBoxes = async (varietyId: number, defaultBoxes: string) => {
try {
const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId);
if (!variety) return;
await api.patch(`/plans/varieties/${varietyId}/`, {
default_seedling_boxes_per_tan: defaultBoxes,
});
await fetchData(true);
} catch (error) {
console.error('Failed to update variety default boxes:', error);
alert('品種デフォルトの更新に失敗しました');
}
};
const toggleFieldSelection = (fieldId: number) => {
setSelectedFields((prev) => {
const next = new Set(prev);
if (next.has(fieldId)) next.delete(fieldId);
else next.add(fieldId);
return next;
});
};
const toggleAllFields = () => {
if (selectedFields.size === filteredFields.length) {
setSelectedFields(new Set());
} else {
setSelectedFields(new Set(filteredFields.map((f) => f.id)));
}
};
const handleBulkUpdate = async () => {
if (selectedFields.size === 0 || !bulkCropId) return;
if (!confirm(`選択した${selectedFields.size}件の圃場に一括で作物・品種を設定します。\n既存の設定は上書きされます。実行しますか`)) return;
setBulkUpdating(true);
try {
await api.post('/plans/bulk_update/', {
field_ids: Array.from(selectedFields),
year,
crop: bulkCropId,
variety: bulkVarietyId || null,
});
setSelectedFields(new Set());
setBulkCropId(0);
setBulkVarietyId(0);
await fetchData();
} catch (error) {
console.error('Bulk update failed:', error);
alert('一括更新に失敗しました');
} finally {
setBulkUpdating(false);
}
};
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 || [];
};
const toggleCropExpand = (cropId: number) => {
setExpandedCrops((prev) => {
const next = new Set(prev);
if (next.has(cropId)) {
next.delete(cropId);
} else {
next.add(cropId);
}
return next;
});
};
const renderSidebar = () => (
<>
<div className="p-4 border-b flex justify-between items-center">
{showSidebar && <h2 className="font-bold text-gray-900"></h2>}
<button
onClick={() => setShowSidebar(!showSidebar)}
className="p-1 hover:bg-gray-100 rounded"
>
{showSidebar ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{showSidebar && (
<div className="p-4">
<div className="mb-4">
<div className="text-sm text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900">
{summary.totalAreaTan.toFixed(2)}
</div>
<div className="text-xs text-gray-500">
({Math.round(summary.totalAreaTan * 1000)} m²)
</div>
</div>
{summary.unassignedAreaTan > 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div className="text-sm text-yellow-800"></div>
<div className="font-bold text-yellow-900">
{summary.unassignedAreaTan.toFixed(2)}
</div>
</div>
)}
<div className="space-y-2">
{summary.items.map((item) => (
<div key={item.cropId} className="border rounded-md overflow-hidden">
<button
onClick={() => toggleCropExpand(item.cropId)}
className="w-full p-3 flex items-center justify-between bg-gray-50 hover:bg-gray-100"
>
<div className="text-left">
<div className="font-medium text-gray-900">{item.cropName}</div>
<div className="text-sm text-gray-500">
{item.areaTan.toFixed(2)}
</div>
</div>
{expandedCrops.has(item.cropId) ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
{expandedCrops.has(item.cropId) && (
<div className="border-t bg-white">
{item.varieties.map((v, idx) => (
<div
key={idx}
className="px-3 py-2 flex justify-between text-sm"
>
<span className="text-gray-600">{v.varietyName}</span>
<span className="text-gray-900 font-medium">
{v.areaTan.toFixed(2)}
</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</>
);
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">...</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="flex flex-col lg:flex-row">
{/* PC用サイドバー */}
<div
className={`hidden lg:block transition-all duration-300 ${
showSidebar ? 'w-64' : 'w-12'
} bg-white shadow rounded-lg m-4 overflow-hidden flex-shrink-0`}
>
{renderSidebar()}
</div>
{/* メインコンテンツ */}
<div className="flex-1 min-w-0 p-4 lg:p-0">
<div className="max-w-7xl mx-auto">
{toast && (
<div
className={`mb-4 rounded-md border px-4 py-3 text-sm ${
toast.type === 'success'
? 'border-green-300 bg-green-50 text-green-800'
: 'border-red-300 bg-red-50 text-red-800'
}`}
>
{toast.message}
</div>
)}
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h1 className="text-2xl font-bold text-gray-900">
<span className="text-green-700">{year}</span>
</h1>
{/* スマホ用集計ボタン */}
<button
className="lg:hidden px-4 py-2 bg-blue-600 text-white rounded-md flex items-center gap-2"
onClick={() => setShowMobileSummary(true)}
>
<BarChart3 className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<select
value={sortType}
onChange={(e) => setSortType(e.target.value as SortType)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm"
>
<option value="custom"></option>
<option value="group"></option>
<option value="crop"></option>
</select>
<label htmlFor="year" className="text-sm font-medium text-gray-700">
:
</label>
<select
id="year"
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 font-semibold"
>
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - 2 + i).map((y) => (
<option key={y} value={y}>{y}</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>
{/* 検索・フィルタバー */}
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="圃場名・住所で検索..."
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<select
value={filterCropId || ''}
onChange={(e) => { setFilterCropId(parseInt(e.target.value) || 0); setFilterUnassigned(false); }}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value=""></option>
{crops.map((crop) => (
<option key={crop.id} value={crop.id}>{crop.name}</option>
))}
</select>
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={filterUnassigned}
onChange={(e) => { setFilterUnassigned(e.target.checked); if (e.target.checked) setFilterCropId(0); }}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</label>
{(searchText || filterCropId || filterUnassigned) && (
<span className="text-sm text-gray-500">
{filteredFields.length}/{fields.length}
</span>
)}
</div>
{filteredFields.length === 0 && fields.length > 0 ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500">
</p>
</div>
) : sortedFields.length === 0 ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500">
</p>
</div>
) : (
<>
{isPastYear && (
<div className="mb-4 p-3 bg-amber-50 border border-amber-300 rounded-md flex items-center justify-between">
<p className="text-sm text-amber-800 font-medium">
{year}
</p>
<button
onClick={() => setYear(currentYear)}
className="text-sm text-amber-700 underline hover:text-amber-900"
>
{currentYear}
</button>
</div>
)}
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800">
💡
</p>
</div>
{selectedFields.size > 0 && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-md flex items-center gap-3 flex-wrap">
<span className="text-sm font-medium text-green-800">
<CheckSquare className="h-4 w-4 inline mr-1" />
{selectedFields.size}
</span>
<select
value={bulkCropId || ''}
onChange={(e) => { setBulkCropId(parseInt(e.target.value) || 0); setBulkVarietyId(0); }}
className="px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value=""></option>
{crops.map((crop) => (
<option key={crop.id} value={crop.id}>{crop.name}</option>
))}
</select>
{bulkCropId > 0 && (
<select
value={bulkVarietyId || ''}
onChange={(e) => setBulkVarietyId(parseInt(e.target.value) || 0)}
className="px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value=""></option>
{getVarietiesForCrop(bulkCropId).map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
)}
<button
onClick={handleBulkUpdate}
disabled={!bulkCropId || bulkUpdating}
className="px-3 py-1.5 bg-green-600 text-white rounded-md text-sm hover:bg-green-700 disabled:opacity-50"
>
{bulkUpdating ? '更新中...' : '一括設定'}
</button>
<button
onClick={() => setSelectedFields(new Set())}
className="px-3 py-1.5 text-gray-600 hover:text-gray-800 text-sm"
>
</button>
</div>
)}
<div className={`rounded-lg shadow overflow-hidden ${isPastYear ? 'bg-amber-50/50 ring-1 ring-amber-200' : 'bg-white'}`}>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-2 py-3 w-10">
<input
type="checkbox"
checked={selectedFields.size === filteredFields.length && filteredFields.length > 0}
onChange={toggleAllFields}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</th>
{sortType === 'custom' && (
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
</th>
)}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
()
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredFields.map((field, index) => {
const plan = getPlanForField(field.id);
const selectedCropId = plan?.crop || 0;
const selectedVarietyId = plan?.variety || 0;
return (
<tr key={field.id} className={`hover:bg-gray-50 ${selectedFields.has(field.id) ? 'bg-green-50' : ''}`}>
<td className="px-2 py-4 w-10">
<input
type="checkbox"
checked={selectedFields.has(field.id)}
onChange={() => toggleFieldSelection(field.id)}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</td>
{sortType === 'custom' && (
<td className="px-2 py-4 whitespace-nowrap">
<div className="flex items-center gap-1">
<button
onClick={() => moveUp(index)}
disabled={index === 0 || saving === field.id}
className="p-1 hover:bg-gray-100 rounded disabled:opacity-30"
title="上へ移動"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
onClick={() => moveDown(index)}
disabled={index === filteredFields.length - 1 || saving === field.id}
className="p-1 hover:bg-gray-100 rounded disabled:opacity-30"
title="下へ移動"
>
<ArrowDown className="h-4 w-4" />
</button>
</div>
</td>
)}
<td className="px-4 py-4 whitespace-nowrap">
<input
list="group-options"
value={field.group_name || ''}
onChange={(e) => handleGroupChange(field.id, e.target.value)}
disabled={saving === field.id}
placeholder="選択または入力"
className="w-36 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"
/>
<datalist id="group-options">
{groupOptions.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{field.name}
</div>
<div className="text-xs text-gray-500">
{field.address}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{field.area_tan}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={selectedCropId || ''}
onChange={(e) =>
handleCropChange(field.id, e.target.value)
}
disabled={saving === field.id}
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"
>
<option value=""></option>
{crops.map((crop) => (
<option key={crop.id} value={crop.id}>
{crop.name}
</option>
))}
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{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>
) : (
<div className="flex items-center gap-2">
<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>
{plan?.latest_variety_change && (
<div
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800"
title={[
`変更日時: ${new Date(plan.latest_variety_change.changed_at).toLocaleString('ja-JP')}`,
`変更前: ${plan.latest_variety_change.old_variety_name || '未設定'}`,
`変更後: ${plan.latest_variety_change.new_variety_name || '未設定'}`,
`施肥移動件数: ${plan.latest_variety_change.fertilizer_moved_entry_count}`,
].join('\n')}
>
<History className="h-3 w-3" />
</div>
)}
</div>
)}
</td>
<td className="px-6 py-4">
<input
type="text"
value={plan?.notes || ''}
onChange={(e) =>
handleNotesChange(field.id, e.target.value)
}
disabled={saving === field.id || !plan}
placeholder="備考を入力"
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm w-full disabled:opacity-50 disabled:bg-gray-100"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
</div>
</div>
{/* スマホ用モーダル */}
{showMobileSummary && (
<div className="fixed inset-0 z-50 lg:hidden">
<div
className="absolute inset-0 bg-black/50"
onClick={() => setShowMobileSummary(false)}
/>
<div className="absolute inset-y-0 right-0 w-full max-w-md bg-white shadow-xl">
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-bold text-lg"></h2>
<button
onClick={() => setShowMobileSummary(false)}
className="p-2 hover:bg-gray-100 rounded"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="mb-4">
<div className="text-sm text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900">
{summary.totalAreaTan.toFixed(2)}
</div>
<div className="text-xs text-gray-500">
({Math.round(summary.totalAreaTan * 1000)} m²)
</div>
</div>
{summary.unassignedAreaTan > 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div className="text-sm text-yellow-800"></div>
<div className="font-bold text-yellow-900">
{summary.unassignedAreaTan.toFixed(2)}
</div>
</div>
)}
<div className="space-y-2">
{summary.items.map((item) => (
<div key={item.cropId} className="border rounded-md overflow-hidden">
<button
onClick={() => toggleCropExpand(item.cropId)}
className="w-full p-3 flex items-center justify-between bg-gray-50 hover:bg-gray-100"
>
<div className="text-left">
<div className="font-medium text-gray-900">
{item.cropName}
</div>
<div className="text-sm text-gray-500">
{item.areaTan.toFixed(2)}
</div>
</div>
{expandedCrops.has(item.cropId) ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
{expandedCrops.has(item.cropId) && (
<div className="border-t bg-white">
{item.varieties.map((v, idx) => (
<div
key={idx}
className="px-3 py-2 flex justify-between text-sm"
>
<span className="text-gray-600">{v.varietyName}</span>
<span className="text-gray-900 font-medium">
{v.areaTan.toFixed(2)}
</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
</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="rounded border border-gray-200 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium 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>
</div>
<VarietyDefaultBoxesForm
varietyId={v.id}
initialValue={v.default_seedling_boxes_per_tan}
onSave={handleUpdateVarietyDefaultBoxes}
/>
</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>
);
}
function VarietyDefaultBoxesForm({
varietyId,
initialValue,
onSave,
}: {
varietyId: number;
initialValue: string;
onSave: (varietyId: number, defaultBoxes: string) => Promise<void>;
}) {
const [value, setValue] = useState(initialValue);
const [saving, setSaving] = useState(false);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const handleSave = async () => {
setSaving(true);
await onSave(varietyId, value);
setSaving(false);
};
return (
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs text-gray-600"></label>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</div>
<button
onClick={handleSave}
disabled={saving}
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
</button>
</div>
);
}