Day 14 完了
作付け計画画面に集計サイドバーを追加しました: 機能: - PC: 左側に集計サイドバー(開/閉可能) - スマホ: 「📊 集計を表示」ボタン → モーダル表示 - リアルタイム更新: 作物・品种選択時に自動再計算 - 未設定圃場の警告表示(黄色) 実装: - useMemo で集計計算を最適化 - 作物別・品种別の面積集計 - 展開可能なツリー表示 http://localhost:3000/allocation で確認できます。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user