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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user