diff --git a/.claude/settings.local.json b/.claude/settings.local.json index df4cb21..9948428 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,9 @@ "Bash(py:*)", "Bash(py -3:*)", "Bash(/c/Users/akira/Develop/keinasystem_t02/.venv/Scripts/python:*)", - "Bash(PYTHONIOENCODING=utf-8 /c/Users/akira/Develop/keinasystem_t02/.venv/Scripts/python:*)" + "Bash(PYTHONIOENCODING=utf-8 /c/Users/akira/Develop/keinasystem_t02/.venv/Scripts/python:*)", + "Bash(docker compose restart:*)", + "Bash(docker compose exec backend python manage.py shell:*)" ] } } diff --git a/backend/apps/fields/serializers.py b/backend/apps/fields/serializers.py index 3e21947..8644be7 100644 --- a/backend/apps/fields/serializers.py +++ b/backend/apps/fields/serializers.py @@ -3,18 +3,28 @@ from .models import Field, OfficialKyosaiField, OfficialChusankanField class OfficialKyosaiFieldSerializer(serializers.ModelSerializer): + linked_field_names = serializers.SerializerMethodField() + class Meta: model = OfficialKyosaiField - fields = ['id', 'k_num', 's_num', 'address', 'kanji_name', 'area'] + fields = ['id', 'k_num', 's_num', 'address', 'kanji_name', 'area', 'linked_field_names'] + + def get_linked_field_names(self, obj): + return list(obj.fields.values_list('name', flat=True)) class OfficialChusankanFieldSerializer(serializers.ModelSerializer): + linked_field_names = serializers.SerializerMethodField() + class Meta: model = OfficialChusankanField fields = ['id', 'c_id', 'chusankan_flag', 'oaza', 'aza', 'chiban', 'branch_num', 'land_type', 'area', 'planting_area', 'original_crop', 'manager', 'owner', 'slope', 'base_amount', 'steep_slope_addition', 'smart_agri_addition', - 'payment_amount'] + 'payment_amount', 'linked_field_names'] + + def get_linked_field_names(self, obj): + return list(obj.fields.values_list('name', flat=True)) class FieldSerializer(serializers.ModelSerializer): diff --git a/backend/apps/fields/urls.py b/backend/apps/fields/urls.py index 7f31e37..4a29aaa 100644 --- a/backend/apps/fields/urls.py +++ b/backend/apps/fields/urls.py @@ -6,8 +6,12 @@ router = DefaultRouter() router.register(r'', views.FieldViewSet, basename='field') urlpatterns = [ - path('', include(router.urls)), path('import/kyosai/', views.import_kyosai_master, name='import_kyosai'), path('import/yoshida/', views.import_yoshida_fields, name='import_yoshida'), path('import/chusankan/', views.import_chusankan_master, name='import_chusankan'), + path('/kyosai-links/', views.add_kyosai_links, name='add_kyosai_links'), + path('/kyosai-links//', views.remove_kyosai_link, name='remove_kyosai_link'), + path('/chusankan-links/', views.add_chusankan_links, name='add_chusankan_links'), + path('/chusankan-links//', views.remove_chusankan_link, name='remove_chusankan_link'), + path('', include(router.urls)), ] diff --git a/backend/apps/fields/views.py b/backend/apps/fields/views.py index 0ac1797..c0f5475 100644 --- a/backend/apps/fields/views.py +++ b/backend/apps/fields/views.py @@ -1,12 +1,14 @@ import pandas as pd from django.db import models as django_models from django.http import JsonResponse +from django.shortcuts import get_object_or_404 from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from rest_framework import viewsets, permissions, filters -from rest_framework.decorators import action +from rest_framework import viewsets, permissions, filters, status +from rest_framework.decorators import action, api_view, permission_classes as perm_classes +from rest_framework.response import Response from .models import OfficialKyosaiField, OfficialChusankanField, Field -from .serializers import FieldSerializer +from .serializers import FieldSerializer, OfficialKyosaiFieldSerializer, OfficialChusankanFieldSerializer class FieldViewSet(viewsets.ModelViewSet): @@ -21,6 +23,56 @@ class FieldViewSet(viewsets.ModelViewSet): ordering_fields = ['group_name', 'display_order', 'id', 'area_tan'] +class OfficialKyosaiFieldViewSet(viewsets.ReadOnlyModelViewSet): + queryset = OfficialKyosaiField.objects.all().order_by('k_num', 's_num') + serializer_class = OfficialKyosaiFieldSerializer + permission_classes = [permissions.IsAuthenticated] + + +class OfficialChusankanFieldViewSet(viewsets.ReadOnlyModelViewSet): + queryset = OfficialChusankanField.objects.all().order_by('c_id') + serializer_class = OfficialChusankanFieldSerializer + permission_classes = [permissions.IsAuthenticated] + + +@api_view(['POST']) +@perm_classes([permissions.IsAuthenticated]) +def add_kyosai_links(request, field_id): + field = get_object_or_404(Field, pk=field_id) + ids = request.data.get('kyosai_field_ids', []) + records = OfficialKyosaiField.objects.filter(id__in=ids) + field.kyosai_fields.add(*records) + return Response({'added': len(records)}) + + +@api_view(['DELETE']) +@perm_classes([permissions.IsAuthenticated]) +def remove_kyosai_link(request, field_id, kyosai_id): + field = get_object_or_404(Field, pk=field_id) + kyosai = get_object_or_404(OfficialKyosaiField, pk=kyosai_id) + field.kyosai_fields.remove(kyosai) + return Response(status=status.HTTP_204_NO_CONTENT) + + +@api_view(['POST']) +@perm_classes([permissions.IsAuthenticated]) +def add_chusankan_links(request, field_id): + field = get_object_or_404(Field, pk=field_id) + ids = request.data.get('chusankan_field_ids', []) + records = OfficialChusankanField.objects.filter(id__in=ids) + field.chusankan_fields.add(*records) + return Response({'added': len(records)}) + + +@api_view(['DELETE']) +@perm_classes([permissions.IsAuthenticated]) +def remove_chusankan_link(request, field_id, chusankan_id): + field = get_object_or_404(Field, pk=field_id) + chusankan = get_object_or_404(OfficialChusankanField, pk=chusankan_id) + field.chusankan_fields.remove(chusankan) + return Response(status=status.HTTP_204_NO_CONTENT) + + @csrf_exempt @require_http_methods(["POST"]) def import_kyosai_master(request): diff --git a/backend/keinasystem/urls.py b/backend/keinasystem/urls.py index 789c483..49c24c3 100644 --- a/backend/keinasystem/urls.py +++ b/backend/keinasystem/urls.py @@ -16,11 +16,18 @@ Including another URLconf """ from django.contrib import admin from django.urls import path, include +from rest_framework.routers import DefaultRouter from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from apps.fields.views import OfficialKyosaiFieldViewSet, OfficialChusankanFieldViewSet + +master_router = DefaultRouter() +master_router.register(r'kyosai-fields', OfficialKyosaiFieldViewSet, basename='kyosai-field') +master_router.register(r'chusankan-fields', OfficialChusankanFieldViewSet, basename='chusankan-field') urlpatterns = [ path('admin/', admin.site.urls), path('api/fields/', include('apps.fields.urls')), + path('api/', include(master_router.urls)), path('api/plans/', include('apps.plans.urls')), path('api/reports/', include('apps.reports.urls')), path('api/auth/jwt/create/', TokenObtainPairView.as_view(), name='token_obtain_pair'), diff --git a/frontend/src/app/fields/[id]/page.tsx b/frontend/src/app/fields/[id]/page.tsx index 3d85788..b85a586 100644 --- a/frontend/src/app/fields/[id]/page.tsx +++ b/frontend/src/app/fields/[id]/page.tsx @@ -1,22 +1,127 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { api } from '@/lib/api'; import { Field, OfficialKyosaiField, OfficialChusankanField } from '@/types'; import Navbar from '@/components/Navbar'; -import { ArrowLeft, Save } from 'lucide-react'; +import { ArrowLeft, Save, Plus, X, Search } from 'lucide-react'; +// --- Link Modal Component --- +function LinkModal({ + title, + items, + alreadyLinkedIds, + renderItem, + searchFilter, + onAdd, + onClose, +}: { + title: string; + items: T[]; + alreadyLinkedIds: Set; + renderItem: (item: T) => React.ReactNode; + searchFilter: (item: T, query: string) => boolean; + onAdd: (ids: number[]) => void; + onClose: () => void; +}) { + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState>(new Set()); + + const filtered = useMemo(() => { + if (!search.trim()) return items; + return items.filter((item) => searchFilter(item, search.trim().toLowerCase())); + }, [items, search, searchFilter]); + + const toggle = (id: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+
+
+

{title}

+ +
+ +
+
+ + setSearch(e.target.value)} + placeholder="検索..." + className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + autoFocus + /> +
+
+ +
+ {filtered.length === 0 ? ( +

該当する区画がありません

+ ) : ( + filtered.map((item) => { + const isLinked = alreadyLinkedIds.has(item.id); + const isSelected = selected.has(item.id); + return ( + + ); + }) + )} +
+ +
+ +
+
+
+ ); +} + +// --- Main Page --- export default function EditFieldPage() { const router = useRouter(); const params = useParams(); const fieldId = parseInt(params.id as string); - + const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [notFound, setNotFound] = useState(false); - + const [formData, setFormData] = useState({ name: '', address: '', @@ -28,6 +133,12 @@ export default function EditFieldPage() { const [kyosaiFields, setKyosaiFields] = useState([]); const [chusankanFields, setChusankanFields] = useState([]); + // Modal state + const [showKyosaiModal, setShowKyosaiModal] = useState(false); + const [showChusankanModal, setShowChusankanModal] = useState(false); + const [allKyosai, setAllKyosai] = useState([]); + const [allChusankan, setAllChusankan] = useState([]); + useEffect(() => { fetchField(); }, [fieldId]); @@ -87,6 +198,72 @@ export default function EditFieldPage() { } }; + // --- Kyosai link management --- + const openKyosaiModal = async () => { + try { + const res = await api.get('/kyosai-fields/'); + setAllKyosai(res.data); + setShowKyosaiModal(true); + } catch (err) { + console.error('Failed to fetch kyosai fields:', err); + } + }; + + const addKyosaiLinks = async (ids: number[]) => { + try { + await api.post(`/fields/${fieldId}/kyosai-links/`, { kyosai_field_ids: ids }); + await fetchField(); + } catch (err) { + console.error('Failed to add kyosai links:', err); + } + }; + + const removeKyosaiLink = async (kyosaiId: number) => { + if (!confirm('この共済区画の紐づけを解除しますか?')) return; + try { + await api.delete(`/fields/${fieldId}/kyosai-links/${kyosaiId}/`); + await fetchField(); + } catch (err) { + console.error('Failed to remove kyosai link:', err); + } + }; + + // --- Chusankan link management --- + const openChusankanModal = async () => { + try { + const res = await api.get('/chusankan-fields/'); + setAllChusankan(res.data); + setShowChusankanModal(true); + } catch (err) { + console.error('Failed to fetch chusankan fields:', err); + } + }; + + const addChusankanLinks = async (ids: number[]) => { + try { + await api.post(`/fields/${fieldId}/chusankan-links/`, { chusankan_field_ids: ids }); + await fetchField(); + } catch (err) { + console.error('Failed to add chusankan links:', err); + } + }; + + const removeChusankanLink = async (chusankanId: number) => { + if (!confirm('この中山間区画の紐づけを解除しますか?')) return; + try { + await api.delete(`/fields/${fieldId}/chusankan-links/${chusankanId}/`); + await fetchField(); + } catch (err) { + console.error('Failed to remove chusankan link:', err); + } + }; + + // --- Computed --- + const kyosaiLinkedIds = useMemo(() => new Set(kyosaiFields.map((k) => k.id)), [kyosaiFields]); + const chusankanLinkedIds = useMemo(() => new Set(chusankanFields.map((c) => c.id)), [chusankanFields]); + const kyosaiTotalArea = kyosaiFields.reduce((sum, k) => sum + k.area, 0); + const chusankanTotalArea = chusankanFields.reduce((sum, c) => sum + c.area, 0); + if (loading) { return (
@@ -120,7 +297,7 @@ export default function EditFieldPage() { return (
-
+
+ {/* 基本情報フォーム */}

圃場編集

@@ -141,38 +319,37 @@ export default function EditFieldPage() { )}
-
- - +
+
+ + +
+
+ + +
-
- - -
- -
+
-
+
+ + +
+
+ +
-
- - -
- -
- - -
- -
+
+
{kyosaiFields.length === 0 ? (

紐づけられた共済区画はありません

) : ( @@ -271,6 +455,7 @@ export default function EditFieldPage() { 漢字地名 住所 面積(m2) + @@ -280,6 +465,15 @@ export default function EditFieldPage() { {k.kanji_name} {k.address} {k.area.toLocaleString()} + + + ))} @@ -290,7 +484,21 @@ export default function EditFieldPage() { {/* 中山間情報 */}
-

中山間情報

+
+

+ 中山間情報 + + ({chusankanFields.length}件 / 計{chusankanTotalArea.toLocaleString()}m2) + +

+ +
{chusankanFields.length === 0 ? (

紐づけられた中山間区画はありません

) : ( @@ -302,6 +510,7 @@ export default function EditFieldPage() { 所在地 面積(m2) 支払金額 + @@ -311,6 +520,15 @@ export default function EditFieldPage() { {c.oaza} {c.aza} {c.chiban} {c.area.toLocaleString()} {c.payment_amount != null ? `¥${c.payment_amount.toLocaleString()}` : '-'} + + + ))} @@ -319,6 +537,63 @@ export default function EditFieldPage() { )}
+ + {/* Kyosai Link Modal */} + {showKyosaiModal && ( + ( +
+ {k.k_num}{k.s_num ? `-${k.s_num}` : ''} + {' '}{k.kanji_name} + {k.area.toLocaleString()}m2 + {k.linked_field_names && k.linked_field_names.length > 0 && ( + + ({k.linked_field_names.join(', ')}) + + )} +
+ )} + searchFilter={(k, q) => + k.kanji_name.toLowerCase().includes(q) || + k.address.toLowerCase().includes(q) || + k.k_num.includes(q) + } + onAdd={addKyosaiLinks} + onClose={() => setShowKyosaiModal(false)} + /> + )} + + {/* Chusankan Link Modal */} + {showChusankanModal && ( + ( +
+ ID{c.c_id} + {' '}{c.oaza} {c.aza} {c.chiban} + {c.area.toLocaleString()}m2 + {c.linked_field_names && c.linked_field_names.length > 0 && ( + + ({c.linked_field_names.join(', ')}) + + )} +
+ )} + searchFilter={(c, q) => + c.c_id.includes(q) || + c.oaza.toLowerCase().includes(q) || + c.aza.toLowerCase().includes(q) || + c.chiban.includes(q) + } + onAdd={addChusankanLinks} + onClose={() => setShowChusankanModal(false)} + /> + )}
); } diff --git a/frontend/src/app/fields/page.tsx b/frontend/src/app/fields/page.tsx index 6578341..da64fb3 100644 --- a/frontend/src/app/fields/page.tsx +++ b/frontend/src/app/fields/page.tsx @@ -172,6 +172,12 @@ export default function FieldsPage() { 所有者 + + 共済 + + + 中山間 + 操作 @@ -228,6 +234,12 @@ export default function FieldsPage() { {field.owner_name || '-'} + + {field.kyosai_fields?.length > 0 ? `${field.kyosai_fields.length}件` : '-'} + + + {field.chusankan_fields?.length > 0 ? `${field.chusankan_fields.length}件` : '-'} +