作成: 2026-05-27
最終更新: 2026-05-27(フォーム入力阻害回避パターン追記)
対象: Next.js / Tailwind CSS による画面実装の統一ルール
位置づけ: 新規画面を実装する際に参照する実装規約。概念・業務方針は00_設計ポリシー_生産履歴中心設計.mdを参照。
すべての画面は以下の骨格で統一する。
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="mx-auto max-w-7xl px-4 py-8">
{/* コンテンツ */}
</main>
</div>
max-w-7xlmax-w-6xl も許容タイトルを左、操作ボタンを右に配置する。
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<SomeIcon className="h-6 w-6 text-emerald-700" />
<h1 className="text-2xl font-bold text-gray-900">ページタイトル</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* 年度セレクト、新規作成ボタンなど */}
</div>
</div>
lucide-react を使用。色は text-emerald-700 を基本とする<p className="mt-1 text-sm text-gray-500">説明文</p>年を軸に絞り込む画面には、年度セレクトを置く。
<label className="text-sm font-medium text-gray-700">年度:</label>
<select
value={year}
onChange={(e) => handleYearChange(Number(e.target.value))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
{years.map((y) => (
<option key={y} value={y}>{y}年度</option>
))}
</select>
Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i) で直近5年を生成localStorage に保存して次回の初期値とする(キー名は <機能名>Year 形式: nurseryYear, pesticideYear など)1ページ内で複数のレコード種別を切り替える場合に使う。
<div className="mb-5 inline-flex overflow-hidden rounded-lg border border-gray-200 bg-white">
<button
type="button"
onClick={() => setTab('seeding')}
className={`px-4 py-2 text-sm font-medium ${
tab === 'seeding' ? 'bg-emerald-600 text-white' : 'text-gray-700 hover:bg-gray-50'
}`}
>
播種
</button>
<button
type="button"
onClick={() => setTab('nursery')}
className={`px-4 py-2 text-sm font-medium ${
tab === 'nursery' ? 'bg-emerald-600 text-white' : 'text-gray-700 hover:bg-gray-50'
}`}
>
育苗
</button>
</div>
フィールド数が少ない実績登録(浸種・籾蒔きなど)に使う標準パターン。
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_420px]">
<section>{/* 一覧テーブル */}</section>
<section>{/* サイドパネルフォーム */}</section>
</div>
420px 固定幅form === null)はプレースホルダーを表示する:<section className="rounded-lg bg-white px-5 py-10 text-sm text-gray-500 shadow-sm">
新規作成または一覧の編集から入力できます。
</section>
| 状況 | パターン |
|---|---|
| フィールド数が少ない / 編集がシンプル | 2カラム(サイドパネル) |
| フィールド数が多い / 一覧行を伴う複雑な編集 | 別ページ(/new, /[id]/edit) |
コンテンツのまとまりを囲う共通スタイル。
<section className="overflow-hidden rounded-lg bg-white shadow-sm">
<div className="border-b bg-gray-50 px-5 py-3 text-sm font-medium text-gray-700">
セクションタイトル
</div>
<div className="px-5 py-5">
{/* コンテンツ */}
</div>
</section>
整数値には、少数点以下の表示をしてはならない。
実数値は、有効桁数を闇雲に広げてはいけない。必ず意味のある桁数までにする。
基本的に少数点以下1桁で表示する。
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-700">
<tr>
<th className="px-5 py-3 font-medium">列名</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map((item) => (
<tr key={item.id} className={editingId === item.id ? 'bg-emerald-50' : ''}>
<td className="px-5 py-3 text-gray-900">{item.value}</td>
<td className="px-5 py-3">
<button
onClick={() => startEdit(item)}
className="inline-flex items-center gap-1 rounded border border-gray-300 px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
<Pencil className="h-3.5 w-3.5" />
編集
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
bg-emerald-50(エメラルド系フォームの場合)または bg-sky-50(スカイ系の場合)でハイライト<div className="px-5 py-8 text-sm text-gray-400">この年度の記録はまだありません。</div><div className="px-5 py-8 text-sm text-gray-500">読み込み中...</div><label className="block">
<div className="mb-1 text-sm font-medium text-gray-700">フィールド名</div>
<input
type="text"
value={form.value}
onChange={(e) => onChange({ ...form, value: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
</label>
select も同じクラスtextarea も同じクラス(rows={3} を基本とする)type="number" の場合、step と min を必ず指定する<div className="grid gap-4 sm:grid-cols-2">
<label className="block">...</label>
<label className="block">...</label>
</div>
<div className="space-y-4 px-5 py-5">
{/* フィールド群 */}
</div>
フォームの入力途中で「マスタ未登録」に気づいた場合でも、ページを離れずに補完できるようにする。
別ページのマスタ管理に遷移させると、入力中の内容が失われるか、ユーザーが戻って再入力する手間が生じる。これを避けることが目的。
マスタを参照するセレクトには、常に以下の2層を用意する。
| 層 | 場所 | 役割 |
|---|---|---|
| フル管理 | /xxx/masters |
一覧・編集・削除・並べ替えなど完全な管理 |
| インライン追加 | フォーム内のセレクト直下 | 入力継続のための即時追加のみ |
フル管理は ページヘッダー右上のリンクボタン(既存の 農薬マスタ ボタンと同じパターン)で誘導する。インライン追加はセレクト直下に展開する。
/xxx/masters のようなサブページでは、ヘッダー左側に ChevronLeft + 戻る のボタンを置く。
inline-flex items-center gap-1 を基本とするtext-gray-500 hover:text-gray-700 を基本とする一覧やフォームの主導線は画面右側に置くことが多いため、左上の戻る導線はテキスト込みで見つけやすくしておく。
セレクトの下に「+ ○○を追加」ボタンを置き、クリックでその場にミニフォームを展開する。
農薬散布フォームの 容器情報を設定 が参考実装(frontend/src/app/pesticide/events/new/page.tsx の containerEditIdx パターン)。
{/* セレクト */}
<select value={form.locationId} onChange={...} className="w-full ...">
<option value="">育苗場所を選択</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>{loc.name}</option>
))}
</select>
{/* インライン追加トリガー */}
{!showInlineAdd && (
<button
type="button"
onClick={() => setShowInlineAdd(true)}
className="mt-1 inline-flex items-center gap-1 text-xs text-emerald-700 hover:underline"
>
<Plus className="h-3 w-3" />
育苗場所を追加
</button>
)}
{/* インラインミニフォーム(展開時) */}
{showInlineAdd && (
<div className="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="mb-2 text-xs font-medium text-blue-800">育苗場所を追加</p>
<div className="grid gap-2 sm:grid-cols-2">
<input
type="text"
placeholder="名称"
value={inlineForm.name}
onChange={...}
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm ..."
/>
<select value={inlineForm.type} onChange={...} className="w-full ...">
<option value="greenhouse">温室</option>
<option value="house">ハウス</option>
<option value="outdoor">露地</option>
<option value="other">その他</option>
</select>
</div>
<div className="mt-2 flex gap-2">
<button onClick={saveInline} disabled={inlineSaving} className="... bg-emerald-600 text-white ...">
保存
</button>
<button onClick={() => setShowInlineAdd(false)} className="... border border-gray-300 ...">
キャンセル
</button>
</div>
</div>
)}
インライン追加に含めるのは「最低限の登録に必要な項目のみ」とし、詳細設定はフル管理ページに委ねる。
| インライン追加で扱う | フル管理ページに委ねる |
|---|---|
| 名称(必須) | 編集・削除 |
| 種別(必須) | メモ・有効フラグなどの補足項目 |
| — | 並べ替え・一括操作 |
インライン保存が成功したら:
ユーザーが「追加してすぐ選択できる」状態にすることが目的。
<button
onClick={onSave}
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
>
<Save className="h-4 w-4" />
保存
</button>
bg-sky-600 hover:bg-sky-700 を使い区別する(例: nursery の「浸種」と「籾蒔き」)<button
onClick={onDelete}
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<Trash2 className="h-4 w-4" />
削除
</button>
editingId がある場合)のみ表示する<button
onClick={onCancel}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
閉じる
</button>
<div className="flex flex-wrap items-center gap-3">
{/* 保存 → 削除 → キャンセル の順 */}
</div>
<button
onClick={startCreate}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
>
<Plus className="h-4 w-4" />
ラベル
</button>
{/* エラー */}
{actionError && (
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{actionError}
</div>
)}
{/* 警告・注意 */}
{warningMessage && (
<div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{warningMessage}
</div>
)}
failedDataKeys パターンを参照)disabled を使って操作を封じる読み込み中... テキストのみで十分(スケルトンは使わない)saving フラグで保存・削除ボタンを disabled にするwindow.confirm を使う。
if (!window.confirm('この記録を削除しますか?')) return;
| 用途 | カラー |
|---|---|
| 主アクション・アクティブタブ・フォーカスリング | emerald-600 / emerald-700 |
| 2つ目の主アクション(同一画面内) | sky-600 / sky-700 |
| 削除・エラー | red-300 / red-50 / red-700 |
| 警告・注意 | amber-300 / amber-50 / amber-800 |
| ボーダー・ラベル・テキスト(標準) | gray-* 系 |
| セクションヘッダー背景 | bg-gray-50 |
品種選択では「作物名 / 品種名」の2段表示が読みやすい。
<select ...>
<option value="">品種を選択</option>
{varieties.map((v) => (
<option key={v.id} value={v.id}>
{v.cropName} / {v.name}
</option>
))}
</select>
一覧テーブルでも同様に、品種名を太字・作物名を補足テキストで表示する。
<td className="px-5 py-3">
<div className="font-medium text-gray-900">{item.variety_name}</div>
<div className="text-xs text-gray-500">{item.crop_name}</div>
</td>
API エラーレスポンスからユーザー向けメッセージを取り出す共通ロジックは、各ページに以下の関数を置く。
const extractErrorMessage = (error: unknown): string => {
const data = (error as any)?.response?.data;
if (!data) return '保存に失敗しました。';
if (typeof data.detail === 'string') return data.detail;
if (typeof data.error === 'string') return data.error;
if (typeof data === 'object') {
const messages = Object.values(data as Record<string, unknown>)
.flatMap((v) => (Array.isArray(v) ? v : [v]))
.filter(Boolean)
.map(String);
if (messages.length > 0) return messages.join(' ');
}
return '保存に失敗しました。';
};
docs/00_設計ポリシー_生産履歴中心設計.mdfrontend/src/app/nursery/page.tsxfrontend/src/app/ridge-building/