Files
keinasystem/frontend/src/app/fields/page.tsx
Akira 4afe37968b A-1(ダッシュボード画面)の実装が完了しました。
実装内容:

バックエンド: summary APIに total_fields, assigned_fields, unassigned_fields を追加
フロントエンド: /dashboard に新画面を作成
概要サマリー: 全圃場数 / 作付け済み / 未割当(警告アイコン付き)
作物別集計テーブル(筆数・面積・合計行)
クイックアクセス: 4つのボタン(作付け計画・圃場管理・帳票出力・データ取込)
年度セレクタで切替可能
Navbar: 「ホーム」ボタン追加、KeinaSystemロゴクリックでダッシュボードへ
ルート (/): /allocation → /dashboard にリダイレクト先変更
http://localhost:3000/dashboard で確認できます。

残りタスク: A-7(検索・フィルタ)のみです
2026-02-19 13:07:16 +09:00

520 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { Field, OfficialKyosaiField, OfficialChusankanField } from '@/types';
import Navbar from '@/components/Navbar';
import LinkModal from '@/components/LinkModal';
import { Plus, Pencil, Trash2, ArrowUp, ArrowDown, X } from 'lucide-react';
type ViewMode = 'normal' | 'mapping';
export default function FieldsPage() {
const router = useRouter();
const [fields, setFields] = useState<Field[]>([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<number | null>(null);
const [uniqueGroups, setUniqueGroups] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState('group_name,display_order,id');
const [viewMode, setViewMode] = useState<ViewMode>('normal');
// Modal state for mapping mode
const [modalFieldId, setModalFieldId] = useState<number | null>(null);
const [modalType, setModalType] = useState<'kyosai' | 'chusankan' | null>(null);
const [allKyosai, setAllKyosai] = useState<OfficialKyosaiField[]>([]);
const [allChusankan, setAllChusankan] = useState<OfficialChusankanField[]>([]);
useEffect(() => {
fetchFields();
}, [sortOrder]);
const fetchFields = async () => {
setLoading(true);
try {
const response = await api.get(`/fields/?ordering=${sortOrder}`);
setFields(response.data);
const groups = Array.from(new Set<string>(response.data.map((f: Field) => f.group_name).filter(Boolean)));
setUniqueGroups(groups.sort());
} catch (error) {
console.error('Failed to fetch fields:', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('この圃場を削除してもよろしいですか?')) return;
setDeleting(id);
try {
await api.delete(`/fields/${id}/`);
await fetchFields();
} catch (error) {
console.error('Failed to delete field:', error);
alert('削除に失敗しました');
} finally {
setDeleting(null);
}
};
const handleGroupChange = async (fieldId: number, newGroup: string) => {
try {
await api.patch(`/fields/${fieldId}/`, { group_name: newGroup || null });
if (newGroup && !uniqueGroups.includes(newGroup)) {
setUniqueGroups([...uniqueGroups, newGroup].sort());
}
if (sortOrder !== 'id') await fetchFields();
} catch (error) {
console.error('Failed to update group:', error);
alert('グループの更新に失敗しました');
}
};
const handleMoveOrder = async (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= fields.length) return;
if (sortOrder !== 'display_order,group_name,id') {
setSortOrder('display_order,group_name,id');
return;
}
const currentField = fields[index];
const targetField = fields[newIndex];
try {
await api.patch(`/fields/${currentField.id}/`, { display_order: targetField.display_order ?? 0 });
await api.patch(`/fields/${targetField.id}/`, { display_order: currentField.display_order ?? 0 });
await fetchFields();
} catch (error) {
console.error('Failed to reorder:', error);
alert('順序の変更に失敗しました');
}
};
// --- Mapping mode link management ---
const openLinkModal = async (fieldId: number, type: 'kyosai' | 'chusankan') => {
try {
if (type === 'kyosai') {
const res = await api.get('/kyosai-fields/');
setAllKyosai(res.data);
} else {
const res = await api.get('/chusankan-fields/');
setAllChusankan(res.data);
}
setModalFieldId(fieldId);
setModalType(type);
} catch (err) {
console.error('Failed to fetch master data:', err);
}
};
const addLinks = async (ids: number[]) => {
if (!modalFieldId || !modalType) return;
try {
if (modalType === 'kyosai') {
await api.post(`/fields/${modalFieldId}/kyosai-links/`, { kyosai_field_ids: ids });
} else {
await api.post(`/fields/${modalFieldId}/chusankan-links/`, { chusankan_field_ids: ids });
}
await fetchFields();
} catch (err) {
console.error('Failed to add links:', err);
}
};
const removeKyosaiLink = async (fieldId: number, kyosaiId: number) => {
if (!confirm('この共済区画の紐づけを解除しますか?')) return;
try {
await api.delete(`/fields/${fieldId}/kyosai-links/${kyosaiId}/`);
await fetchFields();
} catch (err) {
console.error('Failed to remove kyosai link:', err);
}
};
const removeChusankanLink = async (fieldId: number, chusankanId: number) => {
if (!confirm('この中山間区画の紐づけを解除しますか?')) return;
try {
await api.delete(`/fields/${fieldId}/chusankan-links/${chusankanId}/`);
await fetchFields();
} catch (err) {
console.error('Failed to remove chusankan link:', err);
}
};
const currentFieldForModal = modalFieldId ? fields.find((f) => f.id === modalFieldId) : null;
const kyosaiLinkedIds = useMemo(() => {
if (!currentFieldForModal) return new Set<number>();
return new Set(currentFieldForModal.kyosai_fields.map((k) => k.id));
}, [currentFieldForModal]);
const chusankanLinkedIds = useMemo(() => {
if (!currentFieldForModal) return new Set<number>();
return new Set(currentFieldForModal.chusankan_fields.map((c) => c.id));
}, [currentFieldForModal]);
const isDisplayOrderMode = sortOrder === 'display_order,group_name,id';
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={`mx-auto px-4 sm:px-6 lg:px-8 py-8 ${viewMode === 'mapping' ? 'max-w-full' : 'max-w-7xl'}`}>
<div className="mb-6 flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">:</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-green-500"
>
<option value="group_name,display_order,id"></option>
<option value="display_order,group_name,id"></option>
<option value="id"></option>
</select>
</div>
{/* View mode toggle */}
<div className="flex rounded-md border border-gray-300 overflow-hidden">
<button
onClick={() => setViewMode('normal')}
className={`px-3 py-2 text-sm ${
viewMode === 'normal'
? 'bg-green-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
</button>
<button
onClick={() => setViewMode('mapping')}
className={`px-3 py-2 text-sm border-l border-gray-300 ${
viewMode === 'mapping'
? 'bg-green-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
</button>
</div>
<button
onClick={() => router.push('/fields/new')}
className="flex items-center px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
</button>
</div>
</div>
{fields.length === 0 ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500"></p>
</div>
) : viewMode === 'mapping' ? (
/* ===== 対応表モード ===== */
<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>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
</th>
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap 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-4 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">
{fields.map((field) => (
<tr key={field.id} className="hover:bg-gray-50 align-top">
<td className="px-4 py-3 whitespace-nowrap">
<button
onClick={() => router.push(`/fields/${field.id}`)}
className="text-sm font-medium text-blue-600 hover:text-blue-800"
title="詳細を開く"
>
{field.name}
</button>
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{field.address || '-'}
</td>
<td className="px-3 py-3 text-right text-sm text-gray-500 whitespace-nowrap">
{field.area_tan || '-'}
</td>
{/* 共済セル */}
<td className="px-4 py-3 text-sm">
{field.kyosai_fields.length > 0 ? (
<div className="space-y-1">
{field.kyosai_fields.map((k) => (
<div key={k.id} className="flex items-center gap-1 group">
<span className="text-gray-700">
<span className="text-gray-400">{k.k_num}{k.s_num ? `-${k.s_num}` : ''}</span>
{' '}{k.kanji_name}
</span>
<button
onClick={() => removeKyosaiLink(field.id, k.id)}
className="text-red-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
title="紐づけ解除"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : (
<span className="text-gray-300">-</span>
)}
<button
onClick={() => openLinkModal(field.id, 'kyosai')}
className="mt-1 text-xs text-green-500 hover:text-green-700 flex items-center gap-0.5"
>
<Plus className="h-3 w-3" />
</button>
</td>
{/* 中山間セル */}
<td className="px-4 py-3 text-sm">
{field.chusankan_fields.length > 0 ? (
<div className="space-y-1">
{field.chusankan_fields.map((c) => (
<div key={c.id} className="flex items-center gap-1 group">
<span className="text-gray-700">
<span className="text-gray-400">ID{c.c_id}</span>
{' '}{c.oaza} {c.aza} {c.chiban}
</span>
<button
onClick={() => removeChusankanLink(field.id, c.id)}
className="text-red-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
title="紐づけ解除"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : (
<span className="text-gray-300">-</span>
)}
<button
onClick={() => openLinkModal(field.id, 'chusankan')}
className="mt-1 text-xs text-green-500 hover:text-green-700 flex items-center gap-0.5"
>
<Plus className="h-3 w-3" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
/* ===== 通常モード ===== */
<div className="bg-white rounded-lg shadow overflow-hidden">
<datalist id="groups">
{uniqueGroups.map((group) => (
<option key={group} value={group} />
))}
</datalist>
<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 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
</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">
(m2)
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{fields.map((field, index) => (
<tr key={field.id} className="hover:bg-gray-50">
<td className="px-2 py-4 whitespace-nowrap text-center">
<div className="flex items-center justify-center space-x-1">
<button
onClick={() => handleMoveOrder(index, 'up')}
disabled={!isDisplayOrderMode || index === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
title={!isDisplayOrderMode ? "表示順優先モードで操作してください" : "上へ移動"}
>
<ArrowUp className="h-4 w-4" />
</button>
<button
onClick={() => handleMoveOrder(index, 'down')}
disabled={!isDisplayOrderMode || index === fields.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
title={!isDisplayOrderMode ? "表示順優先モードで操作してください" : "下へ移動"}
>
<ArrowDown className="h-4 w-4" />
</button>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{field.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
list="groups"
defaultValue={field.group_name || ''}
onBlur={(e) => handleGroupChange(field.id, e.target.value)}
placeholder="グループ名"
className="w-32 text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-green-500"
/>
</td>
<td className="px-6 py-4">
<div className="text-sm 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 text-sm text-gray-500">
{field.area_m2 || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{field.owner_name || '-'}
</td>
<td className="px-3 py-4 whitespace-nowrap text-center text-sm text-gray-400">
{field.kyosai_fields?.length > 0 ? `${field.kyosai_fields.length}` : '-'}
</td>
<td className="px-3 py-4 whitespace-nowrap text-center text-sm text-gray-400">
{field.chusankan_fields?.length > 0 ? `${field.chusankan_fields.length}` : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<button
onClick={() => router.push(`/fields/${field.id}`)}
className="text-blue-600 hover:text-blue-900 p-1"
title="編集"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(field.id)}
disabled={deleting === field.id}
className="text-red-600 hover:text-red-900 p-1 disabled:opacity-50"
title="削除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Link Modal */}
{modalType === 'kyosai' && modalFieldId && (
<LinkModal
title={`共済区画を追加 — ${currentFieldForModal?.name}`}
items={allKyosai}
alreadyLinkedIds={kyosaiLinkedIds}
renderItem={(k: OfficialKyosaiField) => (
<div>
<span className="font-medium">{k.k_num}{k.s_num ? `-${k.s_num}` : ''}</span>
{' '}{k.kanji_name}
<span className="text-gray-400 ml-2">{k.area.toLocaleString()}m2</span>
{k.linked_field_names && k.linked_field_names.length > 0 && (
<span className="text-gray-400 ml-2 text-xs">
({k.linked_field_names.join(', ')})
</span>
)}
</div>
)}
searchFilter={(k: OfficialKyosaiField, q: string) =>
k.kanji_name.toLowerCase().includes(q) ||
k.address.toLowerCase().includes(q) ||
k.k_num.includes(q)
}
onAdd={addLinks}
onClose={() => { setModalFieldId(null); setModalType(null); }}
/>
)}
{modalType === 'chusankan' && modalFieldId && (
<LinkModal
title={`中山間区画を追加 — ${currentFieldForModal?.name}`}
items={allChusankan}
alreadyLinkedIds={chusankanLinkedIds}
renderItem={(c: OfficialChusankanField) => (
<div>
<span className="font-medium">ID{c.c_id}</span>
{' '}{c.oaza} {c.aza} {c.chiban}
<span className="text-gray-400 ml-2">{c.area.toLocaleString()}m2</span>
{c.linked_field_names && c.linked_field_names.length > 0 && (
<span className="text-gray-400 ml-2 text-xs">
({c.linked_field_names.join(', ')})
</span>
)}
</div>
)}
searchFilter={(c: OfficialChusankanField, q: string) =>
c.c_id.includes(q) ||
c.oaza.toLowerCase().includes(q) ||
c.aza.toLowerCase().includes(q) ||
c.chiban.includes(q)
}
onAdd={addLinks}
onClose={() => { setModalFieldId(null); setModalType(null); }}
/>
)}
</div>
);
}