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