diff --git a/backend/apps/fields/migrations/0004_field_display_order_field_group_name.py b/backend/apps/fields/migrations/0004_field_display_order_field_group_name.py new file mode 100644 index 0000000..27fa731 --- /dev/null +++ b/backend/apps/fields/migrations/0004_field_display_order_field_group_name.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0 on 2026-02-15 06:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fields', '0003_remove_field_chusankan_field_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='field', + name='display_order', + field=models.IntegerField(default=0, help_text='リスト表示時の順序', verbose_name='表示順'), + ), + migrations.AddField( + model_name='field', + name='group_name', + field=models.CharField(blank=True, help_text='エリアや用途によるグループ分け', max_length=50, null=True, verbose_name='グループ名'), + ), + ] diff --git a/backend/apps/fields/models.py b/backend/apps/fields/models.py index 4d87cff..45ff419 100644 --- a/backend/apps/fields/models.py +++ b/backend/apps/fields/models.py @@ -39,6 +39,8 @@ class Field(models.Model): area_tan = models.DecimalField(max_digits=6, decimal_places=4, verbose_name="面積(反)") area_m2 = models.IntegerField(verbose_name="面積(m2)") owner_name = models.CharField(max_length=100, verbose_name="所有者名") + group_name = models.CharField("グループ名", max_length=50, blank=True, null=True, help_text="エリアや用途によるグループ分け") + display_order = models.IntegerField("表示順", default=0, help_text="リスト表示時の順序") raw_kyosai_k_num = models.CharField("細目_耕地番号", max_length=20, null=True, blank=True) raw_kyosai_s_num = models.CharField("細目_分筆番号", max_length=20, null=True, blank=True) raw_chusankan_id = models.CharField("中山間_ID", max_length=20, null=True, blank=True) diff --git a/frontend/src/app/allocation/page.tsx b/frontend/src/app/allocation/page.tsx index 91c4c3a..1d5df1e 100644 --- a/frontend/src/app/allocation/page.tsx +++ b/frontend/src/app/allocation/page.tsx @@ -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([]); const [crops, setCrops] = useState([]); @@ -27,6 +29,7 @@ export default function AllocationPage() { const [showSidebar, setShowSidebar] = useState(true); const [showMobileSummary, setShowMobileSummary] = useState(false); const [expandedCrops, setExpandedCrops] = useState>(new Set()); + const [sortType, setSortType] = useState('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(); + 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() {
+ + @@ -319,18 +441,34 @@ export default function AllocationPage() {
- {fields.length === 0 ? ( + {sortedFields.length === 0 ? (

圃場データがありません。インポートを実行してください。

) : ( +
+

+ 💡 グループ名を入力(または選択)して「グループ順」で並び替え、「↑」「↓」ボタンで順序を変更できます +

+
+ )} + + {sortedFields.length > 0 && (
+ {sortType === 'custom' && ( + + )} + @@ -349,13 +487,50 @@ export default function AllocationPage() { - {fields.map((field) => { + {sortedFields.map((field, index) => { const plan = getPlanForField(field.id); const selectedCropId = plan?.crop || 0; const selectedVarietyId = plan?.variety || 0; return ( + {sortType === 'custom' && ( + + )} +
+ 順序 + + グループ + 圃場名
+
+ + +
+
+ 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" + /> + + {groupOptions.map((g) => ( + +
{field.name} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ac5fcfe..0f9a00a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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[]; }