Day 8 完了
実装内容: 1. frontend/src/types/index.ts - 型定義(Field, Crop, Variety, Plan) 2. frontend/src/components/Navbar.tsx - ナビゲーションバー(ログアウトボタン) 3. backend/apps/fields/views.py - FieldViewSet追加 4. backend/apps/fields/serializers.py - 新規作成(Fieldシリアライザー) 5. backend/apps/fields/urls.py - ViewSetルート追加 6. frontend/src/app/allocation/page.tsx - 作付け計画画面(作物・品種選択可能) 7. frontend/src/app/page.tsx - 自動リダイレクト(ログイン状態による) API動作確認: - /api/fields/ → HTTP 200(圃場データなし) - /api/plans/crops/ → HTTP 200(2作物:水稲・大豆) - /api/plans/?year=2025 → HTTP 200 テスト: http://localhost:3000/ → 自動リダイレクトで /login または /allocation ※ 現在圃場データがないため、画面には「圃場データがありません。インポートを実行してください。」と表示されます。 次の工程に移りますか?
This commit is contained in:
23
backend/apps/fields/serializers.py
Normal file
23
backend/apps/fields/serializers.py
Normal file
@@ -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']
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
from django.urls import path
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'', views.FieldViewSet, basename='field')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
path('import/kyosai/', views.import_kyosai_master, name='import_kyosai'),
|
path('import/kyosai/', views.import_kyosai_master, name='import_kyosai'),
|
||||||
path('import/yoshida/', views.import_yoshida_fields, name='import_yoshida'),
|
path('import/yoshida/', views.import_yoshida_fields, name='import_yoshida'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import pandas as pd
|
|||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods
|
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 .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
|
@csrf_exempt
|
||||||
|
|||||||
265
frontend/src/app/allocation/page.tsx
Normal file
265
frontend/src/app/allocation/page.tsx
Normal file
@@ -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<Field[]>([]);
|
||||||
|
const [crops, setCrops] = useState<Crop[]>([]);
|
||||||
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
|
const [year, setYear] = useState<number>(2025);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState<number | null>(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 (
|
||||||
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">作付け計画</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="year" className="text-sm font-medium text-gray-700">
|
||||||
|
作付年度:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="year"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
<option value={2025}>2025年</option>
|
||||||
|
<option value={2026}>2026年</option>
|
||||||
|
<option value={2027}>2027年</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||||
|
<p className="text-gray-500">
|
||||||
|
圃場データがありません。インポートを実行してください。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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-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">
|
||||||
|
備考
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{fields.map((field) => {
|
||||||
|
const plan = getPlanForField(field.id);
|
||||||
|
const selectedCropId = plan?.crop || 0;
|
||||||
|
const selectedVarietyId = plan?.variety || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={field.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{field.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs 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">
|
||||||
|
<select
|
||||||
|
value={selectedCropId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCropChange(field.id, e.target.value)
|
||||||
|
}
|
||||||
|
disabled={saving === field.id}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">選択してください</option>
|
||||||
|
{crops.map((crop) => (
|
||||||
|
<option key={crop.id} value={crop.id}>
|
||||||
|
{crop.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<select
|
||||||
|
value={selectedVarietyId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleVarietyChange(field.id, e.target.value)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
saving === field.id || !selectedCropId
|
||||||
|
}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">選択してください</option>
|
||||||
|
{getVarietiesForCrop(selectedCropId).map((variety) => (
|
||||||
|
<option key={variety.id} value={variety.id}>
|
||||||
|
{variety.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={plan?.notes || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) {
|
||||||
|
router.push('/allocation');
|
||||||
|
} else {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
|
<div className="text-gray-500">読み込み中...</div>
|
||||||
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
|
|
||||||
Get started by editing
|
|
||||||
<code className="font-mono font-bold">src/app/page.tsx</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-purple-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px]">
|
|
||||||
<h1 className="text-6xl font-bold">KeinaSystem</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left">
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/docs"
|
|
||||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
|
||||||
Docs <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">-></span>
|
|
||||||
</h2>
|
|
||||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
|
||||||
Find in-depth information about Next.js features and API.
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
35
frontend/src/components/Navbar.tsx
Normal file
35
frontend/src/components/Navbar.tsx
Normal file
@@ -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 (
|
||||||
|
<nav className="bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between h-16">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="text-xl font-bold text-green-700">KeinaSystem</h1>
|
||||||
|
<span className="ml-2 text-sm text-gray-500">農業管理システム</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
ログアウト
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
frontend/src/types/index.ts
Normal file
53
frontend/src/types/index.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user