diff --git a/frontend/src/app/allocation/page.tsx b/frontend/src/app/allocation/page.tsx index e944f97..91c4c3a 100644 --- a/frontend/src/app/allocation/page.tsx +++ b/frontend/src/app/allocation/page.tsx @@ -1,9 +1,21 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { api } from '@/lib/api'; import { Field, Crop, Plan } from '@/types'; import Navbar from '@/components/Navbar'; +import { Menu, X, BarChart3, ChevronRight, ChevronDown } from 'lucide-react'; + +interface SummaryItem { + cropId: number; + cropName: string; + areaTan: number; + varieties: { + varietyId: number | null; + varietyName: string; + areaTan: number; + }[]; +} export default function AllocationPage() { const [fields, setFields] = useState([]); @@ -12,6 +24,9 @@ export default function AllocationPage() { const [year, setYear] = useState(2025); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); + const [showSidebar, setShowSidebar] = useState(true); + const [showMobileSummary, setShowMobileSummary] = useState(false); + const [expandedCrops, setExpandedCrops] = useState>(new Set()); useEffect(() => { fetchData(); @@ -35,15 +50,60 @@ export default function AllocationPage() { } }; + const summary = useMemo(() => { + const cropSummary = new Map(); + let totalAreaTan = 0; + let unassignedAreaTan = 0; + + fields.forEach((field) => { + const area = parseFloat(field.area_tan) || 0; + totalAreaTan += area; + + const plan = plans.find((p) => p.field === field.id); + + if (!plan?.crop) { + unassignedAreaTan += area; + return; + } + + if (!cropSummary.has(plan.crop)) { + const crop = crops.find((c) => c.id === plan.crop); + cropSummary.set(plan.crop, { + cropId: plan.crop, + cropName: crop?.name || '不明', + areaTan: 0, + varieties: [], + }); + } + + const item = cropSummary.get(plan.crop)!; + item.areaTan += area; + + const varietyId = plan.variety; + const varietyName = plan.variety_name || '(品種未選択)'; + + let varietyItem = item.varieties.find((v) => v.varietyId === varietyId); + if (!varietyItem) { + varietyItem = { varietyId, varietyName, areaTan: 0 }; + item.varieties.push(varietyItem); + } + varietyItem.areaTan += area; + }); + + return { + totalAreaTan, + unassignedAreaTan, + items: Array.from(cropSummary.values()), + }; + }, [fields, plans, crops]); + 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; - } + if (!crop) return; const existingPlan = getPlanForField(fieldId); setSaving(fieldId); @@ -76,9 +136,7 @@ export default function AllocationPage() { const variety = parseInt(varietyId) || null; const existingPlan = getPlanForField(fieldId); - if (!existingPlan || !existingPlan.crop) { - return; - } + if (!existingPlan || !existingPlan.crop) return; setSaving(fieldId); @@ -97,17 +155,12 @@ export default function AllocationPage() { const handleNotesChange = async (fieldId: number, notes: string) => { const existingPlan = getPlanForField(fieldId); - - if (!existingPlan) { - return; - } + if (!existingPlan) return; setSaving(fieldId); try { - await api.patch(`/plans/${existingPlan.id}/`, { - notes, - }); + await api.patch(`/plans/${existingPlan.id}/`, { notes }); await fetchData(true); } catch (error) { console.error('Failed to save notes:', error); @@ -116,11 +169,99 @@ export default function AllocationPage() { } }; - const getVarietiesForCrop = (cropId: number): typeof crops[0]['varieties'] => { + const getVarietiesForCrop = (cropId: number) => { const crop = crops.find((c) => c.id === cropId); return crop?.varieties || []; }; + const toggleCropExpand = (cropId: number) => { + setExpandedCrops((prev) => { + const next = new Set(prev); + if (next.has(cropId)) { + next.delete(cropId); + } else { + next.add(cropId); + } + return next; + }); + }; + + const renderSidebar = () => ( + <> +
+ {showSidebar &&

集計

} + +
+ + {showSidebar && ( +
+
+
合計面積
+
+ {summary.totalAreaTan.toFixed(2)} 反 +
+
+ ({Math.round(summary.totalAreaTan * 1000)} m²) +
+
+ + {summary.unassignedAreaTan > 0 && ( +
+
未設定
+
+ {summary.unassignedAreaTan.toFixed(2)} 反 +
+
+ )} + +
+ {summary.items.map((item) => ( +
+ + + {expandedCrops.has(item.cropId) && ( +
+ {item.varieties.map((v, idx) => ( +
+ {v.varietyName} + + {v.areaTan.toFixed(2)} 反 + +
+ ))} +
+ )} +
+ ))} +
+
+ )} + + ); + if (loading) { return (
@@ -135,131 +276,240 @@ export default function AllocationPage() { return (
-
-
-

作付け計画

-
- - -
+ +
+ {/* PC用サイドバー */} +
+ {renderSidebar()}
- {fields.length === 0 ? ( -
-

- 圃場データがありません。インポートを実行してください。 -

-
- ) : ( -
-
- - - - - - - - - - - - {fields.map((field) => { - const plan = getPlanForField(field.id); - const selectedCropId = plan?.crop || 0; - const selectedVarietyId = plan?.variety || 0; + {/* メインコンテンツ */} +
+
+
+

作付け計画

- return ( -
- - - - - + {/* スマホ用集計ボタン */} + + +
+ + +
+ + + {fields.length === 0 ? ( +
+

+ 圃場データがありません。インポートを実行してください。 +

+
+ ) : ( +
+
+
- 圃場名 - - 面積(反) - - 作物 - - 品種 - - 備考 -
-
- {field.name} -
-
- {field.address} -
-
- {field.area_tan} - - - - - - - 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" - /> -
+ + + + + + + - ); - })} - -
+ 圃場名 + + 面積(反) + + 作物 + + 品種 + + 備考 +
+ + + {fields.map((field) => { + const plan = getPlanForField(field.id); + const selectedCropId = plan?.crop || 0; + const selectedVarietyId = plan?.variety || 0; + + return ( + + +
+ {field.name} +
+
+ {field.address} +
+ + + {field.area_tan} + + + + + + + + + + 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" + /> + + + ); + })} + + +
+
+ )} +
+
+
+ + {/* スマホ用モーダル */} + {showMobileSummary && ( +
+
setShowMobileSummary(false)} + /> +
+
+
+

集計

+ +
+
+
+
合計面積
+
+ {summary.totalAreaTan.toFixed(2)} 反 +
+
+ ({Math.round(summary.totalAreaTan * 1000)} m²) +
+
+ + {summary.unassignedAreaTan > 0 && ( +
+
未設定
+
+ {summary.unassignedAreaTan.toFixed(2)} 反 +
+
+ )} + +
+ {summary.items.map((item) => ( +
+ + + {expandedCrops.has(item.cropId) && ( +
+ {item.varieties.map((v, idx) => ( +
+ {v.varietyName} + + {v.areaTan.toFixed(2)} 反 + +
+ ))} +
+ )} +
+ ))} +
+
- )} -
+
+ )}
); }