Day 14 完了

作付け計画画面に集計サイドバーを追加しました:
機能:
- PC: 左側に集計サイドバー(開/閉可能)
- スマホ: 「📊 集計を表示」ボタン → モーダル表示
- リアルタイム更新: 作物・品种選択時に自動再計算
- 未設定圃場の警告表示(黄色)
実装:
- useMemo で集計計算を最適化
- 作物別・品种別の面積集計
- 展開可能なツリー表示
http://localhost:3000/allocation で確認できます。
This commit is contained in:
Akira
2026-02-15 15:26:03 +09:00
parent 15a94867fa
commit f4165e2c68

View File

@@ -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<Field[]>([]);
@@ -12,6 +24,9 @@ export default function AllocationPage() {
const [year, setYear] = useState<number>(2025);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<number | null>(null);
const [showSidebar, setShowSidebar] = useState(true);
const [showMobileSummary, setShowMobileSummary] = useState(false);
const [expandedCrops, setExpandedCrops] = useState<Set<number>>(new Set());
useEffect(() => {
fetchData();
@@ -35,15 +50,60 @@ export default function AllocationPage() {
}
};
const summary = useMemo(() => {
const cropSummary = new Map<number, SummaryItem>();
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 = () => (
<>
<div className="p-4 border-b flex justify-between items-center">
{showSidebar && <h2 className="font-bold text-gray-900"></h2>}
<button
onClick={() => setShowSidebar(!showSidebar)}
className="p-1 hover:bg-gray-100 rounded"
>
{showSidebar ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{showSidebar && (
<div className="p-4">
<div className="mb-4">
<div className="text-sm text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900">
{summary.totalAreaTan.toFixed(2)}
</div>
<div className="text-xs text-gray-500">
({Math.round(summary.totalAreaTan * 1000)} m²)
</div>
</div>
{summary.unassignedAreaTan > 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div className="text-sm text-yellow-800"></div>
<div className="font-bold text-yellow-900">
{summary.unassignedAreaTan.toFixed(2)}
</div>
</div>
)}
<div className="space-y-2">
{summary.items.map((item) => (
<div key={item.cropId} className="border rounded-md overflow-hidden">
<button
onClick={() => toggleCropExpand(item.cropId)}
className="w-full p-3 flex items-center justify-between bg-gray-50 hover:bg-gray-100"
>
<div className="text-left">
<div className="font-medium text-gray-900">{item.cropName}</div>
<div className="text-sm text-gray-500">
{item.areaTan.toFixed(2)}
</div>
</div>
{expandedCrops.has(item.cropId) ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
{expandedCrops.has(item.cropId) && (
<div className="border-t bg-white">
{item.varieties.map((v, idx) => (
<div
key={idx}
className="px-3 py-2 flex justify-between text-sm"
>
<span className="text-gray-600">{v.varietyName}</span>
<span className="text-gray-900 font-medium">
{v.areaTan.toFixed(2)}
</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</>
);
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
@@ -135,9 +276,32 @@ export default function AllocationPage() {
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">
<div className="flex flex-col lg:flex-row">
{/* PC用サイドバー */}
<div
className={`hidden lg:block transition-all duration-300 ${
showSidebar ? 'w-64' : 'w-12'
} bg-white shadow rounded-lg m-4 overflow-hidden flex-shrink-0`}
>
{renderSidebar()}
</div>
{/* メインコンテンツ */}
<div className="flex-1 min-w-0 p-4 lg:p-0">
<div className="max-w-7xl mx-auto">
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h1 className="text-2xl font-bold text-gray-900"></h1>
{/* スマホ用集計ボタン */}
<button
className="lg:hidden px-4 py-2 bg-blue-600 text-white rounded-md flex items-center gap-2"
onClick={() => setShowMobileSummary(true)}
>
<BarChart3 className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<label htmlFor="year" className="text-sm font-medium text-gray-700">
:
@@ -261,5 +425,91 @@ export default function AllocationPage() {
)}
</div>
</div>
</div>
{/* スマホ用モーダル */}
{showMobileSummary && (
<div className="fixed inset-0 z-50 lg:hidden">
<div
className="absolute inset-0 bg-black/50"
onClick={() => setShowMobileSummary(false)}
/>
<div className="absolute inset-y-0 right-0 w-full max-w-md bg-white shadow-xl">
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-bold text-lg"></h2>
<button
onClick={() => setShowMobileSummary(false)}
className="p-2 hover:bg-gray-100 rounded"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="mb-4">
<div className="text-sm text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900">
{summary.totalAreaTan.toFixed(2)}
</div>
<div className="text-xs text-gray-500">
({Math.round(summary.totalAreaTan * 1000)} m²)
</div>
</div>
{summary.unassignedAreaTan > 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div className="text-sm text-yellow-800"></div>
<div className="font-bold text-yellow-900">
{summary.unassignedAreaTan.toFixed(2)}
</div>
</div>
)}
<div className="space-y-2">
{summary.items.map((item) => (
<div key={item.cropId} className="border rounded-md overflow-hidden">
<button
onClick={() => toggleCropExpand(item.cropId)}
className="w-full p-3 flex items-center justify-between bg-gray-50 hover:bg-gray-100"
>
<div className="text-left">
<div className="font-medium text-gray-900">
{item.cropName}
</div>
<div className="text-sm text-gray-500">
{item.areaTan.toFixed(2)}
</div>
</div>
{expandedCrops.has(item.cropId) ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
{expandedCrops.has(item.cropId) && (
<div className="border-t bg-white">
{item.varieties.map((v, idx) => (
<div
key={idx}
className="px-3 py-2 flex justify-between text-sm"
>
<span className="text-gray-600">{v.varietyName}</span>
<span className="text-gray-900 font-medium">
{v.areaTan.toFixed(2)}
</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}