Compare commits

...

6 Commits

Author SHA1 Message Date
akira
1371eef648 必須の指摘を修正しました。更新したのは Navbar.tsx です。
修正内容は2点です。

/fertilizer/masters を 施肥計画 のアクティブ判定から除外
運搬計画 のアイコンを FlaskConical から Truck に変更
確認は docker compose exec -T frontend npx tsc --noEmit で通っています。今回のレビューで必須だった重複アクティブはこれで解消しています
2026-04-07 11:19:05 +09:00
akira
ac0bc7b6a9 マスタードキュメント化しました。追加したのは 17_マスタードキュメント_ナビゲーション再編編.md です。
内容は、今回の仕様案をそのまま移すだけでなく、

なぜこの再編が必要になったか
なぜ マスター / 帳票・連携 という分類にしたか
URL とメニューをどう切り分けるか
Phase 1〜3 をどう進めるか
まで、長期参照向けに整理してあります。
改善案/ナビゲーション再編仕様書.md は実装向け詳細、今回追加したマスタードキュメントは長期保管用、Issue #13 は議論の背景、という役割分担にしています。必要なら次に、既存のどこかからこの 17_...md へ参照リンクを足します。
2026-04-07 11:12:19 +09:00
akira
1c474e9692 仕様書を更新しました。更新先は ナビゲーション再編仕様書.md です。
反映した内容は次の通りです。

5-3 に、初期実装ではブラウザ標準の Tab 移動を基本とすることを追記
5-3 に、矢印キーでの項目間移動は Phase 1 の必須要件外と明記
9 に、ドロップダウン展開後は Tab で各項目へ到達できることを追加
9 に、矢印キー移動は将来のアクセシビリティ強化項目として扱うと追記
これで、キーボード操作の範囲について実装者が迷いにくくなったはずです。
2026-04-07 11:09:51 +09:00
akira
0cd90e61db 仕様書を更新しました。変更先は ナビゲーション再編仕様書.md です。
今回入れた主な追記はこの3点です。

1-4 に Issue #13 との役割分担を追加
10-2 に NavGroup / NavItem の階層構造と match の位置づけを追加
10-3 に URL とナビゲーションの分離原則、Route Groups の扱い方を追加
これで、仕様書だけ読んでも実装方針が分かり、なぜそうしているかは Issue #13 にたどれる構成になりました。必要なら次に、10-2 のサンプル navGroups を今の分類に合わせてもっと具体化します。
2026-04-07 11:01:44 +09:00
akira
8de27de335 第2版 2026-04-07 10:33:29 +09:00
akira
71b8258281 メニューの整理案 2026-04-07 10:01:02 +09:00
4 changed files with 1301 additions and 185 deletions

View File

@@ -0,0 +1,298 @@
# マスタードキュメント:ナビゲーション再編
> **作成**: 2026-04-07
> **最終更新**: 2026-04-07
> **対象機能**: グローバルナビゲーション再編トップメニュー整理・カテゴリ再編・PC/スマホ共通情報設計)
> **実装状況**: 仕様策定完了・未実装
---
## 概要
機能追加に伴って共通ナビゲーションのトップレベル項目が増え、画面名ベースで並ぶ構造になってきたため、業務カテゴリ単位で再整理する。
今回の再編では、トップナビを `ホーム / 計画 / 実績 / マスター / 帳票・連携` の 5 分類に絞り、個別画面はドロップダウン配下に集約する。
これにより、利用者が「どの画面名か」ではなく「何をしたいか」で画面を探せる状態を目指す。
また、URL 構造とメニュー構成は意図的に分離して扱う。既存 URL は安定性を優先して原則維持し、アクティブ判定はナビ定義側で吸収する。
### 機能スコープIN / OUT
| IN今回対象 | OUT今回対象外 |
|---|---|
| PC ヘッダーのトップメニュー再編 | 各業務画面自体のUI改修 |
| スマホ用ハンバーガーメニュー再編 | 権限別メニュー出し分け |
| メニュー分類、並び順、開閉仕様 | お気に入り、ピン留め |
| アクティブ判定ルール整理 | ダッシュボード内容の刷新 |
| `NavGroup` / `NavItem` ベースのメニュー定義整理 | URL の全面変更 |
| `作物` `品種` を将来のマスター画面として位置づけ | 矢印キー移動を含む高度なメニューアクセシビリティ |
---
## 背景と判断理由
### 現状の課題
- 横並びのトップメニュー数が多く、目的の画面を探しにくい
- `計画` `実績` `設定` `補助機能` が同じ粒度で並んでいる
- 画面名ベースで項目が増えており、業務単位でまとまっていない
- 今後も機能追加が続くと、視認性と拡張性の両方が悪化する
### 採用した考え方
1. トップレベルは日常的に使う業務カテゴリだけに絞る
2. 個別機能名ではなく、業務単位で束ねる
3. URL はリソース識別子として安定性を優先し、メニュー構成とは分離する
4. 例外的な URL 衝突のみナビ定義側のルールで吸収する
### 関連議論
- 判断理由、論点の切り分け、URL とメニューの関係整理は Gitea Issue `#13` に残す
- 実装向けの決定事項は `改善案/ナビゲーション再編仕様書.md` に集約する
- 本ドキュメントは、その内容を長期参照用に固定化したものとして扱う
---
## 情報設計
### トップレベル構成
1. ホーム
2. 計画
3. 実績
4. マスター
5. 帳票・連携
右上ユーザー操作:
- パスワード変更
- ログアウト
### カテゴリ構成
#### ホーム
- ダッシュボード
#### 計画
- 作付け計画
- 施肥計画
- 田植え計画
- 運搬計画
#### 実績
- 散布実績
- 畔塗記録
- 作業記録
#### マスター
- 圃場管理
- 作物
- 品種
- 資材マスタ
- 肥料マスタ
#### 帳票・連携
- 在庫管理
- 帳票出力
- データ取込
- 気象
- メール
### この分類にした理由
#### マスター
- `圃場管理` は圃場マスタとして独立性が高い
- `作物` `品種` も本来マスター管理である
- `資材マスタ` `肥料マスタ` はすでに独立画面が存在する
そのため、基礎データ管理を `マスター` に集約する。
#### 帳票・連携
- `在庫管理` `帳票出力` `データ取込` `気象` `メール` は完全に同質ではない
- ただし、いずれも主作業そのものではなく、補助・参照・出力・連携の性質が強い
そのため、トップ階層を増やしすぎないための受け皿として `帳票・連携` にまとめる。
補足:
- `データ取込` は日常操作ではなく、年度切替時や初期設定時の補助導線とみなす
- `メール` は個別トップにしない
- `設定` は現状パスワード変更のみなので、右上ユーザー操作に残す
---
## 画面と所属カテゴリ
| カテゴリ | ラベル | パス |
|---|---|---|
| ホーム | ダッシュボード | `/dashboard` |
| 計画 | 作付け計画 | `/allocation` |
| 計画 | 施肥計画 | `/fertilizer` |
| 計画 | 田植え計画 | `/rice-transplant` |
| 計画 | 運搬計画 | `/distribution` |
| 実績 | 散布実績 | `/fertilizer/spreading` |
| 実績 | 畔塗記録 | `/levee-work` |
| 実績 | 作業記録 | `/workrecords` |
| マスター | 圃場管理 | `/fields` |
| マスター | 作物 | 未実装allocation 内管理を独立予定) |
| マスター | 品種 | 未実装allocation 内管理を独立予定) |
| マスター | 資材マスタ | `/materials/masters` |
| マスター | 肥料マスタ | `/fertilizer/masters` |
| 帳票・連携 | 在庫管理 | `/materials` |
| 帳票・連携 | 帳票出力 | `/reports` |
| 帳票・連携 | データ取込 | `/import` |
| 帳票・連携 | 気象 | `/weather` |
| 帳票・連携 > メール | メール履歴 | `/mail/history` |
| 帳票・連携 > メール | メールルール | `/mail/rules` |
| 右上ユーザー操作 | パスワード変更 | `/settings/password` |
---
## URL とナビゲーションの関係
### 基本原則
1. URL はリソース・機能識別子として安定性を優先する
2. メニュー構成とは意図的に分離して扱う
3. メニュー再編のたびに URL を変更しない
4. アクティブ判定はナビ定義側のルールで吸収する
### 採用理由
- URL をメニュー階層に合わせて変更すると、既存リンク、ブックマーク、テストへの影響が大きい
- メニュー構成は将来も変わりうるため、URL にメニュー階層を埋め込むと変更コストが増える
### 衝突する既存パス
| 優先判定するパス | 所属カテゴリ | 除外される側 |
|---|---|---|
| `/fertilizer/spreading` | 実績 | 計画 > 施肥計画 |
| `/materials/masters` | マスター | 帳票・連携 > 在庫管理 |
通常判定:
- `/fertilizer` `/fertilizer/new` `/fertilizer/[id]/edit``施肥計画`
- `/materials` `/materials?tab=...``在庫管理`
---
## 表示仕様
### PC
- 左: ブランド名 `KeinaSystem`
- 中央: トップメニュー 5 項目
- 右: パスワード変更、ログアウト
表示ルール:
- `ホーム` は単独リンク
- `計画` `実績` `マスター` `帳票・連携` はドロップダウン
- 開いているメニューがある状態で別メニューを開く場合は、前のメニューを閉じる
- メニュー外クリック、`Esc` キーで閉じる
- 項目選択後は遷移して閉じる
### スマホ
- ハンバーガーメニューを採用する
- `ホーム` は単独リンクで `/dashboard` へ遷移する
- それ以外のカテゴリはアコーディオン形式で開閉する
- 現在表示中の画面が属するカテゴリは初期状態で展開してよい
- 項目タップ後はメニューを閉じて画面遷移する
---
## アクセシビリティ方針
- トップメニューへキーボードでフォーカス移動できること
- `Enter` または `Space` でドロップダウンを開閉できること
- ドロップダウン展開後、各項目へ `Tab` で到達できること
- `Esc` で閉じられること
- 現在位置が視覚的に分かること
### 初期実装でやらないこと
- 矢印キーによるドロップダウン項目間移動
これは Phase 1 の必須要件には含めず、将来のアクセシビリティ強化項目として扱う。
---
## 実装方針
### メニュー定義
メニュー定義はフラットな `group` 管理ではなく、階層構造で持つ。
```ts
type NavItem = {
label: string;
href: string;
match?: (pathname: string) => boolean;
};
type NavGroup = {
key: string;
label: string;
type: 'link' | 'group';
href?: string;
items?: NavItem[];
};
```
方針:
- グループ構成そのものが定義から読み取れることを優先する
- 通常ケースは `href` ベースで扱う
- `href` ベースで判定しきれない例外ケースだけ `match` を持つ
- `match` は全件定義用ではなく、衝突回避用の最小限の逃げ道として使う
### Next.js App Router との関係
- Route Groups は、URL を変えずにコード構造を整理する手段として有効
- ただし Route Groups はナビ判定そのものの代替ではなく、実装整理の補助手段として扱う
---
## 段階導入
### Phase 1
- トップナビを 5 分類へ再編する
- `マスター` 配下に表示するのは `圃場管理` `資材マスタ` `肥料マスタ` のみ
- `作物` `品種` はマスター体系には含めるが、独立画面がまだないため Phase 1 ではメニューに表示しない
- PC / スマホともに同じ情報設計にそろえる
### Phase 2
- `作物管理` `品種管理` を独立画面として追加
- `帳票・連携` 内の `メール` を必要に応じてサブグループ化
### Phase 3
- 将来マルチユーザー化した場合のみ再検討
- 単独利用前提の間は実施対象外
---
## 受け入れ条件
- トップレベルに常時表示される業務メニューが 5 項目に収まっていること
- 現在存在する主要画面が、いずれかのカテゴリに漏れなく所属していること
- 各画面でアクティブ状態が期待通りに表示されること
- PC とスマホで同じカテゴリ構成になっていること
- メニューが増えても、トップレベルの項目数を増やさずに拡張できること
---
## 参照
- 議論の背景・判断理由: Gitea Issue `#13 メニューがごちゃごちゃしてきたので、整理する`
- 実装向け詳細仕様: `改善案/ナビゲーション再編仕様書.md`

View File

@@ -1,213 +1,503 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine, Construction, Tractor } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
ChevronDown,
Cloud,
FileText,
History,
KeyRound,
LayoutDashboard,
LogOut,
MapPin,
Menu,
NotebookText,
Package,
PencilLine,
Shield,
Sprout,
Tractor,
Truck,
Upload,
Construction,
Wheat,
X,
type LucideIcon,
} from 'lucide-react';
import { logout } from '@/lib/api';
type NavItem = {
label: string;
href: string;
icon?: LucideIcon;
match?: (pathname: string) => boolean;
};
type NavGroup = {
key: string;
label: string;
type: 'link' | 'group';
href?: string;
icon?: LucideIcon;
items?: NavItem[];
};
const matchesHref = (pathname: string, href: string) =>
pathname === href || pathname.startsWith(`${href}/`);
const navGroups: NavGroup[] = [
{
key: 'home',
label: 'ホーム',
type: 'link',
href: '/dashboard',
icon: LayoutDashboard,
},
{
key: 'planning',
label: '計画',
type: 'group',
icon: Wheat,
items: [
{ label: '作付け計画', href: '/allocation', icon: Wheat },
{
label: '施肥計画',
href: '/fertilizer',
icon: Sprout,
match: (pathname) =>
matchesHref(pathname, '/fertilizer') &&
!matchesHref(pathname, '/fertilizer/spreading') &&
!matchesHref(pathname, '/fertilizer/masters'),
},
{ label: '田植え計画', href: '/rice-transplant', icon: Tractor },
{ label: '運搬計画', href: '/distribution', icon: Truck },
],
},
{
key: 'records',
label: '実績',
type: 'group',
icon: NotebookText,
items: [
{
label: '散布実績',
href: '/fertilizer/spreading',
icon: PencilLine,
},
{ label: '畔塗記録', href: '/levee-work', icon: Construction },
{ label: '作業記録', href: '/workrecords', icon: NotebookText },
],
},
{
key: 'masters',
label: 'マスター',
type: 'group',
icon: Package,
items: [
{ label: '圃場管理', href: '/fields', icon: MapPin },
{
label: '資材マスタ',
href: '/materials/masters',
icon: Package,
},
{
label: '肥料マスタ',
href: '/fertilizer/masters',
icon: Sprout,
},
],
},
{
key: 'support',
label: '帳票・連携',
type: 'group',
icon: FileText,
items: [
{
label: '在庫管理',
href: '/materials',
icon: Package,
match: (pathname) =>
matchesHref(pathname, '/materials') && !matchesHref(pathname, '/materials/masters'),
},
{ label: '帳票出力', href: '/reports', icon: FileText },
{ label: 'データ取込', href: '/import', icon: Upload },
{ label: '気象', href: '/weather', icon: Cloud },
{ label: 'メール履歴', href: '/mail/history', icon: History },
{ label: 'メールルール', href: '/mail/rules', icon: Shield },
],
},
];
const userActions: NavItem[] = [
{ label: 'パスワード変更', href: '/settings/password', icon: KeyRound },
];
export default function Navbar() {
const router = useRouter();
const pathname = usePathname();
const navRef = useRef<HTMLElement>(null);
const [openDesktopGroup, setOpenDesktopGroup] = useState<string | null>(null);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [openMobileGroups, setOpenMobileGroups] = useState<string[]>([]);
useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!navRef.current?.contains(event.target as Node)) {
setOpenDesktopGroup(null);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenDesktopGroup(null);
setMobileMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
useEffect(() => {
setOpenDesktopGroup(null);
setMobileMenuOpen(false);
setOpenMobileGroups((prev) => {
const activeKey = getActiveGroupKey(pathname);
if (!activeKey) return prev;
return prev.includes(activeKey) ? prev : [activeKey];
});
}, [pathname]);
const handleLogout = () => {
logout();
};
const isActive = (path: string) => pathname === path;
const navigateTo = (href: string) => {
setOpenDesktopGroup(null);
setMobileMenuOpen(false);
router.push(href);
};
const toggleDesktopGroup = (key: string) => {
setOpenDesktopGroup((prev) => (prev === key ? null : key));
};
const toggleMobileGroup = (key: string) => {
setOpenMobileGroups((prev) =>
prev.includes(key) ? prev.filter((groupKey) => groupKey !== key) : [...prev, key]
);
};
const toggleMobileMenu = () => {
if (!mobileMenuOpen) {
const activeKey = getActiveGroupKey(pathname);
setOpenMobileGroups(activeKey ? [activeKey] : []);
}
setMobileMenuOpen((prev) => !prev);
};
return (
<nav className="bg-white shadow-sm border-b border-gray-200">
<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">
<button onClick={() => router.push('/dashboard')} className="text-xl font-bold text-green-700 hover:text-green-800 transition-colors">
<nav ref={navRef} className="border-b border-gray-200 bg-white shadow-sm">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-4 lg:gap-8">
<button
onClick={() => navigateTo('/dashboard')}
className="text-lg font-bold text-green-700 transition-colors hover:text-green-800 sm:text-xl"
>
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 ${
isActive('/allocation')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Wheat className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/fields')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/fields') || pathname?.startsWith('/fields/')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<MapPin className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/reports')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/reports') || pathname?.startsWith('/reports/')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<FileText className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/import')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/import')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Upload className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/mail/history')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/mail/history')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<History className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/mail/rules')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/mail/rules')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Shield className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/weather')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
isActive('/weather')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Cloud className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/fertilizer')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/fertilizer') && !pathname?.startsWith('/fertilizer/spreading')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Sprout className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/rice-transplant')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/rice-transplant')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Tractor className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/fertilizer/spreading')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/fertilizer/spreading')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<PencilLine className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/distribution')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/distribution')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<FlaskConical className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/levee-work')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/levee-work')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Construction className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/materials')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/materials')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Package className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/workrecords')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/workrecords')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<NotebookText className="h-4 w-4 mr-2" />
</button>
<div className="hidden items-center gap-2 lg:flex">
{navGroups.map((group) =>
group.type === 'link' ? (
<DesktopLinkButton
key={group.key}
group={group}
pathname={pathname}
onNavigate={navigateTo}
/>
) : (
<DesktopGroupButton
key={group.key}
group={group}
isOpen={openDesktopGroup === group.key}
pathname={pathname}
onNavigate={navigateTo}
onToggle={toggleDesktopGroup}
/>
)
)}
</div>
</div>
<div className="flex items-center space-x-1">
<button
onClick={() => router.push('/settings/password')}
className="flex items-center px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
title="パスワード変更"
>
<KeyRound className="h-4 w-4" />
</button>
<div className="hidden items-center gap-1 lg:flex">
{userActions.map((item) => (
<button
key={item.href}
onClick={() => navigateTo(item.href)}
className={`rounded-md px-3 py-2 text-sm transition-colors ${
isItemActive(item, pathname)
? 'bg-green-50 text-green-700'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
}`}
title={item.label}
>
{item.icon ? <item.icon className="h-4 w-4" /> : item.label}
</button>
))}
<button
onClick={handleLogout}
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
className="flex items-center rounded-md px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900"
>
<LogOut className="h-4 w-4 mr-2" />
<LogOut className="mr-2 h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2 lg:hidden">
<button
onClick={() => navigateTo('/settings/password')}
className={`rounded-md p-2 transition-colors ${
isItemActive(userActions[0], pathname)
? 'bg-green-50 text-green-700'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
}`}
title="パスワード変更"
>
<KeyRound className="h-5 w-5" />
</button>
<button
onClick={toggleMobileMenu}
className="rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
aria-expanded={mobileMenuOpen}
aria-label="メニューを開く"
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
{mobileMenuOpen && (
<div className="border-t border-gray-200 py-3 lg:hidden">
<div className="space-y-1">
{navGroups.map((group) =>
group.type === 'link' ? (
<MobileLinkButton
key={group.key}
group={group}
pathname={pathname}
onNavigate={navigateTo}
/>
) : (
<MobileGroupButton
key={group.key}
group={group}
isOpen={openMobileGroups.includes(group.key)}
pathname={pathname}
onNavigate={navigateTo}
onToggle={toggleMobileGroup}
/>
)
)}
<button
onClick={handleLogout}
className="mt-3 flex w-full items-center rounded-lg px-3 py-3 text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900"
>
<LogOut className="mr-3 h-4 w-4" />
</button>
</div>
</div>
)}
</div>
</nav>
);
}
function DesktopLinkButton({
group,
pathname,
onNavigate,
}: {
group: NavGroup;
pathname: string;
onNavigate: (href: string) => void;
}) {
const active = isGroupActive(group, pathname);
const Icon = group.icon;
return (
<button
onClick={() => group.href && onNavigate(group.href)}
className={`flex items-center rounded-md px-3 py-2 text-sm transition-colors ${
active ? 'bg-green-50 text-green-700' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{Icon ? <Icon className="mr-2 h-4 w-4" /> : null}
{group.label}
</button>
);
}
function DesktopGroupButton({
group,
isOpen,
pathname,
onNavigate,
onToggle,
}: {
group: NavGroup;
isOpen: boolean;
pathname: string;
onNavigate: (href: string) => void;
onToggle: (key: string) => void;
}) {
const active = isGroupActive(group, pathname);
const Icon = group.icon;
return (
<div className="relative">
<button
onClick={() => onToggle(group.key)}
className={`flex items-center rounded-md px-3 py-2 text-sm transition-colors ${
active || isOpen
? 'bg-green-50 text-green-700'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
aria-expanded={isOpen}
>
{Icon ? <Icon className="mr-2 h-4 w-4" /> : null}
{group.label}
<ChevronDown className={`ml-2 h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && group.items ? (
<div className="absolute left-0 top-full z-20 mt-2 w-64 rounded-xl border border-gray-200 bg-white p-2 shadow-lg">
{group.items.map((item) => (
<button
key={item.href}
onClick={() => onNavigate(item.href)}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isItemActive(item, pathname)
? 'bg-green-50 text-green-700'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{item.icon ? <item.icon className="mr-3 h-4 w-4" /> : null}
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}
function MobileLinkButton({
group,
pathname,
onNavigate,
}: {
group: NavGroup;
pathname: string;
onNavigate: (href: string) => void;
}) {
const active = isGroupActive(group, pathname);
const Icon = group.icon;
return (
<button
onClick={() => group.href && onNavigate(group.href)}
className={`flex w-full items-center rounded-lg px-3 py-3 text-left text-sm transition-colors ${
active ? 'bg-green-50 text-green-700' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{Icon ? <Icon className="mr-3 h-4 w-4" /> : null}
{group.label}
</button>
);
}
function MobileGroupButton({
group,
isOpen,
pathname,
onNavigate,
onToggle,
}: {
group: NavGroup;
isOpen: boolean;
pathname: string;
onNavigate: (href: string) => void;
onToggle: (key: string) => void;
}) {
const active = isGroupActive(group, pathname);
const Icon = group.icon;
return (
<div className="rounded-lg border border-gray-200">
<button
onClick={() => onToggle(group.key)}
className={`flex w-full items-center justify-between rounded-lg px-3 py-3 text-left text-sm transition-colors ${
active || isOpen
? 'bg-green-50 text-green-700'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
aria-expanded={isOpen}
>
<span className="flex items-center">
{Icon ? <Icon className="mr-3 h-4 w-4" /> : null}
{group.label}
</span>
<ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && group.items ? (
<div className="space-y-1 border-t border-gray-200 px-2 py-2">
{group.items.map((item) => (
<button
key={item.href}
onClick={() => onNavigate(item.href)}
className={`flex w-full items-center rounded-lg px-3 py-2.5 text-left text-sm transition-colors ${
isItemActive(item, pathname)
? 'bg-green-50 text-green-700'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{item.icon ? <item.icon className="mr-3 h-4 w-4" /> : null}
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}
function isGroupActive(group: NavGroup, pathname: string) {
if (group.type === 'link') {
return group.href ? matchesHref(pathname, group.href) : false;
}
return group.items?.some((item) => isItemActive(item, pathname)) ?? false;
}
function isItemActive(item: NavItem, pathname: string) {
if (item.match) {
return item.match(pathname);
}
return matchesHref(pathname, item.href);
}
function getActiveGroupKey(pathname: string) {
return navGroups.find((group) => isGroupActive(group, pathname))?.key ?? null;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,528 @@
# ナビゲーション再編仕様書
> 作成日: 2026-04-07
> 対象: `frontend/src/components/Navbar.tsx`
> 方針: 第一候補「上段5分類 + ドロップダウン」
---
## 0. 背景
現状のグローバルナビゲーションは、機能追加のたびに横並びのボタンを増やしており、以下の問題が出ている。
- 上位階層の導線が多すぎて、目的の画面を探しにくい
- 「計画」「実績」「設定」「補助機能」が同じ粒度で並んでいる
- メール関連のように、単独トップに置くほどではない機能が場所を取りやすい
- 今後も機能追加が続くと、横幅不足と認知負荷の両方が悪化する
このため、トップナビは「日常的に使う業務カテゴリ」だけを見せ、個別画面はドロップダウン配下へ整理する。
---
## 1. 目的
### 1-1. 目指す状態
- 1階層目では「何をしたいか」で探せる
- 似た役割の画面を同じカテゴリに集約する
- 画面数が増えても、トップレベルの見た目を増やしすぎない
- PC とスマホで同じ情報設計を維持する
### 1-2. 今回の対象
- 共通ヘッダー内のグローバルナビゲーション再編
- メニュー分類、ラベル、並び順、開閉仕様の定義
- 各画面がどのカテゴリに属するかの明確化
### 1-3. 今回やらないこと
- 各業務画面そのものの UI 改修
- 権限別メニュー出し分け
- お気に入り機能、ピン留め機能
- ナビゲーションと連動したダッシュボード内容の刷新
### 1-4. 関連 Issue との役割分担
- Issue `#13 メニューがごちゃごちゃしてきたので、整理する` は、背景、論点、判断理由を残す親議論として扱う
- 本仕様書は、その議論を踏まえた実装向けの決定事項をまとめる文書として扱う
- 後から判断理由を確認したい場合は Issue `#13` を参照する
---
## 2. 基本方針
### 2-1. トップレベル構成
トップナビでは以下の 5 項目のみを常時表示する。
1. ホーム
2. 計画
3. 実績
4. マスター
5. 帳票・連携
右端には従来どおりユーザー操作を置く。
- パスワード変更
- ログアウト
### 2-2. 設計ルール
- 毎日使う業務カテゴリだけをトップに置く
- 個別機能名ではなく、業務単位で束ねる
- 設定、履歴、通知、補助系は単独トップにしない
- 同じ業務の前後工程は可能な限り同じカテゴリに寄せる
---
## 3. 情報設計
### 3-1. カテゴリ構成
#### ホーム
- ダッシュボード
#### 計画
- 作付け計画
- 施肥計画
- 田植え計画
- 運搬計画
#### 実績
- 散布実績
- 畔塗記録
- 作業記録
#### マスター
- 圃場管理
- 作物
- 品種
- 資材マスタ
- 肥料マスタ
#### 帳票・連携
- 在庫管理
- 帳票出力
- データ取込
- 気象
- メール
### 3-2. メールの扱い
メール関連はトップ階層に個別表示しない。
`帳票・連携 > メール` の中にまとめる。
内訳:
- メール履歴
- メールルール
### 3-3. 設定の扱い
現状はパスワード変更のみのため、独立カテゴリにはしない。
初期実装では右上アイコンからのパスワード変更導線を維持し、設定系機能が増えた場合に別途 `設定` グループ化を検討する。
---
## 4. 画面とメニューの対応
### 4-1. 現在の主要画面の所属
| カテゴリ | ラベル | パス |
|---|---|---|
| ホーム | ダッシュボード | `/dashboard` |
| 計画 | 作付け計画 | `/allocation` |
| 計画 | 施肥計画 | `/fertilizer` |
| 計画 | 田植え計画 | `/rice-transplant` |
| 計画 | 運搬計画 | `/distribution` |
| 実績 | 散布実績 | `/fertilizer/spreading` |
| 実績 | 畔塗記録 | `/levee-work` |
| 実績 | 作業記録 | `/workrecords` |
| マスター | 圃場管理 | `/fields` |
| マスター | 作物 | `未実装: allocation 内管理を独立予定` |
| マスター | 品種 | `未実装: allocation 内管理を独立予定` |
| マスター | 資材マスタ | `/materials/masters` |
| マスター | 肥料マスタ | `/fertilizer/masters` |
| 帳票・連携 | 在庫管理 | `/materials` |
| 帳票・連携 | 帳票出力 | `/reports` |
| 帳票・連携 | データ取込 | `/import` |
| 帳票・連携 | 気象 | `/weather` |
| 帳票・連携 > メール | メール履歴 | `/mail/history` |
| 帳票・連携 > メール | メールルール | `/mail/rules` |
| 右上ユーザー操作 | パスワード変更 | `/settings/password` |
### 4-2. アクティブ表示ルール
- その画面自身、またはその配下詳細画面にいる場合、所属カテゴリをアクティブにする
- ドロップダウン内の該当項目も個別にアクティブ表示する
- パス接頭辞だけで判定するとカテゴリが衝突する画面があるため、より具体的なパスを優先して判定する
- 特に `施肥計画``散布実績` はともに `/fertilizer` 配下を使うため、`/fertilizer/spreading` を先に判定し、`計画` 側から明示的に除外する
- 同様に `在庫管理``資材マスタ` はともに `/materials` 配下を使うため、`/materials/masters` を先に判定し、`帳票・連携` 側の `在庫管理` から明示的に除外する
- 例:
- `/fertilizer``計画` をアクティブ
- `/fertilizer/new``計画` をアクティブ
- `/fertilizer/[id]` および `/fertilizer/[id]/edit``計画` をアクティブ
- `/fertilizer/spreading``実績` をアクティブ
- `/fertilizer/spreading?...``実績` をアクティブ
- `/materials``帳票・連携` をアクティブ
- `/materials/masters``マスター` をアクティブ
- 例:
- `/fields/123` の場合は `マスター` がアクティブ
- `/fertilizer/10/edit` の場合は `計画` がアクティブ
- `/mail/history` の場合は `帳票・連携``メール履歴` がアクティブ
---
## 5. PC 表示仕様
### 5-1. レイアウト
PC ではヘッダーを 3 ブロック構成とする。
1. 左: ブランド名 `KeinaSystem`
2. 中央: トップメニュー 5 項目
3. 右: パスワード変更、ログアウト
### 5-2. トップメニューの見せ方
- `ホーム` は単独リンク
- `計画` `実績` `マスター` `帳票・連携` はドロップダウン付きメニュー
- ラベルの横に開閉アイコンを表示する
- 開いたメニューは白背景のパネルとして表示する
### 5-3. 開閉ルール
- クリックで開閉
- 開いている他メニューがある場合は、それを閉じてから新しいメニューを開く
- メニュー外クリックで閉じる
- `Esc` キーで閉じる
- キーボード操作は初期実装ではブラウザ標準の `Tab` 移動を基本とする
- 矢印キーによるドロップダウン項目間移動は Phase 1 の必須要件には含めない
- 項目クリック後は遷移して閉じる
### 5-4. ドロップダウンの表示内容
各項目は以下の順で並べる。
#### 計画
1. 作付け計画
2. 施肥計画
3. 田植え計画
4. 運搬計画
#### 実績
1. 散布実績
2. 畔塗記録
3. 作業記録
#### マスター
1. 圃場管理
2. 作物
3. 品種
4. 資材マスタ
5. 肥料マスタ
#### 帳票・連携
1. 在庫管理
2. 帳票出力
3. データ取込
4. 気象
5. メール
`メール` は 2 段構造にする方法と、直接展開せず一覧モーダル風に見せる方法があるが、初期実装ではシンプルさを優先し、`帳票・連携` ドロップダウン内に個別リンクを直接置く。
初期実装の並び:
1. 在庫管理
2. 帳票出力
3. データ取込
4. 気象
5. メール履歴
6. メールルール
なお情報設計上の名称としては `メール` を維持し、将来的に機能が増えた時点で再度サブグループ化する。
---
## 6. スマホ表示仕様
### 6-1. 基本方針
スマホではハンバーガーメニューを採用する。
PC と同じカテゴリ構成を維持し、見た目だけ縦並びにする。
### 6-2. 表示ルール
- 初期状態ではロゴ、メニューボタン、ログアウト系導線のみ表示
- メニューボタン押下で全画面または右スライドのメニューを開く
- カテゴリはアコーディオン形式で開閉する
### 6-3. 並び順
1. ホーム
2. 計画
3. 実績
4. マスター
5. 帳票・連携
6. パスワード変更
7. ログアウト
### 6-4. 開閉ルール
- `ホーム` は単独リンクとし、タップ時はそのまま `/dashboard` へ遷移する
- カテゴリ見出しタップで開閉
- 現在表示中の画面が属するカテゴリは初期状態で展開してよい
- 項目タップ後はメニューを閉じて画面遷移する
---
## 7. ラベル方針
### 7-1. トップ階層ラベル
- 短く、役割が伝わる言葉を使う
- 詳細機能名は 2 階層目へ寄せる
### 7-2. 用語ルール
- `ホーム` は利用者に最も分かりやすいため維持
- `計画` は作付け、施肥、田植え、運搬を含む包括名として使用
- `実績` は記録系業務のまとめ先とする
- `マスター` は日々の業務を支える基礎データ管理の置き場とする
- `帳票・連携` は出力、取込、通知、補助参照のまとめ先とする
将来 `帳票・連携` に機能が増えすぎた場合は、次の再編を検討する。
- `帳票`
- `連携`
- `通知`
---
## 8. アイコン方針
### 8-1. トップ階層
トップ階層には必要最低限のアイコンのみ使用する。
- ホーム: 家またはダッシュボード系
- 計画: 作物または計画系
- 実績: チェック、記録系
- マスター: 設定、リスト、データベース系
- 帳票・連携: ファイル、送受信、クラウド系
ただし、文字認識を優先し、アイコンは補助扱いとする。
### 8-2. ドロップダウン内
現行アイコンを流用してよいが、すべてに付ける必要はない。
視認性よりも一覧性を優先し、テキスト中心でも可。
---
## 9. 操作性・アクセシビリティ要件
- キーボードでトップメニューにフォーカス移動できること
- `Enter` または `Space` でドロップダウンを開閉できること
- ドロップダウン展開後、各項目へ `Tab` で到達できること
- `Esc` で閉じられること
- 矢印キーによる項目間移動は初期実装の必須要件には含めず、将来のアクセシビリティ強化項目として扱う
- 現在位置が視覚的に分かること
- タップ領域は十分に確保すること
- スマホで誤タップしにくい行間と余白を確保すること
---
## 10. 実装方針
### 10-1. コンポーネント構成案
`Navbar.tsx` を以下の責務に分ける。
- ブランド表示
- トップメニュー定義
- ドロップダウン表示
- モバイルメニュー表示
- 右端ユーザー操作
必要に応じて次のような補助構成へ分割してよい。
- `navGroups` 定数
- `DesktopNav`
- `MobileNav`
### 10-2. メニュー定義データ化
個別ボタンの直書きはやめ、カテゴリ配列で管理する。
メニュー定義はフラットな `group` 管理ではなく、階層構造で持つ。
方針:
- グループ構成そのものが定義から読み取れることを優先する
- 通常ケースは `href` ベースで扱う
- `href` ベースで判定しきれない例外ケースだけ `match` を持つ
- `match` は全件定義用ではなく、衝突回避用の最小限の逃げ道として使う
想定イメージ:
```ts
type NavItem = {
label: string;
href: string;
match?: (pathname: string) => boolean;
};
type NavGroup = {
key: string;
label: string;
type: 'link' | 'group';
href?: string;
items?: NavItem[];
};
const navGroups = [
{
key: 'home',
label: 'ホーム',
type: 'link',
href: '/dashboard',
},
{
key: 'planning',
label: '計画',
type: 'group',
items: [
{ label: '作付け計画', href: '/allocation' },
{ label: '施肥計画', href: '/fertilizer' },
{ label: '田植え計画', href: '/rice-transplant' },
{ label: '運搬計画', href: '/distribution' },
],
},
];
```
### 10-3. アクティブ判定
アクティブ判定は、現在の `pathname` と各項目の対応パターンで管理する。
基本原則:
- URL はリソース・機能識別子として安定性を優先し、メニュー階層とは分離して扱う
- メニュー再編のたびに URL を変更しない
- アクティブ判定はナビ定義側のルールで吸収する
- ただし、全件をルーターのように再定義するのではなく、通常ケースは `href` ベース、衝突ケースだけ `match` を使う
補足:
- Next.js App Router の Route Groups は、URL を変えずにコード構造を整理する手段としては有効
- ただし Route Groups はナビ判定そのものの代替ではなく、実装整理の補助手段として扱う
パス接頭辞衝突が起きる組み合わせは、具体パス優先で次のように扱う。
| 優先判定するパス | 所属カテゴリ | 除外される側 |
|---|---|---|
| `/fertilizer/spreading` | 実績 | 計画 > 施肥計画 |
| `/materials/masters` | マスター | 帳票・連携 > 在庫管理 |
通常判定の例:
- `/fertilizer`
- `/fertilizer/new`
- `/fertilizer/[id]/edit`
はすべて `施肥計画` 所属として扱う。
- `/materials`
- `/materials?tab=...`
`在庫管理` 所属として扱う。
実装上は、上記の衝突ケースのみ `NavItem.match` を使う想定とする。
---
## 11. 段階導入案
### Phase 1
- PC ナビを 5 分類へ再編
- `作物` `品種` はマスター体系に含めるが、Phase 1 ではメニューに表示しない
- Phase 1 の `マスター` 配下に表示するのは `圃場管理` `資材マスタ` `肥料マスタ` のみとする
- `作物` `品種` は未実装項目として仕様上は位置づけ、独立画面が用意できる Phase 2 でメニュー表示を開始する
- モバイルも同じ情報設計へ変更
### Phase 2
- `作物管理` `品種管理` を独立画面として追加
- `帳票・連携` 内の `メール` をサブグループ化
- よく使う画面の履歴や最近使った機能を補助表示
### Phase 3
- 将来マルチユーザー化した場合の再設計検討
- 権限や担当業務ごとの表示最適化の要否整理
- 単独利用を前提とする間は、Phase 3 は実施対象外とする
---
## 12. 受け入れ条件
- トップレベルに常時表示される業務メニューが 5 項目に収まっていること
- 現在存在する主要画面が、いずれかのカテゴリに漏れなく所属していること
- 各画面でアクティブ状態が期待通りに表示されること
- PC とスマホで同じカテゴリ構成になっていること
- メニューが増えても、トップレベルの項目数を増やさずに拡張できること
---
## 13. 想定される懸念と対応
### 13-1. 「マスター」に含める範囲
現時点では、`圃場管理` `作物` `品種` `資材マスタ` `肥料マスタ``マスター` として集約する。
日々の入力対象ではなく、業務の前提データを整える画面群としてまとめるのが自然である。
補足:
- `圃場管理` は圃場マスタとして独立性が高い
- `作物``品種` も本来マスター管理であり、現状は allocation 画面内で扱っているだけで、メニュー上は独立させる前提で考える
- `資材マスタ``肥料マスタ` はすでに独立ページがあり、`マスター` 配下に置くのが自然である
- 将来、地図、圃場グループ、所有者管理などが増えた場合は、`圃場マスタ` の再独立も検討する
### 13-2. 「帳票・連携」が少し広い
現時点では `在庫管理` `帳票出力` `データ取込` `気象` `メール` をまとめる。
完全に同じ性質ではないが、いずれも補助業務、参照、連携、出力に近い機能であり、トップ階層を増やしすぎないために同居させる。
### 13-3. 「データ取込」は日常操作ではない
`データ取込` は日常的に何度も使う画面ではなく、年度切替時や初期設定時に使う補助導線である。
そのためトップレベル常設には置かず、`帳票・連携` 配下に置く判断は妥当とする。
ただし今後、取込対象や運用頻度が増えた場合は、`設定` または `運用` 系カテゴリへの移設も検討対象とする。
### 13-4. 既存ユーザーが場所を見失う
初期導入時は以下を行う。
- 並び順をできるだけ業務の流れに合わせる
- ドロップダウン内で既存画面名は変更しない
- ダッシュボード上に主要導線を残す
---
## 14. 結論
現行の 1 画面 1 ボタン方式は、今後の機能追加に対して拡張性が低い。
そのため、トップナビは `ホーム / 計画 / 実績 / マスター / 帳票・連携` の 5 分類へ再編し、個別画面はドロップダウン配下に置く。
この構成により、利用者は「画面名」ではなく「やりたい業務」から機能を探せるようになり、将来の機能追加にも耐えやすくなる。