見直し前の最終

This commit is contained in:
Akira
2026-02-16 13:45:16 +09:00
parent 4486722949
commit 9c21caa017
8 changed files with 953 additions and 160 deletions

Submodule .claude/worktrees/pensive-ptolemy added at 4486722949

291
CLAUDE.md Normal file
View File

@@ -0,0 +1,291 @@
# Keina System - Claude 向けガイド
> **最終更新**: 2026-02-16
> **現在のフェーズ**: Phase 1 (MVP) - 基本機能実装完了、試験中
## 📌 このファイルの目的
このファイルは、Claude が新しいセッションを開始する際に最初に読むべきドキュメントです。
プロジェクト全体の構造、重要な設計判断、現在の状態を把握するための情報を集約しています。
## ⚠️ Claude への重要な指示
**このファイルは、セッションごとに必ず最初に読んでください。**
さらに、以下のルールを厳守してください:
### 📝 更新義務
**機能追加・変更時は、必ずこのファイルを更新すること。**
- ✅ 新機能実装時 → 「実装状況」セクションを更新
- ✅ データモデル変更時 → 「データモデル概要」を更新
- ✅ 重要な設計判断時 → 「重要な制約・ルール」に追記
- ✅ 新作業パターン確立時 → 「よくある作業パターン」に追加
- ✅ 問題解決時 → 「トラブルシューティング」に追加
- ✅ 更新時は必ず「更新履歴」セクションに記録
**更新を忘れると、次のセッションで情報が失われます。これは最優先事項です。**
---
## 🎯 プロジェクト概要30秒で理解
**何を作っているか:**
農業生産者向けの作付け計画管理システム。圃場管理、作付け計画、申請書自動生成を行う。
**ユーザー:**
65歳の農家元プログラマー、シングルユーザー、39筆の圃場を管理
**技術スタック:**
- Backend: Django 5.2 + DRF + PostGIS
- Frontend: Next.js 14 (App Router) + TypeScript + Tailwind CSS
- Database: PostgreSQL 16 + PostGIS 3.4
**開発方針:**
シンプルさ最優先、段階的な機能追加、過度な複雑化を避ける
---
## 📂 プロジェクト構造
```
keinasystem_t02/
├── CLAUDE.md # このファイルClaude向けガイド
├── document/ # 詳細設計書(人間向け)
│ ├── 00_Gemini向け統合指示書.md # 全体像の詳細
│ ├── 01_プロダクトビジョン.md
│ ├── 02_ユーザーストーリー.md
│ ├── 03_データ仕様書.md
│ ├── 04_画面設計書.md
│ └── 05_実装優先順位.md
├── backend/
│ ├── keinasystem/ # Django設定
│ │ ├── settings.py # 重要: CORS, JWT, DB設定
│ │ └── urls.py # ルートURL設定
│ └── apps/
│ ├── fields/ # 圃場管理アプリ
│ │ ├── models.py # Field, OfficialKyosaiField, OfficialChusankanField
│ │ ├── views.py # インポート機能、CRUD API
│ │ └── urls.py
│ ├── plans/ # 作付け計画アプリ
│ │ ├── models.py # Plan, Crop, Variety
│ │ ├── views.py # 作付け計画API、集計API
│ │ └── management/commands/init_crops.py # 初期データ投入
│ └── reports/ # 申請書生成アプリ
│ ├── views.py # PDF生成API
│ └── templates/ # PDF用HTMLテンプレート
└── frontend/
└── src/app/
├── allocation/ # 作付け計画編集画面(メイン)
├── fields/ # 圃場一覧・詳細
├── reports/ # 申請書ダウンロード
└── import/ # データ取込画面
```
---
## 🗄️ データモデル概要
### コアエンティティ
```
Field (実圃場)
├── 39筆の実際の農地
├── area_tan (反), area_m2 (m2) の2つの面積フィールド
├── group_name, display_order (グループ分け・表示順)
└── ManyToMany関係
├── kyosai_fields (共済マスタ、M:N)
└── chusankan_fields (中山間マスタ、M:N)
OfficialKyosaiField (共済マスタ)
└── 31区画水稲共済細目書用
OfficialChusankanField (中山間マスタ)
└── 71区画中山間地域等直接支払交付金用
Plan (作付け計画)
├── field (FK to Field)
├── year (年度)
├── crop (FK to Crop)
├── variety (FK to Variety, nullable)
└── unique_together = ['field', 'year']
Crop (作物マスタ)
└── 米、トウモロコシ、エンドウ、野菜、その他
Variety (品種マスタ)
├── crop (FK to Crop)
├── name (品種名)
└── unique_together = ['crop', 'name']
```
### 重要な設計判断
1. **M:N関係に変更**: 当初はM:1だったが、実運用で「1つの実圃場が複数の申請区画に紐づく」ケースが判明し、ManyToManyに変更マイグレーション0003で実施
2. **面積単位の二重管理**:
- DB内部は `area_m2` (整数) で保存
- 表示用に `area_tan` (反, Decimal) も保持
- 理由: 申請書ではm2、農家の感覚では反
3. **品種は全作物で統一**:
- 「作付けしない」も「その他」作物の品種として扱う
- UI操作を統一するため
4. **グループ機能**:
- `group_name` (エリアや用途によるグループ分け)
- `display_order` (リスト表示時の順序)
- マイグレーション0004で追加
---
## 🔑 重要な制約・ルール
### 絶対に守るべきこと
1. **データの整合性**
- 年度 + 圃場の組み合わせは1つの Plan のみ (`unique_together`)
- 作物 + 品種名の組み合わせは一意 (`unique_together`)
2. **面積の扱い**
- 表示: 反 (tan)
- 計算・保存: m2
- 変換: 1反 = 1000m2 (正確には991.736m2だが、実運用では1000で統一)
3. **M:N関係の重要性**
- Field と OfficialKyosaiField は M:N
- Field と OfficialChusankanField は M:N
- 決して FK (1:N) に戻さない
4. **シンプルさ優先**
- 過度な抽象化を避ける
- 3回同じコードを書くまでは抽象化しない
- ユーザーは1人、パフォーマンス最適化は後回し
### コーディング規約
- **Backend**: Django のベストプラクティスに従う
- **Frontend**: TypeScript strict mode、ESLint に従う
- **API**: REST原則、エンドポイントは複数形 (`/api/fields/`, `/api/plans/`)
- **命名**: 日本語のフィールドは `verbose_name` で対応
---
## 📍 現在の実装状況
### ✅ 実装済みPhase 1 - MVP
1. **認証**: JWT認証アクセストークン24h、リフレッシュトークン7日
2. **圃場管理**:
- CRUD API (`/api/fields/`)
- ODS/Excelインポート (`/api/fields/import/`)
- グループ機能マイグレーション0004
3. **作付け計画**:
- 年度別の作付け計画 CRUD (`/api/plans/?year=2025`)
- 前年度コピー機能 (`/api/plans/copy_from_previous_year/`)
- 一括更新 (`/api/plans/bulk_update/`)
- 集計API (`/api/plans/summary/?year=2025`)
4. **申請書生成**:
- 水稲共済細目書 PDF (`/api/reports/kyosai/?year=2025`)
- 中山間交付金 PDF (`/api/reports/chusankan/?year=2025`)
5. **フロントエンド**:
- 作付け計画編集画面(集計サイドバー付き)
- 圃場一覧・詳細・新規作成
- データ取込画面
- 申請書ダウンロード画面
### 🚧 既知の課題・技術的負債
1. **認証周り**: ログアウト処理が未実装(トークン破棄のみ)
2. **エラーハンドリング**: フロントエンドでの統一的なエラー表示が未実装
3. **テスト**: 自動テストが未実装Phase 2で追加予定
4. **パフォーマンス**: N+1問題が一部存在現状は問題ないが、データ増加時に対応必要
### 📅 次のマイルストーンPhase 2
- 栽培履歴管理(播種日、農薬・肥料の散布記録)
- 作業予定のカレンダー表示
- モバイル対応の改善(スマホでの記録入力)
---
## 🛠️ よくある作業パターン
### 新しいモデルを追加する場合
1. `apps/<app_name>/models.py` にモデルクラスを追加
2. `python manage.py makemigrations`
3. `python manage.py migrate`
4. `apps/<app_name>/admin.py` に登録(管理画面で確認するため)
5. Serializer 作成 (`apps/<app_name>/serializers.py`)
6. ViewSet 作成 (`apps/<app_name>/views.py`)
7. URL登録 (`apps/<app_name>/urls.py`)
### 新しいAPI エンドポイントを追加する場合
1. `apps/<app_name>/views.py` にビューを追加
2. `apps/<app_name>/urls.py` にパスを追加
3. フロントエンドで型定義 (`frontend/src/lib/types.ts`)
4. API呼び出し関数作成 (`frontend/src/lib/api.ts` または直接fetch)
### 新しい画面を追加する場合
1. `frontend/src/app/<page_name>/page.tsx` を作成
2. 必要に応じてレイアウト調整 (`layout.tsx`)
3. API呼び出しは `useEffect` + `fetch` で実装
4. ローディング状態、エラー状態を適切に処理
---
## 🔍 トラブルシューティング
### マイグレーションエラー
```bash
# マイグレーションをリセット(開発環境のみ!)
docker-compose exec backend python manage.py migrate <app_name> zero
docker-compose exec backend python manage.py makemigrations
docker-compose exec backend python manage.py migrate
```
### CORS エラー
- `backend/keinasystem/settings.py``CORS_ALLOWED_ORIGINS` を確認
- 現在は `http://localhost:3000``http://127.0.0.1:3000` を許可
### JWT トークンエラー
- トークンの有効期限を確認(アクセストークン: 24時間
- リフレッシュトークンを使って更新(エンドポイント: `/api/auth/jwt/refresh/`
### PDF 生成エラー
- WeasyPrint のインストールを確認
- 日本語フォントの設定を確認HTMLテンプレートのCSS
---
## 📚 詳細情報へのリンク
- **プロジェクトの背景・目的**: `document/01_プロダクトビジョン.md`
- **機能要求・ユーザーストーリー**: `document/02_ユーザーストーリー.md`
- **データモデル詳細**: `document/03_データ仕様書.md`
- **画面設計**: `document/04_画面設計書.md`
- **実装手順**: `document/00_Gemini向け統合指示書.md`
---
## 💡 新しいセッションでの推奨フロー
1. この `CLAUDE.md` を読む
2. 現在のタスクに関連する `document/` 内のファイルを確認
3. 該当するモデル・ビュー・コンポーネントのコードを読む
4. 実装・修正を行う
5. 重要な設計判断があれば、この `CLAUDE.md` を更新
---
## 📝 更新履歴
- 2026-02-16: 初版作成(ハイブリッドアプローチの方針決定)

View File

@@ -20,4 +20,4 @@ class FieldSerializer(serializers.ModelSerializer):
class Meta:
model = Field
fields = ['id', 'name', 'address', 'area_tan', 'area_m2', 'owner_name', 'kyosai_fields', 'chusankan_fields']
fields = ['id', 'name', 'address', 'area_tan', 'area_m2', 'owner_name', 'group_name', 'display_order', 'kyosai_fields', 'chusankan_fields']

View File

@@ -1,17 +1,24 @@
import pandas as pd
from django.db import models as django_models
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from rest_framework import viewsets, permissions
from rest_framework import viewsets, permissions, filters
from rest_framework.decorators import action
from .models import OfficialKyosaiField, OfficialChusankanField, Field
from .serializers import FieldSerializer
class FieldViewSet(viewsets.ModelViewSet):
queryset = Field.objects.all()
queryset = Field.objects.all().order_by(
django_models.functions.Coalesce('group_name', django_models.Value('')),
'display_order',
'id'
)
serializer_class = FieldSerializer
permission_classes = [permissions.AllowAny]
filter_backends = [filters.OrderingFilter]
ordering_fields = ['group_name', 'display_order', 'id', 'area_tan']
@csrf_exempt

View File

@@ -0,0 +1,369 @@
# ドキュメント vs 実装 差異レポート
> **作成日**: 2026-02-16
> **目的**: ドキュメントと実装の差異を洗い出し、対応方針を決定する
>
> 各項目の「対応方針」欄に指示を記入してください。
> 記入例: 「実装する」「ドキュメント削除」「Phase 2に延期」「現状維持」など
---
## A. ドキュメントに書かれているが実装されていないもの
### A-1: ダッシュボード画面
- **ドキュメント**: 画面設計書 画面2 - 概要サマリー(全圃場数/作付け済み/未割当)、クイックアクセスボタン、最近の変更履歴
- **実装**: `/` はトークンの有無で `/allocation``/login` にリダイレクトするだけ
- **影響**: なくても作付け計画画面から全機能にアクセス可能。Navbarで各画面に遷移できる
**対応方針**:
```
将来、機能追加する時には、ここにボタンが増えていく形式になっていくはずなので必要です
```
---
### A-2: チェックボックスによる一括操作
- **ドキュメント**: 画面設計書 画面3 - 各行にチェックボックス、複数選択→一括割当
- **実装**: チェックボックスなし、一括操作UI なし
- **補足**: Backend には `POST /api/plans/bulk_update/` APIが既に存在する
**対応方針**:
```
利便性向上の為必要です。
```
---
### A-3: 前年度コピー機能(フロントエンド)
- **ドキュメント**: ユーザーストーリー P1-5、画面設計書 画面3 - [前年度をコピー]ボタン
- **実装**: Backend API (`POST /api/plans/copy_from_previous_year/`) は存在するが、Frontend にボタンがない
- **影響**: 毎年手動で39筆を設定する必要がある
**対応方針**:
```
必要な項目です。
```
---
### A-4: 品種のインライン追加
- **ドキュメント**: 画面設計書 画面4 - [+ 新しい品種を追加]ボタン、その場で入力して即座にマスタ登録
- **実装**: 既存品種からの選択のみ。新品種の追加はDjango管理画面からのみ可能
- **影響**: 運用中に新品種が出てきた場合、管理画面を開く必要がある
**対応方針**:
```
追加出来る事は必要です。削除も出来ないと間違って追加した時に不便です
```
---
### A-5: PDFプレビュー機能
- **ドキュメント**: 画面設計書 画面6 - [プレビュー]ボタンで新タブにPDF表示
- **実装**: ダウンロードボタンのみ(プレビューなし)
- **影響**: ダウンロード前に内容確認ができない
**対応方針**:
```
プレビューしてから保存、もしくは、印刷出来るようにしたいです。
```
---
### A-6: エクスポート機能CSV/ZIP
- **ドキュメント**: 画面設計書 画面7 - 全圃場データCSV、作付け計画CSV、全データZIPバックアップ
- **実装**: 未実装
- **影響**: バックアップ手段がないDBダンプのみ
**対応方針**:
```
必要です。近い将来サーバーに移行するので、その時に、このローカル環境で設定したデータを移動できるようにしたいです。
```
---
### A-7: 作付け計画画面の検索・フィルタ
- **ドキュメント**: 画面設計書 画面3 - 圃場名・住所で部分一致検索、作物で絞り込み、未割当のみトグル
- **実装**: 並び替え(カスタム順/グループ順/作付け順)のみ。テキスト検索なし
- **影響**: 39筆なので目視でも探せるが、検索があると便利
**対応方針**:
```
優先度は低いですが必要です。
```
---
### A-8: 圃場詳細画面の共済/中山間情報表示
- **ドキュメント**: 画面設計書 画面5 - 共済情報(耕地番号/分筆、中山間情報IDを表示
- **実装**: 基本情報(名称、住所、面積、所有者、グループ)の編集のみ
- **影響**: 圃場と申請区画の対応がUI上で確認できない
**対応方針**:
```
これ絶対に必要!
```
---
## B. 実装されているがドキュメントに記載がないもの
### B-1: グループ機能
- **実装**: `group_name`(エリア分け)、`display_order`(表示順)フィールド。圃場一覧・作付け計画で並び替え可能
- **ドキュメント**: CLAUDE.md にのみ記載。データ仕様書・画面設計書には未記載
- **経緯**: マイグレーション0004で後から追加された機能
**対応方針**:
```
ドキュメントに追記する / 現状維持
```
---
### B-2: 圃場管理画面(/fields
- **実装**: 圃場一覧(テーブル形式)、グループ編集、表示順変更(上下ボタン)、削除機能
- **ドキュメント**: 画面設計書に独立した画面としての記載なし画面5は「圃場詳細」のみ
**対応方針**:
```
ドキュメントに追記する / 現状維持
```
---
### B-3: 圃場新規作成画面(/fields/new
- **実装**: 手動で圃場を1件ずつ作成可能
- **ドキュメント**: インポートODS/Excelのみ想定。手動作成の記載なし
**対応方針**:
```
ドキュメントに追記する / 現状維持
```
---
### B-4: インライン編集方式(作付け計画)
- **実装**: テーブル内で直接作物・品種・備考を編集(即時保存)
- **ドキュメント**: 画面設計書 画面4 ではモーダルウィンドウでの編集を想定
**対応方針**:
```
実装に合わせてドキュメント更新 / モーダルに変更
```
---
### B-5: Navbarナビゲーションバー
- **実装**: 左サイドに常時表示のナビ(作付け計画、圃場管理、帳票出力、データ取込、ログアウト)
- **ドキュメント**: 画面設計書ではヘッダー内にハンバーガーメニュー(スマホ)を想定
**対応方針**:
```
ドキュメントに追記する / 現状維持
```
---
## C. ドキュメントと実装で食い違っているもの
### C-1: Field と共済/中山間の関係M:1 vs M:N
- **ドキュメント(データ仕様書)**: M:1ForeignKey- 「複数の実圃場が1つの共済区画に対応」
- **実装**: M:NManyToManyField- 1つの実圃場が複数の申請区画に紐づくケースにも対応
- **CLAUDE.md**: M:N に更新済み
- **影響**: データ仕様書のER図、紐付けロジックのコード例が古い
**対応方針**:
```
データ仕様書を M:N に更新
```
---
### C-2: 共済マスタのフィールド型
- **ドキュメント**: `k_num` = IntegerField, `s_num` = IntegerField、(k_num, s_num)のペアで一意
- **実装**: `k_num` = CharField(unique=True), `s_num` = CharField(null許容)
- **影響**: 紐付けロジックの仕様が異なる。k_num 単独で一意になっている
**対応方針**:
```
(k_num, s_num) ペアで一意が正しいです
```
---
### C-3: 中山間マスタのフィールド型
- **ドキュメント**: `c_id` = IntegerField, `chiban` = IntegerField
- **実装**: `c_id` = CharField, `chiban` = CharField
- **影響**: 数値でないデータが入る可能性(実運用で問題ないか要確認)
**対応方針**:
```
数値でないデータが入る可能性あります。イとかロとかがあります
```
---
### C-4: 面積フィールドの単位
- **ドキュメント**: 共済マスタ `area` = m2 (FloatField)、中山間マスタ `area` = m2 (IntegerField)
- **実装**: 両方とも `area` = DecimalField(ha表記)
- **影響**: PDF出力時の面積表示が m2 ではなく ha になっている可能性
**対応方針**:
```
面積の内部表現がm2に統一。表示上は反=10aに統一してください
```
---
### C-5: 作物マスタの初期データ
- **ドキュメント**: 米、トウモロコシ、エンドウ、野菜、その他
- **実装init_crops.py**: 水稲、大豆、小麦、そば、とうきび
- **品種も全く異なる**:
- ドキュメント: にこまる、たちはるか、たちはるか(特栽)、久留米豊、完全休耕 等
- 実装: コシヒカリ、ひとめぼれ、あきたこまち、つや姫、タマホマレ 等
- **影響**: ドキュメントはユーザー固有のデータだが、init_crops.py は汎用的なサンプルになっている
**対応方針**:
```
初期データはいらないです消して入れなおさなきゃいけない事になっている
どうしても必要なら、今登録されているデータに準拠してください
```
---
### C-6: 申請書の出力形式CSV vs PDF
- **ドキュメント(複数箇所)**:
- 01_プロダクトビジョン.md: 「申請書水稲共済・中山間のCSV出力」
- 05_実装優先順位.md: 「水稲共済細目書のCSV出力」「中山間交付金のCSV出力」
- 02_ユーザーストーリー.md: 「PDFでダウンロード」
- 04_画面設計書.md: 「PDFダウンロード」
- **実装**: PDF出力WeasyPrint
- **影響**: ドキュメント内で CSV と PDF が混在している
**対応方針**:
```
全ドキュメントをPDFに統一
```
---
### C-7: Django バージョン
- **ドキュメント(統合指示書)**: Django 5.0
- **実装**: Django 5.2.11
**対応方針**:
```
ドキュメントを 5.2 に更新
```
---
### C-8: DEFAULT_PERMISSION_CLASSES
- **ドキュメント(統合指示書)**: `IsAuthenticated`(認証必須)
- **実装**: `AllowAny`(認証なしでアクセス可能)
- **影響**: 現状では誰でもAPIにアクセスできる状態。開発中は便利だが本番では危険
**対応方針**:
```
IsAuthenticated に変更
後で変えると、漏れが怖いです
```
---
## D. 潜在的な不具合
### D-1: PDF生成時の variety/crop が null でクラッシュ
- **該当コード**: `backend/apps/reports/views.py` 20行目、24行目
- **問題**: `plan.crop.name` / `plan.variety.name` を直接参照。variety は nullable (blank=True, null=True)、crop も nullable
- **再現条件**: 品種を設定せずに作物だけ設定した圃場があるとPDF生成時にエラー
- **修正案**: `plan.variety.name if plan.variety else ''` のように null チェックを追加
**対応方針**:
```
修正する
```
---
### D-2: init_crops.py の不正データ
- **該当コード**: `backend/apps/plans/management/commands/init_crops.py` 12行目、26行目
- **問題**:
- `'ミヤギром'` — 日本語 + キリル文字(ロシア語の「ром」が混入)。正しくは「ミヤギシロメ」等?
- `'ゴールdent'` — 日本語 + 英語混在。正しくは「ゴールデント」等?
- **影響**: マスタデータの品種名が不正。品種選択UIで表示される
**対応方針**:
```
削除する — init_crops.py 自体を消す。作物・品種は管理画面やUIから登録する運用にする
```
---
### D-3: settings.py の二重定義
- **該当コード**: `backend/keinasystem/settings.py`
- 113行目: `LANGUAGE_CODE = 'en-us'`、115行目: `TIME_ZONE = 'UTC'`
- 152行目: `LANGUAGE_CODE = 'ja'`、154行目: `TIME_ZONE = 'Asia/Tokyo'`
- **問題**: 後の定義で上書きされるので動作に影響はないが、コードが紛らわしい
- **修正案**: 前の定義を削除し、後の定義のみ残す
**対応方針**:
```
`LANGUAGE_CODE = 'ja'`、154行目: `TIME_ZONE = 'Asia/Tokyo'`
```
---
### D-4: AllowAny で全API公開
- **該当コード**: `backend/keinasystem/settings.py` 137行目
- **問題**: DEFAULT_PERMISSION_CLASSES が AllowAny。認証トークンなしでもすべてのAPI圃場データ、作付け計画、インポート、PDF生成にアクセス可能
- **影響**: ローカル開発のみなら問題ないが、外部公開時にはデータ漏洩リスク
- **備考**: C-8 と同じ内容。セキュリティ面での対応
**対応方針**:
```
```
セキュリティ必須
---
## E.追加で修正の要望
### E-1.PDF出力される帳票のフォーマットが気に入らないです。
ここ未定義なので、まずは仕様を決めましょう
## 対応の進め方
上記すべての項目に対応方針を記入後、以下の順序で作業を進めることを推奨します:
1. **不具合修正** (D-1, D-2, D-3) — すぐに直せるもの
2. **セキュリティ** (C-8 / D-4) — 運用方針の確認
3. **データの食い違い修正** (C-1〜C-7) — ドキュメントか実装の修正
4. **未実装機能の追加** (A-1〜A-8) — 優先度を決めて順次対応
5. **ドキュメントへの追記** (B-1〜B-5) — 実装済み機能の記録

View File

@@ -39,7 +39,7 @@ export default function AllocationPage() {
if (!background) setLoading(true);
try {
const [fieldsRes, cropsRes, plansRes] = await Promise.all([
api.get('/fields/'),
api.get('/fields/?ordering=group_name,display_order,id'),
api.get('/plans/crops/'),
api.get(`/plans/?year=${year}`),
]);
@@ -215,7 +215,6 @@ export default function AllocationPage() {
};
const handleGroupChange = async (fieldId: number, groupName: string) => {
// ローカル状態を先に更新(並び替え防止)
setFields(prev => prev.map(f =>
f.id === fieldId ? { ...f, group_name: groupName || null } : f
));
@@ -226,7 +225,6 @@ export default function AllocationPage() {
});
} catch (error) {
console.error('Failed to save group:', error);
// エラー時は再取得
await fetchData(true);
}
};
@@ -448,155 +446,155 @@ export default function AllocationPage() {
</p>
</div>
) : (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800">
💡
</p>
</div>
)}
<>
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800">
💡
</p>
</div>
{sortedFields.length > 0 && (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{sortType === 'custom' && (
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{sortType === 'custom' && (
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
</th>
)}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
)}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
()
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedFields.map((field, index) => {
const plan = getPlanForField(field.id);
const selectedCropId = plan?.crop || 0;
const selectedVarietyId = plan?.variety || 0;
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
()
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedFields.map((field, index) => {
const plan = getPlanForField(field.id);
const selectedCropId = plan?.crop || 0;
const selectedVarietyId = plan?.variety || 0;
return (
<tr key={field.id} className="hover:bg-gray-50">
{sortType === 'custom' && (
<td className="px-2 py-4 whitespace-nowrap">
<div className="flex items-center gap-1">
<button
onClick={() => moveUp(index)}
disabled={index === 0 || saving === field.id}
className="p-1 hover:bg-gray-100 rounded disabled:opacity-30"
title="上へ移動"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
onClick={() => moveDown(index)}
disabled={index === sortedFields.length - 1 || saving === field.id}
className="p-1 hover:bg-gray-100 rounded disabled:opacity-30"
title="下へ移動"
>
<ArrowDown className="h-4 w-4" />
</button>
return (
<tr key={field.id} className="hover:bg-gray-50">
{sortType === 'custom' && (
<td className="px-2 py-4 whitespace-nowrap">
<div className="flex items-center gap-1">
<button
onClick={() => moveUp(index)}
disabled={index === 0 || saving === field.id}
className="p-1 hover:bg-gray-100 rounded disabled:opacity-30"
title="上へ移動"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
onClick={() => moveDown(index)}
disabled={index === sortedFields.length - 1 || saving === field.id}
className="p-1 hover:bg-gray-100 rounded disabled:opacity-30"
title="下へ移動"
>
<ArrowDown className="h-4 w-4" />
</button>
</div>
</td>
)}
<td className="px-4 py-4 whitespace-nowrap">
<input
list="group-options"
value={field.group_name || ''}
onChange={(e) => handleGroupChange(field.id, e.target.value)}
disabled={saving === field.id}
placeholder="選択または入力"
className="w-36 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50"
/>
<datalist id="group-options">
{groupOptions.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{field.name}
</div>
<div className="text-xs text-gray-500">
{field.address}
</div>
</td>
)}
<td className="px-4 py-4 whitespace-nowrap">
<input
list="group-options"
value={field.group_name || ''}
onChange={(e) => handleGroupChange(field.id, e.target.value)}
disabled={saving === field.id}
placeholder="選択または入力"
className="w-36 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50"
/>
<datalist id="group-options">
{groupOptions.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{field.name}
</div>
<div className="text-xs text-gray-500">
{field.address}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{field.area_tan}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={selectedCropId || ''}
onChange={(e) =>
handleCropChange(field.id, e.target.value)
}
disabled={saving === field.id}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50"
>
<option value=""></option>
{crops.map((crop) => (
<option key={crop.id} value={crop.id}>
{crop.name}
</option>
))}
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={selectedVarietyId || ''}
onChange={(e) =>
handleVarietyChange(field.id, e.target.value)
}
disabled={
saving === field.id || !selectedCropId
}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
>
<option value=""></option>
{getVarietiesForCrop(selectedCropId).map((variety) => (
<option key={variety.id} value={variety.id}>
{variety.name}
</option>
))}
</select>
</td>
<td className="px-6 py-4">
<input
type="text"
value={plan?.notes || ''}
onChange={(e) =>
handleNotesChange(field.id, e.target.value)
}
disabled={saving === field.id || !plan}
placeholder="備考を入力"
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm w-full disabled:opacity-50 disabled:bg-gray-100"
/>
</td>
</tr>
);
})}
</tbody>
</table>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{field.area_tan}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={selectedCropId || ''}
onChange={(e) =>
handleCropChange(field.id, e.target.value)
}
disabled={saving === field.id}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50"
>
<option value=""></option>
{crops.map((crop) => (
<option key={crop.id} value={crop.id}>
{crop.name}
</option>
))}
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={selectedVarietyId || ''}
onChange={(e) =>
handleVarietyChange(field.id, e.target.value)
}
disabled={
saving === field.id || !selectedCropId
}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm disabled:opacity-50 disabled:bg-gray-100"
>
<option value=""></option>
{getVarietiesForCrop(selectedCropId).map((variety) => (
<option key={variety.id} value={variety.id}>
{variety.name}
</option>
))}
</select>
</td>
<td className="px-6 py-4">
<input
type="text"
value={plan?.notes || ''}
onChange={(e) =>
handleNotesChange(field.id, e.target.value)
}
disabled={saving === field.id || !plan}
placeholder="備考を入力"
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 text-sm w-full disabled:opacity-50 disabled:bg-gray-100"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</>
)}
</div>
</div>

View File

@@ -23,6 +23,7 @@ export default function EditFieldPage() {
area_tan: '',
area_m2: '',
owner_name: '',
group_name: '',
});
useEffect(() => {
@@ -39,6 +40,7 @@ export default function EditFieldPage() {
area_tan: field.area_tan?.toString() || '',
area_m2: field.area_m2?.toString() || '',
owner_name: field.owner_name || '',
group_name: field.group_name || '',
});
} catch (err: unknown) {
console.error('Failed to fetch field:', err);
@@ -68,6 +70,7 @@ export default function EditFieldPage() {
area_tan: formData.area_tan ? parseFloat(formData.area_tan) : null,
area_m2: formData.area_m2 ? parseInt(formData.area_m2) : null,
owner_name: formData.owner_name || null,
group_name: formData.group_name || null,
};
await api.patch(`/fields/${fieldId}/`, data);
@@ -213,6 +216,26 @@ export default function EditFieldPage() {
/>
</div>
<div>
<label htmlFor="group_name" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
id="group_name"
name="group_name"
value={formData.group_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
placeholder="例Aエリア"
/>
</div>
<div className="pt-4">
placeholder="例:山田太郎"
/>
</div>
<div className="pt-4">
<button
type="submit"

View File

@@ -5,23 +5,27 @@ import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { Field } from '@/types';
import Navbar from '@/components/Navbar';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import { Plus, Pencil, Trash2, ArrowUp, ArrowDown } from 'lucide-react';
export default function FieldsPage() {
const router = useRouter();
const [fields, setFields] = useState<Field[]>([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<number | null>(null);
const [uniqueGroups, setUniqueGroups] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState('group_name,display_order,id');
useEffect(() => {
fetchFields();
}, []);
}, [sortOrder]);
const fetchFields = async () => {
setLoading(true);
try {
const response = await api.get('/fields/');
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[];
setUniqueGroups(groups.sort());
} catch (error) {
console.error('Failed to fetch fields:', error);
} finally {
@@ -46,6 +50,52 @@ export default function FieldsPage() {
}
};
const handleGroupChange = async (fieldId: number, newGroup: string) => {
try {
await api.patch(`/fields/${fieldId}/`, {
group_name: newGroup || null
});
if (newGroup && !uniqueGroups.includes(newGroup)) {
setUniqueGroups([...uniqueGroups, newGroup].sort());
}
if (sortOrder !== 'id') {
await fetchFields();
}
} catch (error) {
console.error('Failed to update group:', error);
alert('グループの更新に失敗しました');
}
};
const handleMoveOrder = async (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= fields.length) return;
if (sortOrder !== 'display_order,group_name,id') {
setSortOrder('display_order,group_name,id');
return;
}
const currentField = fields[index];
const targetField = fields[newIndex];
const currentOrder = currentField.display_order ?? 0;
const targetOrder = targetField.display_order ?? 0;
try {
await api.patch(`/fields/${currentField.id}/`, { display_order: targetOrder });
await api.patch(`/fields/${targetField.id}/`, { display_order: currentOrder });
await fetchFields();
} catch (error) {
console.error('Failed to reorder:', error);
alert('順序の変更に失敗しました');
}
};
const isDisplayOrderMode = sortOrder === 'display_order,group_name,id';
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
@@ -63,13 +113,27 @@ export default function FieldsPage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<button
onClick={() => router.push('/fields/new')}
className="flex items-center px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
</button>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">:</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-green-500"
>
<option value="group_name,display_order,id"></option>
<option value="display_order,group_name,id"></option>
<option value="id"></option>
</select>
</div>
<button
onClick={() => router.push('/fields/new')}
className="flex items-center px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
</button>
</div>
</div>
{fields.length === 0 ? (
@@ -78,13 +142,24 @@ export default function FieldsPage() {
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<datalist id="groups">
{uniqueGroups.map((group) => (
<option key={group} value={group} />
))}
</datalist>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-2 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
@@ -103,13 +178,42 @@ export default function FieldsPage() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{fields.map((field) => (
{fields.map((field, index) => (
<tr key={field.id} className="hover:bg-gray-50">
<td className="px-2 py-4 whitespace-nowrap text-center">
<div className="flex items-center justify-center space-x-1">
<button
onClick={() => handleMoveOrder(index, 'up')}
disabled={!isDisplayOrderMode || index === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
title={!isDisplayOrderMode ? "表示順優先モードで操作してください" : "上へ移動"}
>
<ArrowUp className="h-4 w-4" />
</button>
<button
onClick={() => handleMoveOrder(index, 'down')}
disabled={!isDisplayOrderMode || index === fields.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
title={!isDisplayOrderMode ? "表示順優先モードで操作してください" : "下へ移動"}
>
<ArrowDown className="h-4 w-4" />
</button>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{field.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
list="groups"
defaultValue={field.group_name || ''}
onBlur={(e) => handleGroupChange(field.id, e.target.value)}
placeholder="グループ名"
className="w-32 text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-green-500"
/>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-500">
{field.address || '-'}