圃場グループ機能
This commit is contained in:
@@ -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 } from 'lucide-react';
|
||||
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
|
||||
interface SummaryItem {
|
||||
cropId: number;
|
||||
@@ -17,6 +17,8 @@ interface SummaryItem {
|
||||
}[];
|
||||
}
|
||||
|
||||
type SortType = 'custom' | 'group' | 'crop';
|
||||
|
||||
export default function AllocationPage() {
|
||||
const [fields, setFields] = useState<Field[]>([]);
|
||||
const [crops, setCrops] = useState<Crop[]>([]);
|
||||
@@ -27,6 +29,7 @@ export default function AllocationPage() {
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [showMobileSummary, setShowMobileSummary] = useState(false);
|
||||
const [expandedCrops, setExpandedCrops] = useState<Set<number>>(new Set());
|
||||
const [sortType, setSortType] = useState<SortType>('custom');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -97,6 +100,48 @@ export default function AllocationPage() {
|
||||
};
|
||||
}, [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 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);
|
||||
};
|
||||
@@ -169,6 +214,73 @@ export default function AllocationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 getVarietiesForCrop = (cropId: number) => {
|
||||
const crop = crops.find((c) => c.id === cropId);
|
||||
return crop?.varieties || [];
|
||||
@@ -303,6 +415,16 @@ export default function AllocationPage() {
|
||||
</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>
|
||||
@@ -319,18 +441,34 @@ export default function AllocationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
{sortedFields.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<p className="text-gray-500">
|
||||
圃場データがありません。インポートを実行してください。
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{sortedFields.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{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>
|
||||
@@ -349,13 +487,50 @@ export default function AllocationPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{fields.map((field) => {
|
||||
{sortedFields.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">
|
||||
{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 === sortedFields.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}
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface Field {
|
||||
area_tan: string;
|
||||
area_m2: number;
|
||||
owner_name: string;
|
||||
group_name: string | null;
|
||||
display_order: number;
|
||||
kyosai_fields: OfficialKyosaiField[];
|
||||
chusankan_fields: OfficialChusankanField[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user