A-1(ダッシュボード画面)の実装が完了しました。

実装内容:

バックエンド: summary APIに total_fields, assigned_fields, unassigned_fields を追加
フロントエンド: /dashboard に新画面を作成
概要サマリー: 全圃場数 / 作付け済み / 未割当(警告アイコン付き)
作物別集計テーブル(筆数・面積・合計行)
クイックアクセス: 4つのボタン(作付け計画・圃場管理・帳票出力・データ取込)
年度セレクタで切替可能
Navbar: 「ホーム」ボタン追加、KeinaSystemロゴクリックでダッシュボードへ
ルート (/): /allocation → /dashboard にリダイレクト先変更
http://localhost:3000/dashboard で確認できます。

残りタスク: A-7(検索・フィルタ)のみです
This commit is contained in:
Akira
2026-02-19 13:07:16 +09:00
parent cce119b1a8
commit 4afe37968b
8 changed files with 206 additions and 15 deletions

View File

@@ -0,0 +1,166 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import Navbar from '@/components/Navbar';
import { Wheat, MapPin, FileText, Upload, Loader2, AlertTriangle } from 'lucide-react';
interface SummaryData {
year: number;
total_fields: number;
assigned_fields: number;
unassigned_fields: number;
total_plans: number;
total_area: number;
by_crop: { crop: string; count: number; area: number }[];
}
export default function DashboardPage() {
const router = useRouter();
const currentYear = new Date().getFullYear();
const [year, setYear] = useState(currentYear);
const [summary, setSummary] = useState<SummaryData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchSummary = async () => {
setLoading(true);
try {
const res = await api.get(`/plans/summary/?year=${year}`);
setSummary(res.data);
} catch (error) {
console.error('Failed to fetch summary:', error);
} finally {
setLoading(false);
}
};
fetchSummary();
}, [year]);
const years = [];
for (let y = currentYear + 1; y >= currentYear - 3; y--) {
years.push(y);
}
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* ヘッダー */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-md text-sm"
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : summary ? (
<div className="space-y-6">
{/* 概要サマリーカード */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-5">
<p className="text-sm text-gray-500"></p>
<p className="text-3xl font-bold text-gray-900 mt-1">{summary.total_fields}<span className="text-base font-normal text-gray-500 ml-1"></span></p>
</div>
<div className="bg-white rounded-lg shadow p-5">
<p className="text-sm text-gray-500"></p>
<p className="text-3xl font-bold text-green-600 mt-1">{summary.assigned_fields}<span className="text-base font-normal text-gray-500 ml-1"></span></p>
</div>
<div className="bg-white rounded-lg shadow p-5">
<p className="text-sm text-gray-500"></p>
<div className="flex items-center mt-1">
<p className={`text-3xl font-bold ${summary.unassigned_fields > 0 ? 'text-amber-500' : 'text-gray-400'}`}>
{summary.unassigned_fields}<span className="text-base font-normal text-gray-500 ml-1"></span>
</p>
{summary.unassigned_fields > 0 && (
<AlertTriangle className="h-5 w-5 text-amber-500 ml-2" />
)}
</div>
</div>
</div>
{/* 作物別集計 */}
{summary.by_crop.length > 0 && (
<div className="bg-white rounded-lg shadow p-5">
<h2 className="text-lg font-semibold text-gray-900 mb-3"></h2>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 text-gray-500 font-medium"></th>
<th className="text-right py-2 text-gray-500 font-medium"></th>
<th className="text-right py-2 text-gray-500 font-medium"></th>
</tr>
</thead>
<tbody>
{summary.by_crop.map((item) => (
<tr key={item.crop} className="border-b border-gray-100">
<td className="py-2 text-gray-900">{item.crop}</td>
<td className="py-2 text-right text-gray-700">{item.count}</td>
<td className="py-2 text-right text-gray-700">{item.area.toFixed(1)}</td>
</tr>
))}
<tr className="font-semibold">
<td className="py-2 text-gray-900"></td>
<td className="py-2 text-right text-gray-900">{summary.total_plans}</td>
<td className="py-2 text-right text-gray-900">{summary.total_area.toFixed(1)}</td>
</tr>
</tbody>
</table>
</div>
)}
{/* クイックアクセス */}
<div className="bg-white rounded-lg shadow p-5">
<h2 className="text-lg font-semibold text-gray-900 mb-3"></h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<button
onClick={() => router.push('/allocation')}
className="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:bg-green-50 hover:border-green-300 transition-colors"
>
<Wheat className="h-6 w-6 text-green-600 mb-2" />
<span className="text-sm text-gray-700"></span>
</button>
<button
onClick={() => router.push('/fields')}
className="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
<MapPin className="h-6 w-6 text-blue-600 mb-2" />
<span className="text-sm text-gray-700"></span>
</button>
<button
onClick={() => router.push('/reports')}
className="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:bg-purple-50 hover:border-purple-300 transition-colors"
>
<FileText className="h-6 w-6 text-purple-600 mb-2" />
<span className="text-sm text-gray-700"></span>
</button>
<button
onClick={() => router.push('/import')}
className="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:bg-orange-50 hover:border-orange-300 transition-colors"
>
<Upload className="h-6 w-6 text-orange-600 mb-2" />
<span className="text-sm text-gray-700"></span>
</button>
</div>
</div>
</div>
) : (
<div className="text-center py-16 text-gray-500">
</div>
)}
</div>
</div>
);
}

View File

@@ -34,7 +34,7 @@ export default function FieldsPage() {
try {
const response = await api.get(`/fields/?ordering=${sortOrder}`);
setFields(response.data);
const groups = [...new Set(response.data.map((f: Field) => f.group_name).filter(Boolean))] as string[];
const groups = Array.from(new Set<string>(response.data.map((f: Field) => f.group_name).filter(Boolean)));
setUniqueGroups(groups.sort());
} catch (error) {
console.error('Failed to fetch fields:', error);

View File

@@ -9,7 +9,7 @@ export default function Home() {
useEffect(() => {
const token = localStorage.getItem('accessToken');
if (token) {
router.push('/allocation');
router.push('/dashboard');
} else {
router.push('/login');
}