圃場グループ機能
This commit is contained in:
@@ -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='グループ名'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -39,6 +39,8 @@ class Field(models.Model):
|
|||||||
area_tan = models.DecimalField(max_digits=6, decimal_places=4, verbose_name="面積(反)")
|
area_tan = models.DecimalField(max_digits=6, decimal_places=4, verbose_name="面積(反)")
|
||||||
area_m2 = models.IntegerField(verbose_name="面積(m2)")
|
area_m2 = models.IntegerField(verbose_name="面積(m2)")
|
||||||
owner_name = models.CharField(max_length=100, verbose_name="所有者名")
|
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_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_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)
|
raw_chusankan_id = models.CharField("中山間_ID", max_length=20, null=True, blank=True)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Field, Crop, Plan } from '@/types';
|
import { Field, Crop, Plan } from '@/types';
|
||||||
import Navbar from '@/components/Navbar';
|
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 {
|
interface SummaryItem {
|
||||||
cropId: number;
|
cropId: number;
|
||||||
@@ -17,6 +17,8 @@ interface SummaryItem {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SortType = 'custom' | 'group' | 'crop';
|
||||||
|
|
||||||
export default function AllocationPage() {
|
export default function AllocationPage() {
|
||||||
const [fields, setFields] = useState<Field[]>([]);
|
const [fields, setFields] = useState<Field[]>([]);
|
||||||
const [crops, setCrops] = useState<Crop[]>([]);
|
const [crops, setCrops] = useState<Crop[]>([]);
|
||||||
@@ -27,6 +29,7 @@ export default function AllocationPage() {
|
|||||||
const [showSidebar, setShowSidebar] = useState(true);
|
const [showSidebar, setShowSidebar] = useState(true);
|
||||||
const [showMobileSummary, setShowMobileSummary] = useState(false);
|
const [showMobileSummary, setShowMobileSummary] = useState(false);
|
||||||
const [expandedCrops, setExpandedCrops] = useState<Set<number>>(new Set());
|
const [expandedCrops, setExpandedCrops] = useState<Set<number>>(new Set());
|
||||||
|
const [sortType, setSortType] = useState<SortType>('custom');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -97,6 +100,48 @@ export default function AllocationPage() {
|
|||||||
};
|
};
|
||||||
}, [fields, plans, crops]);
|
}, [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 => {
|
const getPlanForField = (fieldId: number): Plan | undefined => {
|
||||||
return plans.find((p) => p.field === fieldId);
|
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 getVarietiesForCrop = (cropId: number) => {
|
||||||
const crop = crops.find((c) => c.id === cropId);
|
const crop = crops.find((c) => c.id === cropId);
|
||||||
return crop?.varieties || [];
|
return crop?.varieties || [];
|
||||||
@@ -303,6 +415,16 @@ export default function AllocationPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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 htmlFor="year" className="text-sm font-medium text-gray-700">
|
||||||
作付年度:
|
作付年度:
|
||||||
</label>
|
</label>
|
||||||
@@ -319,18 +441,34 @@ export default function AllocationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fields.length === 0 ? (
|
{sortedFields.length === 0 ? (
|
||||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
圃場データがありません。インポートを実行してください。
|
圃場データがありません。インポートを実行してください。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<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 className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
圃場名
|
圃場名
|
||||||
</th>
|
</th>
|
||||||
@@ -349,13 +487,50 @@ export default function AllocationPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{fields.map((field) => {
|
{sortedFields.map((field, index) => {
|
||||||
const plan = getPlanForField(field.id);
|
const plan = getPlanForField(field.id);
|
||||||
const selectedCropId = plan?.crop || 0;
|
const selectedCropId = plan?.crop || 0;
|
||||||
const selectedVarietyId = plan?.variety || 0;
|
const selectedVarietyId = plan?.variety || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={field.id} className="hover:bg-gray-50">
|
<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">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
{field.name}
|
{field.name}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface Field {
|
|||||||
area_tan: string;
|
area_tan: string;
|
||||||
area_m2: number;
|
area_m2: number;
|
||||||
owner_name: string;
|
owner_name: string;
|
||||||
|
group_name: string | null;
|
||||||
|
display_order: number;
|
||||||
kyosai_fields: OfficialKyosaiField[];
|
kyosai_fields: OfficialKyosaiField[];
|
||||||
chusankan_fields: OfficialChusankanField[];
|
chusankan_fields: OfficialChusankanField[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user