圃場グループ機能

This commit is contained in:
Akira
2026-02-15 15:51:51 +09:00
parent f4165e2c68
commit 4486722949
4 changed files with 205 additions and 3 deletions

View File

@@ -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='グループ名'),
),
]

View File

@@ -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)

View File

@@ -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}

View File

@@ -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[];
} }