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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<code className="font-mono font-bold">src/app/page.tsx</code>
|
||||
</p>
|
||||
</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>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">読み込み中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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