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; +}