1236 lines
50 KiB
TypeScript
1236 lines
50 KiB
TypeScript
'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>
|
||
);
|
||
}
|