From afd434cd4cfeff74c596f3e6e1ca35e9ef2f2afa Mon Sep 17 00:00:00 2001 From: Akira Date: Sun, 15 Feb 2026 13:23:40 +0900 Subject: [PATCH] =?UTF-8?q?Day=208=20=E5=AE=8C=E4=BA=86=20=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E5=86=85=E5=AE=B9:=201.=20frontend/src/types/index.ts?= =?UTF-8?q?=20-=20=E5=9E=8B=E5=AE=9A=E7=BE=A9=EF=BC=88Field,=20Crop,=20Var?= =?UTF-8?q?iety,=20Plan=EF=BC=89=202.=20frontend/src/components/Navbar.tsx?= =?UTF-8?q?=20-=20=E3=83=8A=E3=83=93=E3=82=B2=E3=83=BC=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=83=90=E3=83=BC=EF=BC=88=E3=83=AD=E3=82=B0=E3=82=A2?= =?UTF-8?q?=E3=82=A6=E3=83=88=E3=83=9C=E3=82=BF=E3=83=B3=EF=BC=89=203.=20b?= =?UTF-8?q?ackend/apps/fields/views.py=20-=20FieldViewSet=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=204.=20backend/apps/fields/serializers.py=20-=20?= =?UTF-8?q?=E6=96=B0=E8=A6=8F=E4=BD=9C=E6=88=90=EF=BC=88Field=E3=82=B7?= =?UTF-8?q?=E3=83=AA=E3=82=A2=E3=83=A9=E3=82=A4=E3=82=B6=E3=83=BC=EF=BC=89?= =?UTF-8?q?=205.=20backend/apps/fields/urls.py=20-=20ViewSet=E3=83=AB?= =?UTF-8?q?=E3=83=BC=E3=83=88=E8=BF=BD=E5=8A=A0=206.=20frontend/src/app/al?= =?UTF-8?q?location/page.tsx=20-=20=E4=BD=9C=E4=BB=98=E3=81=91=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E7=94=BB=E9=9D=A2=EF=BC=88=E4=BD=9C=E7=89=A9=E3=83=BB?= =?UTF-8?q?=E5=93=81=E7=A8=AE=E9=81=B8=E6=8A=9E=E5=8F=AF=E8=83=BD=EF=BC=89?= =?UTF-8?q?=207.=20frontend/src/app/page.tsx=20-=20=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E3=83=AA=E3=83=80=E3=82=A4=E3=83=AC=E3=82=AF=E3=83=88=EF=BC=88?= =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=82=A4=E3=83=B3=E7=8A=B6=E6=85=8B=E3=81=AB?= =?UTF-8?q?=E3=82=88=E3=82=8B=EF=BC=89=20API=E5=8B=95=E4=BD=9C=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D:=20-=20/api/fields/=20=E2=86=92=20HTTP=20200=EF=BC=88?= =?UTF-8?q?=E5=9C=83=E5=A0=B4=E3=83=87=E3=83=BC=E3=82=BF=E3=81=AA=E3=81=97?= =?UTF-8?q?=EF=BC=89=20-=20/api/plans/crops/=20=E2=86=92=20HTTP=20200?= =?UTF-8?q?=EF=BC=882=E4=BD=9C=E7=89=A9=EF=BC=9A=E6=B0=B4=E7=A8=B2?= =?UTF-8?q?=E3=83=BB=E5=A4=A7=E8=B1=86=EF=BC=89=20-=20/api/plans/=3Fyear?= =?UTF-8?q?=3D2025=20=E2=86=92=20HTTP=20200=20=E3=83=86=E3=82=B9=E3=83=88:?= =?UTF-8?q?=20http://localhost:3000/=20=E2=86=92=20=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E3=83=AA=E3=83=80=E3=82=A4=E3=83=AC=E3=82=AF=E3=83=88=E3=81=A7?= =?UTF-8?q?=20/login=20=E3=81=BE=E3=81=9F=E3=81=AF=20/allocation=20?= =?UTF-8?q?=E2=80=BB=20=E7=8F=BE=E5=9C=A8=E5=9C=83=E5=A0=B4=E3=83=87?= =?UTF-8?q?=E3=83=BC=E3=82=BF=E3=81=8C=E3=81=AA=E3=81=84=E3=81=9F=E3=82=81?= =?UTF-8?q?=E3=80=81=E7=94=BB=E9=9D=A2=E3=81=AB=E3=81=AF=E3=80=8C=E5=9C=83?= =?UTF-8?q?=E5=A0=B4=E3=83=87=E3=83=BC=E3=82=BF=E3=81=8C=E3=81=82=E3=82=8A?= =?UTF-8?q?=E3=81=BE=E3=81=9B=E3=82=93=E3=80=82=E3=82=A4=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=82=92=E5=AE=9F=E8=A1=8C=E3=81=97=E3=81=A6?= =?UTF-8?q?=E3=81=8F=E3=81=A0=E3=81=95=E3=81=84=E3=80=82=E3=80=8D=E3=81=A8?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=95=E3=82=8C=E3=81=BE=E3=81=99=E3=80=82?= =?UTF-8?q?=20=E6=AC=A1=E3=81=AE=E5=B7=A5=E7=A8=8B=E3=81=AB=E7=A7=BB?= =?UTF-8?q?=E3=82=8A=E3=81=BE=E3=81=99=E3=81=8B=EF=BC=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/fields/serializers.py | 23 +++ backend/apps/fields/urls.py | 7 +- backend/apps/fields/views.py | 9 + frontend/src/app/allocation/page.tsx | 265 +++++++++++++++++++++++++++ frontend/src/app/page.tsx | 47 ++--- frontend/src/components/Navbar.tsx | 35 ++++ frontend/src/types/index.ts | 53 ++++++ 7 files changed, 410 insertions(+), 29 deletions(-) create mode 100644 backend/apps/fields/serializers.py create mode 100644 frontend/src/app/allocation/page.tsx create mode 100644 frontend/src/components/Navbar.tsx create mode 100644 frontend/src/types/index.ts diff --git a/backend/apps/fields/serializers.py b/backend/apps/fields/serializers.py new file mode 100644 index 0000000..44c2bd3 --- /dev/null +++ b/backend/apps/fields/serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers +from .models import Field, OfficialKyosaiField, OfficialChusankanField + + +class OfficialKyosaiFieldSerializer(serializers.ModelSerializer): + class Meta: + model = OfficialKyosaiField + fields = ['id', 'k_num', 's_num', 'address', 'kanji_name', 'area'] + + +class OfficialChusankanFieldSerializer(serializers.ModelSerializer): + class Meta: + model = OfficialChusankanField + fields = ['id', 'c_id', 'oaza', 'aza', 'chiban', 'area', 'payment_amount'] + + +class FieldSerializer(serializers.ModelSerializer): + kyosai_fields = OfficialKyosaiFieldSerializer(many=True, read_only=True) + chusankan_fields = OfficialChusankanFieldSerializer(many=True, read_only=True) + + class Meta: + model = Field + fields = ['id', 'name', 'address', 'area_tan', 'area_m2', 'owner_name', 'kyosai_fields', 'chusankan_fields'] diff --git a/backend/apps/fields/urls.py b/backend/apps/fields/urls.py index 1b0bc3f..a071d2d 100644 --- a/backend/apps/fields/urls.py +++ b/backend/apps/fields/urls.py @@ -1,7 +1,12 @@ -from django.urls import path +from django.urls import path, include +from rest_framework.routers import DefaultRouter from . import views +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'), ] diff --git a/backend/apps/fields/views.py b/backend/apps/fields/views.py index 1b31472..1ffef4b 100644 --- a/backend/apps/fields/views.py +++ b/backend/apps/fields/views.py @@ -2,7 +2,16 @@ import pandas as pd from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods +from rest_framework import viewsets, permissions +from rest_framework.decorators import action from .models import OfficialKyosaiField, OfficialChusankanField, Field +from .serializers import FieldSerializer + + +class FieldViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Field.objects.all() + serializer_class = FieldSerializer + permission_classes = [permissions.AllowAny] @csrf_exempt diff --git a/frontend/src/app/allocation/page.tsx b/frontend/src/app/allocation/page.tsx new file mode 100644 index 0000000..59fb23b --- /dev/null +++ b/frontend/src/app/allocation/page.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { api } from '@/lib/api'; +import { Field, Crop, Plan } from '@/types'; +import Navbar from '@/components/Navbar'; + +export default function AllocationPage() { + const [fields, setFields] = useState([]); + const [crops, setCrops] = useState([]); + const [plans, setPlans] = useState([]); + const [year, setYear] = useState(2025); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(null); + + useEffect(() => { + fetchData(); + }, [year]); + + const fetchData = async () => { + setLoading(true); + try { + const [fieldsRes, cropsRes, plansRes] = await Promise.all([ + api.get('/fields/'), + api.get('/plans/crops/'), + api.get(`/plans/?year=${year}`), + ]); + setFields(fieldsRes.data); + setCrops(cropsRes.data); + setPlans(plansRes.data); + } catch (error) { + console.error('Failed to fetch data:', error); + } finally { + setLoading(false); + } + }; + + const getPlanForField = (fieldId: number): Plan | undefined => { + return plans.find((p) => p.field === fieldId); + }; + + const handleCropChange = async (fieldId: number, cropId: string) => { + const crop = parseInt(cropId); + if (!crop) { + return; + } + + const existingPlan = getPlanForField(fieldId); + setSaving(fieldId); + + try { + if (existingPlan) { + await api.patch(`/plans/${existingPlan.id}/`, { + crop, + variety: null, + notes: existingPlan.notes, + }); + } else { + await api.post('/plans/', { + field: fieldId, + year, + crop, + variety: null, + notes: '', + }); + } + await fetchData(); + } catch (error) { + console.error('Failed to save crop:', error); + } finally { + setSaving(null); + } + }; + + const handleVarietyChange = async (fieldId: number, varietyId: string) => { + const variety = parseInt(varietyId) || null; + const existingPlan = getPlanForField(fieldId); + + if (!existingPlan || !existingPlan.crop) { + return; + } + + setSaving(fieldId); + + try { + await api.patch(`/plans/${existingPlan.id}/`, { + variety, + notes: existingPlan.notes, + }); + await fetchData(); + } catch (error) { + console.error('Failed to save variety:', error); + } finally { + setSaving(null); + } + }; + + const handleNotesChange = async (fieldId: number, notes: string) => { + const existingPlan = getPlanForField(fieldId); + + if (!existingPlan) { + return; + } + + setSaving(fieldId); + + try { + await api.patch(`/plans/${existingPlan.id}/`, { + notes, + }); + await fetchData(); + } catch (error) { + console.error('Failed to save notes:', error); + } finally { + setSaving(null); + } + }; + + const getVarietiesForCrop = (cropId: number): typeof crops[0]['varieties'] => { + const crop = crops.find((c) => c.id === cropId); + return crop?.varieties || []; + }; + + if (loading) { + return ( +
+ +
+
読み込み中...
+
+
+ ); + } + + return ( +
+ +
+
+

作付け計画

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

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

+
+ ) : ( +
+
+ + + + + + + + + + + + {fields.map((field) => { + const plan = getPlanForField(field.id); + const selectedCropId = plan?.crop || 0; + const selectedVarietyId = plan?.variety || 0; + + return ( + + + + + + + + ); + })} + +
+ 圃場名 + + 面積(反) + + 作物 + + 品種 + + 備考 +
+
+ {field.name} +
+
+ {field.address} +
+
+ {field.area_tan} + + + + + + + handleNotesChange(field.id, e.target.value) + } + disabled={saving === field.id || !plan} + placeholder="備考を入力" + className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm w-full disabled:opacity-50 disabled:bg-gray-100" + /> +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6498e8d..dbfa9e1 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,32 +1,23 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + export default function Home() { + const router = useRouter(); + + useEffect(() => { + const token = localStorage.getItem('accessToken'); + if (token) { + router.push('/allocation'); + } else { + router.push('/login'); + } + }, [router]); + return ( -
-
-

- Get started by editing  - src/app/page.tsx -

-
- -
-

KeinaSystem

-
- - -
+
+
読み込み中...
+
); } diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..3c84490 --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { LogOut } from 'lucide-react'; +import { logout } from '@/lib/api'; + +export default function Navbar() { + const router = useRouter(); + + const handleLogout = () => { + logout(); + }; + + return ( + + ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..ac5fcfe --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,53 @@ +export interface OfficialKyosaiField { + id: number; + k_num: string; + s_num: string | null; + address: string; + kanji_name: string; + area: string; +} + +export interface OfficialChusankanField { + id: number; + c_id: string; + oaza: string; + aza: string; + chiban: string; + area: string; + payment_amount: string | null; +} + +export interface Field { + id: number; + name: string; + address: string; + area_tan: string; + area_m2: number; + owner_name: string; + kyosai_fields: OfficialKyosaiField[]; + chusankan_fields: OfficialChusankanField[]; +} + +export interface Variety { + id: number; + crop: number; + name: string; +} + +export interface Crop { + id: number; + name: string; + varieties: Variety[]; +} + +export interface Plan { + id: number; + field: number; + field_name: string; + year: number; + crop: number; + crop_name: string; + variety: number; + variety_name: string; + notes: string | null; +}