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:
@@ -202,6 +202,7 @@ Variety (品種マスタ)
|
||||
- 圃場一覧・詳細・新規作成
|
||||
- データ取込画面
|
||||
- 申請書ダウンロード画面
|
||||
- ダッシュボード画面(概要サマリー、作物別集計、クイックアクセス)
|
||||
6. **対応付け可視化・紐づけ管理** (E-2):
|
||||
- 圃場一覧「対応表」モード(共済漢字地名・中山間所在地の一覧表示、直接紐づけ追加・解除)
|
||||
- 圃場詳細画面の共済/中山間リンク管理(+追加、×解除、面積参考表示)
|
||||
@@ -215,8 +216,7 @@ Variety (品種マスタ)
|
||||
4. **パフォーマンス**: N+1問題が一部存在(現状は問題ないが、データ増加時に対応必要)
|
||||
### 🔜 次の実装タスク(優先順)
|
||||
|
||||
1. **A-1**: ダッシュボード画面
|
||||
2. **A-7**: 検索・フィルタ
|
||||
1. **A-7**: 検索・フィルタ
|
||||
|
||||
詳細は `document/06_ドキュメントvs実装_差異レポート.md` を参照
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from rest_framework.response import Response
|
||||
from django.db.models import Sum
|
||||
from .models import Crop, Variety, Plan
|
||||
from .serializers import CropSerializer, VarietySerializer, PlanSerializer
|
||||
from apps.fields.models import Field
|
||||
|
||||
|
||||
class CropViewSet(viewsets.ModelViewSet):
|
||||
@@ -48,8 +49,16 @@ class PlanViewSet(viewsets.ModelViewSet):
|
||||
by_crop[crop_name]['count'] += 1
|
||||
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({
|
||||
'year': int(year),
|
||||
'total_fields': total_fields,
|
||||
'assigned_fields': assigned_count,
|
||||
'unassigned_fields': unassigned_count,
|
||||
'total_plans': plans.count(),
|
||||
'total_area': float(total_area),
|
||||
'by_crop': list(by_crop.values())
|
||||
|
||||
@@ -8,14 +8,17 @@
|
||||
|
||||
## A. ドキュメントに書かれているが実装されていないもの
|
||||
|
||||
### A-1: ダッシュボード画面
|
||||
### ~~A-1: ダッシュボード画面~~ ✅ 対応済み
|
||||
|
||||
- **ドキュメント**: 画面設計書 画面2 - 概要サマリー(全圃場数/作付け済み/未割当)、クイックアクセスボタン、最近の変更履歴
|
||||
- **実装**: `/` はトークンの有無で `/allocation` か `/login` にリダイレクトするだけ
|
||||
- **影響**: なくても作付け計画画面から全機能にアクセス可能。Navbarで各画面に遷移できる
|
||||
- **状態**: 🔜 未着手
|
||||
|
||||
**対応方針**: 将来、機能追加する時には、ここにボタンが増えていく形式になっていくはずなので必要です
|
||||
- **対応内容**:
|
||||
- `/dashboard` にダッシュボード画面を新設。`/` はログイン済みなら `/dashboard` にリダイレクト
|
||||
- 概要サマリー: 全圃場数、作付け済み筆数、未割当筆数(警告アイコン付き)
|
||||
- 作物別集計テーブル: 作物名、筆数、面積(反)、合計行
|
||||
- クイックアクセス: 作付け計画・圃場管理・帳票出力・データ取込への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-3 | 前年度コピーボタン | ✅ 完了 |
|
||||
| A-4 | 品種インライン追加・削除 | ✅ 完了 |
|
||||
|
||||
166
frontend/src/app/dashboard/page.tsx
Normal file
166
frontend/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
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="flex justify-between h-16">
|
||||
<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">
|
||||
<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
|
||||
onClick={() => router.push('/allocation')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "playwright.config.ts", "e2e"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user