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'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Field, Crop, Plan } from '@/types'; import { Field, Crop, Plan } from '@/types';
import Navbar from '@/components/Navbar'; 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() { export default function AllocationPage() {
const [fields, setFields] = useState<Field[]>([]); const [fields, setFields] = useState<Field[]>([]);
@@ -12,6 +24,9 @@ export default function AllocationPage() {
const [year, setYear] = useState<number>(2025); const [year, setYear] = useState<number>(2025);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<number | null>(null); 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(() => { useEffect(() => {
fetchData(); 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 => { const getPlanForField = (fieldId: number): Plan | undefined => {
return plans.find((p) => p.field === fieldId); return plans.find((p) => p.field === fieldId);
}; };
const handleCropChange = async (fieldId: number, cropId: string) => { const handleCropChange = async (fieldId: number, cropId: string) => {
const crop = parseInt(cropId); const crop = parseInt(cropId);
if (!crop) { if (!crop) return;
return;
}
const existingPlan = getPlanForField(fieldId); const existingPlan = getPlanForField(fieldId);
setSaving(fieldId); setSaving(fieldId);
@@ -76,9 +136,7 @@ export default function AllocationPage() {
const variety = parseInt(varietyId) || null; const variety = parseInt(varietyId) || null;
const existingPlan = getPlanForField(fieldId); const existingPlan = getPlanForField(fieldId);
if (!existingPlan || !existingPlan.crop) { if (!existingPlan || !existingPlan.crop) return;
return;
}
setSaving(fieldId); setSaving(fieldId);
@@ -97,17 +155,12 @@ export default function AllocationPage() {
const handleNotesChange = async (fieldId: number, notes: string) => { const handleNotesChange = async (fieldId: number, notes: string) => {
const existingPlan = getPlanForField(fieldId); const existingPlan = getPlanForField(fieldId);
if (!existingPlan) return;
if (!existingPlan) {
return;
}
setSaving(fieldId); setSaving(fieldId);
try { try {
await api.patch(`/plans/${existingPlan.id}/`, { await api.patch(`/plans/${existingPlan.id}/`, { notes });
notes,
});
await fetchData(true); await fetchData(true);
} catch (error) { } catch (error) {
console.error('Failed to save notes:', 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); const crop = crops.find((c) => c.id === cropId);
return crop?.varieties || []; 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) { if (loading) {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
@@ -135,131 +276,240 @@ export default function AllocationPage() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Navbar /> <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">
<h1 className="text-2xl font-bold text-gray-900"></h1> {/* PC用サイドバー */}
<div className="flex items-center gap-2"> <div
<label htmlFor="year" className="text-sm font-medium text-gray-700"> className={`hidden lg:block transition-all duration-300 ${
: showSidebar ? 'w-64' : 'w-12'
</label> } bg-white shadow rounded-lg m-4 overflow-hidden flex-shrink-0`}
<select >
id="year" {renderSidebar()}
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> </div>
{fields.length === 0 ? ( {/* メインコンテンツ */}
<div className="bg-white rounded-lg shadow p-8 text-center"> <div className="flex-1 min-w-0 p-4 lg:p-0">
<p className="text-gray-500"> <div className="max-w-7xl mx-auto">
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
</p> <h1 className="text-2xl font-bold text-gray-900"></h1>
</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"> <button
<td className="px-6 py-4 whitespace-nowrap"> className="lg:hidden px-4 py-2 bg-blue-600 text-white rounded-md flex items-center gap-2"
<div className="text-sm font-medium text-gray-900"> onClick={() => setShowMobileSummary(true)}
{field.name} >
</div> <BarChart3 className="h-4 w-4" />
<div className="text-xs text-gray-500">
{field.address} </button>
</div>
</td> <div className="flex items-center gap-2">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <label htmlFor="year" className="text-sm font-medium text-gray-700">
{field.area_tan} :
</td> </label>
<td className="px-6 py-4 whitespace-nowrap"> <select
<select id="year"
value={selectedCropId || ''} value={year}
onChange={(e) => onChange={(e) => setYear(parseInt(e.target.value))}
handleCropChange(field.id, e.target.value) className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
} >
disabled={saving === field.id} <option value={2025}>2025</option>
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={2026}>2026</option>
> <option value={2027}>2027</option>
<option value=""></option> </select>
{crops.map((crop) => ( </div>
<option key={crop.id} value={crop.id}> </div>
{crop.name}
</option> {fields.length === 0 ? (
))} <div className="bg-white rounded-lg shadow p-8 text-center">
</select> <p className="text-gray-500">
</td>
<td className="px-6 py-4 whitespace-nowrap"> </p>
<select </div>
value={selectedVarietyId || ''} ) : (
onChange={(e) => <div className="bg-white rounded-lg shadow overflow-hidden">
handleVarietyChange(field.id, e.target.value) <div className="overflow-x-auto">
} <table className="min-w-full divide-y divide-gray-200">
disabled={ <thead className="bg-gray-50">
saving === field.id || !selectedCropId <tr>
} <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
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"
> </th>
<option value=""></option> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{getVarietiesForCrop(selectedCropId).map((variety) => ( ()
<option key={variety.id} value={variety.id}> </th>
{variety.name} <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</option>
))} </th>
</select> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</td>
<td className="px-6 py-4"> </th>
<input <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
type="text"
value={plan?.notes || ''} </th>
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> </tr>
); </thead>
})} <tbody className="bg-white divide-y divide-gray-200">
</tbody> {fields.map((field) => {
</table> 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>
</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>
)} </div>
</div> )}
</div> </div>
); );
} }