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:
Akira
2026-02-15 13:23:40 +09:00
parent d7ab48772e
commit afd434cd4c
7 changed files with 410 additions and 29 deletions

View 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']

View File

@@ -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'),
]

View File

@@ -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

View 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>
);
}

View File

@@ -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 (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
<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&nbsp;
<code className="font-mono font-bold">src/app/page.tsx</code>
</p>
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">...</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">-&gt;</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>
);
}

View 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>
);
}

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