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

@@ -202,6 +202,7 @@ Variety (品種マスタ)
- 圃場一覧・詳細・新規作成 - 圃場一覧・詳細・新規作成
- データ取込画面 - データ取込画面
- 申請書ダウンロード画面 - 申請書ダウンロード画面
- ダッシュボード画面(概要サマリー、作物別集計、クイックアクセス)
6. **対応付け可視化・紐づけ管理** (E-2): 6. **対応付け可視化・紐づけ管理** (E-2):
- 圃場一覧「対応表」モード(共済漢字地名・中山間所在地の一覧表示、直接紐づけ追加・解除) - 圃場一覧「対応表」モード(共済漢字地名・中山間所在地の一覧表示、直接紐づけ追加・解除)
- 圃場詳細画面の共済/中山間リンク管理(+追加、×解除、面積参考表示) - 圃場詳細画面の共済/中山間リンク管理(+追加、×解除、面積参考表示)
@@ -215,8 +216,7 @@ Variety (品種マスタ)
4. **パフォーマンス**: N+1問題が一部存在現状は問題ないが、データ増加時に対応必要 4. **パフォーマンス**: N+1問題が一部存在現状は問題ないが、データ増加時に対応必要
### 🔜 次の実装タスク(優先順) ### 🔜 次の実装タスク(優先順)
1. **A-1**: ダッシュボード画面 1. **A-7**: 検索・フィルタ
2. **A-7**: 検索・フィルタ
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照 詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照

View File

@@ -4,6 +4,7 @@ from rest_framework.response import Response
from django.db.models import Sum from django.db.models import Sum
from .models import Crop, Variety, Plan from .models import Crop, Variety, Plan
from .serializers import CropSerializer, VarietySerializer, PlanSerializer from .serializers import CropSerializer, VarietySerializer, PlanSerializer
from apps.fields.models import Field
class CropViewSet(viewsets.ModelViewSet): class CropViewSet(viewsets.ModelViewSet):
@@ -48,8 +49,16 @@ class PlanViewSet(viewsets.ModelViewSet):
by_crop[crop_name]['count'] += 1 by_crop[crop_name]['count'] += 1
by_crop[crop_name]['area'] += float(plan.field.area_tan) by_crop[crop_name]['area'] += float(plan.field.area_tan)
total_fields = Field.objects.count()
assigned_field_ids = plans.values_list('field_id', flat=True).distinct()
assigned_count = assigned_field_ids.count()
unassigned_count = total_fields - assigned_count
return Response({ return Response({
'year': int(year), 'year': int(year),
'total_fields': total_fields,
'assigned_fields': assigned_count,
'unassigned_fields': unassigned_count,
'total_plans': plans.count(), 'total_plans': plans.count(),
'total_area': float(total_area), 'total_area': float(total_area),
'by_crop': list(by_crop.values()) 'by_crop': list(by_crop.values())

View File

@@ -8,14 +8,17 @@
## A. ドキュメントに書かれているが実装されていないもの ## A. ドキュメントに書かれているが実装されていないもの
### A-1: ダッシュボード画面 ### ~~A-1: ダッシュボード画面~~ ✅ 対応済み
- **ドキュメント**: 画面設計書 画面2 - 概要サマリー(全圃場数/作付け済み/未割当)、クイックアクセスボタン、最近の変更履歴 - **対応内容**:
- **実装**: `/` はトークンの有無で `/allocation``/login` にリダイレクトするだけ - `/dashboard` にダッシュボード画面を新設。`/` はログイン済みなら `/dashboard` にリダイレクト
- **影響**: なくても作付け計画画面から全機能にアクセス可能。Navbarで各画面に遷移できる - 概要サマリー: 全圃場数、作付け済み筆数、未割当筆数(警告アイコン付き)
- **状態**: 🔜 未着手 - 作物別集計テーブル: 作物名、筆数、面積(反)、合計行
- クイックアクセス: 作付け計画・圃場管理・帳票出力・データ取込への4ボタン
**対応方針**: 将来、機能追加する時には、ここにボタンが増えていく形式になっていくはずなので必要です - 年度セレクタで年度切替可能
- Navbarに「ホーム」ボタン追加、KeinaSystemロゴクリックでダッシュボードに遷移
- バックエンド: summary APIに `total_fields`, `assigned_fields`, `unassigned_fields` を追加
- **対応日**: 2026-02-19
--- ---
@@ -224,7 +227,7 @@
| カテゴリ | 項目 | 状態 | | カテゴリ | 項目 | 状態 |
|---------|------|------| |---------|------|------|
| A-1 | ダッシュボード画面 | 🔜 未着手 | | A-1 | ダッシュボード画面 | ✅ 完了 |
| A-2 | チェックボックス一括操作 | ✅ 完了 | | A-2 | チェックボックス一括操作 | ✅ 完了 |
| A-3 | 前年度コピーボタン | ✅ 完了 | | A-3 | 前年度コピーボタン | ✅ 完了 |
| A-4 | 品種インライン追加・削除 | ✅ 完了 | | A-4 | 品種インライン追加・削除 | ✅ 完了 |

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 { try {
const response = await api.get(`/fields/?ordering=${sortOrder}`); const response = await api.get(`/fields/?ordering=${sortOrder}`);
setFields(response.data); 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()); setUniqueGroups(groups.sort());
} catch (error) { } catch (error) {
console.error('Failed to fetch fields:', error); console.error('Failed to fetch fields:', error);

View File

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

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { LogOut, Wheat, MapPin, FileText, Upload } from 'lucide-react'; import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard } from 'lucide-react';
import { logout } from '@/lib/api'; import { logout } from '@/lib/api';
export default function Navbar() { export default function Navbar() {
@@ -19,8 +19,21 @@ export default function Navbar() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16"> <div className="flex justify-between h-16">
<div className="flex items-center space-x-8"> <div className="flex items-center space-x-8">
<h1 className="text-xl font-bold text-green-700">KeinaSystem</h1> <button onClick={() => router.push('/dashboard')} className="text-xl font-bold text-green-700 hover:text-green-800 transition-colors">
KeinaSystem
</button>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<button
onClick={() => router.push('/dashboard')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/dashboard')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<LayoutDashboard className="h-4 w-4 mr-2" />
</button>
<button <button
onClick={() => router.push('/allocation')} onClick={() => router.push('/allocation')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${ className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${

View File

@@ -22,5 +22,5 @@
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules", "playwright.config.ts", "e2e"]
} }