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