施肥計画機能を追加(年度×品種単位のマトリクス管理)
- Backend: apps/fertilizer を新規追加 - Fertilizer(肥料マスタ)、FertilizationPlan、FertilizationEntry モデル - 肥料マスタ・施肥計画 CRUD API - 3方式の自動計算API(反当袋数・均等配分・反当チッソ成分量) - 作付け計画から圃場候補を取得する API - WeasyPrint による PDF 出力(圃場×肥料=袋数 マトリクス表) - Frontend: app/fertilizer を新規追加 - 施肥計画一覧(年度セレクタ・PDF出力・編集・削除) - 肥料マスタ管理(インライン編集) - 施肥計画編集(品種選択→圃場自動取得→肥料追加→自動計算→マトリクス手動調整) - Navbar に「施肥計画」メニューを追加(Sprout アイコン) - Cursor ルールファイル・連携ガイドを削除(Claude Code 単独運用へ) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,58 +0,0 @@
|
|||||||
## Cursor ガイド(Keina System)
|
|
||||||
|
|
||||||
> **最終更新**: 2026-02-28
|
|
||||||
> **対象**: Cursor(GPT-5.1)エージェント用の運用ルール
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## このドキュメントの目的
|
|
||||||
|
|
||||||
- Keina System プロジェクトにおいて、Cursor がどのような前提・役割で振る舞うかを明文化する。
|
|
||||||
- Claude Code(Sonnet 系)と併用する際の、自身の立ち位置と守るべきルールを整理する。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## セッション開始時の読み順
|
|
||||||
|
|
||||||
Cursor は、このリポジトリで新しいセッションを開始する際に、次の順番でドキュメントを参照することを前提とする。
|
|
||||||
|
|
||||||
1. `.cursor/rules/30_Cursorガイド.md`(このファイル)
|
|
||||||
- Cursor 自身の振る舞い・運用ルールを確認し、必要に応じてアップデートする。
|
|
||||||
2. `document/20_Cursor_Claude連携ガイド.md`
|
|
||||||
- Cursor / Claude Code の役割分担と情報共有の方針を確認する。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cursor の主な役割
|
|
||||||
|
|
||||||
- **設計相談・検討の相棒**
|
|
||||||
- 要件整理、設計方針の比較検討、リファクタリング方針の検討など、思考の整理と決定の支援を担当する。
|
|
||||||
- **ドキュメントドリブンの徹底**
|
|
||||||
- 新しい設計判断や運用ルールを決めた場合は、まず関連ドキュメント(`CLAUDE.md` / `20_` / 本ファイルなど)を更新する。
|
|
||||||
- **実装の下書き・雛形作成**
|
|
||||||
- 実装方針が固まったあとのコードスケッチや雛形作成も行うが、実際の細かい実装・仕上げは Claude Code と分担してもよい。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Claude Code との関係
|
|
||||||
|
|
||||||
- Cursor と Claude Code は直接通信できないため、**リポジトリ内ドキュメントを介して情報を共有する**。
|
|
||||||
- Cursor は、設計フェーズの結果を必要に応じて以下のファイルにまとめる。
|
|
||||||
- `document/21_Cursor_設計ログ.md`(詳細な時系列ログ。必要になったタイミングで作成し、以後更新)
|
|
||||||
- `document/22_Cursor_からClaudeへの引き継ぎ.md`(Claude Code 向けの要約・ToDo。必要になったタイミングで作成し、以後更新)
|
|
||||||
- Claude Code に作業を渡すときは、ユーザーから「`CLAUDE.md` と 20_ / 22_ を読むように指示する」ことを前提とする。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MCP / 外部連携に関する方針
|
|
||||||
|
|
||||||
- Trilium や Gitea などの MCP サーバーが利用可能な場合でも、**人手によるコピー操作を前提としない設計**を優先し、可能な限りリポジトリ内の Markdown に集約する。
|
|
||||||
- MCP ツールを使う際は、各 MCP サーバー配下の `SERVER_METADATA.json` や `tools/*.json` を事前に参照し、スキーマに従った安全な呼び出しのみを行う。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## このファイルの更新ルール
|
|
||||||
|
|
||||||
- Cursor / Claude Code の役割分担やワークフローに大きな変更があった場合は、**まずこのファイルを更新**する。
|
|
||||||
- 変更がプロジェクト全体のルールに影響する場合は、`CLAUDE.md` や `document/20_Cursor_Claude連携ガイド.md` にも必要な範囲で反映する。
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
description: Cursor エージェント専用の振る舞いガイドを定義する
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# Cursor エージェントの参照ドキュメント優先順位
|
|
||||||
|
|
||||||
- Cursor は、このリポジトリで新しいセッションを開始するとき、**自分専用のガイドとして** `document/30_Cursorガイド.md` を最優先で参照する。
|
|
||||||
- `CLAUDE.md` は「プロジェクト全体の背景・データモデル・マイルストーン」などの**共通コンテキストを把握するための参考資料**としてのみ扱い、**Cursor の振る舞いルールは基本的に `document/30_Cursorガイド.md` に従う。**
|
|
||||||
- Claude Code(Sonnet 系)向けの具体的な運用指示は `CLAUDE.md` 側を正とし、Cursor は「Claude Code がどう動く前提なのか」を理解するために必要なときだけ参照する。
|
|
||||||
|
|
||||||
# 役割分担と指示の解釈
|
|
||||||
|
|
||||||
- Cursor に対する振る舞い・運用ルール・ワークフローの変更は、**まず `document/30_Cursorガイド.md` に記述された内容を正とする。**
|
|
||||||
- Cursor は、`CLAUDE.md` に Cursor 自身への直接的な指示が書かれていても、**内容が `document/30_Cursorガイド.md` と衝突する場合は 30 側を優先する。**
|
|
||||||
- Claude Code 向けの設定や運用手順を変える場合は、`CLAUDE.md` と `document/20_Cursor_Claude連携ガイド.md` を更新し、Cursor はそれらを「Claude Code に何を伝えるべきか」の資料として参照する。
|
|
||||||
|
|
||||||
30
CLAUDE.md
30
CLAUDE.md
@@ -171,6 +171,25 @@ WeatherRecord (日次気象記録)
|
|||||||
└── pressure_min (最低気圧hPa)
|
└── pressure_min (最低気圧hPa)
|
||||||
※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API
|
※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API
|
||||||
※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入)
|
※ 2016-01-01 から蓄積(初回は fetch_weather --full で一括投入)
|
||||||
|
|
||||||
|
Fertilizer (肥料マスタ)
|
||||||
|
├── name(肥料名、必須・unique)
|
||||||
|
├── maker(メーカー、任意)
|
||||||
|
├── capacity_kg(1袋重量kg、任意)
|
||||||
|
├── nitrogen_pct / phosphorus_pct / potassium_pct(成分%、任意)
|
||||||
|
└── notes(備考、任意)
|
||||||
|
|
||||||
|
FertilizationPlan (施肥計画)
|
||||||
|
├── name(計画名)
|
||||||
|
├── year(年度)
|
||||||
|
└── variety (FK to plans.Variety)
|
||||||
|
|
||||||
|
FertilizationEntry (施肥エントリ・中間テーブル)
|
||||||
|
├── plan (FK to FertilizationPlan)
|
||||||
|
├── field (FK to fields.Field)
|
||||||
|
├── fertilizer (FK to Fertilizer)
|
||||||
|
├── bags(袋数、Decimal)
|
||||||
|
└── unique_together = ['plan', 'field', 'fertilizer']
|
||||||
```
|
```
|
||||||
|
|
||||||
### 重要な設計判断
|
### 重要な設計判断
|
||||||
@@ -290,6 +309,12 @@ WeatherRecord (日次気象記録)
|
|||||||
- フロントエンド `/weather` 画面(年別集計・期間指定 モード、グラフは Recharts)
|
- フロントエンド `/weather` 画面(年別集計・期間指定 モード、グラフは Recharts)
|
||||||
- **将来計画**: 開花・収穫予測(品種ごとの目標GDD設定 → 到達日予測)
|
- **将来計画**: 開花・収穫予測(品種ごとの目標GDD設定 → 到達日予測)
|
||||||
- マスタードキュメント: `document/12_マスタードキュメント_気象データ編.md`
|
- マスタードキュメント: `document/12_マスタードキュメント_気象データ編.md`
|
||||||
|
10. **施肥計画機能**:
|
||||||
|
- Django `apps/fertilizer` アプリ(Fertilizer, FertilizationPlan, FertilizationEntry)
|
||||||
|
- API(JWT認証): `GET/POST /api/fertilizer/fertilizers/`, `GET/POST /api/fertilizer/plans/?year=`, `GET /api/fertilizer/plans/{id}/pdf/`, `GET /api/fertilizer/candidate_fields/?year=&variety_id=`, `POST /api/fertilizer/calculate/`
|
||||||
|
- 自動計算3方式: 反当袋数(per_tan)、均等配分(even)、反当チッソ(nitrogen)
|
||||||
|
- フロントエンド: `/fertilizer/`(一覧), `/fertilizer/new`・`/fertilizer/[id]/edit`(編集・マトリクス表), `/fertilizer/masters/`(肥料マスタ)
|
||||||
|
- スコープ外(将来): 購入管理、配置計画
|
||||||
|
|
||||||
### 🚧 既知の課題・技術的負債
|
### 🚧 既知の課題・技術的負債
|
||||||
|
|
||||||
@@ -314,8 +339,6 @@ Phase 2 のタスクに進む段階。
|
|||||||
|
|
||||||
## 🛠️ よくある作業パターン
|
## 🛠️ よくある作業パターン
|
||||||
|
|
||||||
- **Cursor / Claude Code 連携**: 詳細な運用ルールは `document/20_Cursor_Claude連携ガイド.md` を参照すること。
|
|
||||||
|
|
||||||
### 新しいモデルを追加する場合
|
### 新しいモデルを追加する場合
|
||||||
|
|
||||||
1. `apps/<app_name>/models.py` にモデルクラスを追加
|
1. `apps/<app_name>/models.py` にモデルクラスを追加
|
||||||
@@ -416,7 +439,8 @@ docker-compose exec backend python manage.py migrate
|
|||||||
|
|
||||||
## 📝 更新履歴
|
## 📝 更新履歴
|
||||||
|
|
||||||
- 2026-02-28: Cursor / Claude Code 連携運用ルールを追加(詳細は `document/20_Cursor_Claude連携ガイド.md` を参照)
|
- 2026-02-28: Cursor連携を廃止。Claude Code 単独運用に変更。`document/20_Cursor_Claude連携ガイド.md` を削除
|
||||||
|
- 2026-03-01: 施肥計画機能を実装。`apps/fertilizer`(Fertilizer, FertilizationPlan, FertilizationEntry, 自動計算3方式, PDF出力)、フロントエンド `/fertilizer/`(一覧・編集・肥料マスタ)。スコープ外: 購入管理・配置計画
|
||||||
- 2026-02-28: 気象データ機能を実装・本番稼働。`apps/weather`(WeatherRecord, 5 API)、Windmill `f/weather/weather_sync`(毎朝6時)、フロントエンド `/weather`(年別集計・期間指定・Rechartsグラフ)。`Crop.base_temp` 追加。デプロイコマンドの本番パス修正(/home/keinasystem/)。マスタードキュメント `document/12_マスタードキュメント_気象データ編.md` 追加
|
- 2026-02-28: 気象データ機能を実装・本番稼働。`apps/weather`(WeatherRecord, 5 API)、Windmill `f/weather/weather_sync`(毎朝6時)、フロントエンド `/weather`(年別集計・期間指定・Rechartsグラフ)。`Crop.base_temp` 追加。デプロイコマンドの本番パス修正(/home/keinasystem/)。マスタードキュメント `document/12_マスタードキュメント_気象データ編.md` 追加
|
||||||
- 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加
|
- 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加
|
||||||
- 2026-02-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPI(APIキー認証)、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md`
|
- 2026-02-22: メールフィルタリング機能を実装。`apps/mail` Django app、Windmill向けAPI(APIキー認証)、フィードバックページ、ルール管理ページを追加。仕様書: `document/メールフィルタ/mail_filter_spec.md`
|
||||||
|
|||||||
0
backend/apps/fertilizer/__init__.py
Normal file
0
backend/apps/fertilizer/__init__.py
Normal file
19
backend/apps/fertilizer/admin.py
Normal file
19
backend/apps/fertilizer/admin.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import Fertilizer, FertilizationPlan, FertilizationEntry
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Fertilizer)
|
||||||
|
class FertilizerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'maker', 'capacity_kg', 'nitrogen_pct']
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationEntryInline(admin.TabularInline):
|
||||||
|
model = FertilizationEntry
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FertilizationPlan)
|
||||||
|
class FertilizationPlanAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'year', 'variety']
|
||||||
|
list_filter = ['year']
|
||||||
|
inlines = [FertilizationEntryInline]
|
||||||
7
backend/apps/fertilizer/apps.py
Normal file
7
backend/apps/fertilizer/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizerConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.fertilizer'
|
||||||
|
verbose_name = '施肥計画'
|
||||||
67
backend/apps/fertilizer/migrations/0001_initial.py
Normal file
67
backend/apps/fertilizer/migrations/0001_initial.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Generated by Django 5.0 on 2026-03-01 02:50
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fields', '0006_e1c_chusankan_17_fields'),
|
||||||
|
('plans', '0004_crop_base_temp'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Fertilizer',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True, verbose_name='肥料名')),
|
||||||
|
('maker', models.CharField(blank=True, max_length=100, null=True, verbose_name='メーカー')),
|
||||||
|
('capacity_kg', models.DecimalField(blank=True, decimal_places=3, max_digits=8, null=True, verbose_name='1袋重量(kg)')),
|
||||||
|
('nitrogen_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='窒素含有率(%)')),
|
||||||
|
('phosphorus_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='リン酸含有率(%)')),
|
||||||
|
('potassium_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='カリ含有率(%)')),
|
||||||
|
('notes', models.TextField(blank=True, null=True, verbose_name='備考')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '肥料マスタ',
|
||||||
|
'verbose_name_plural': '肥料マスタ',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FertilizationPlan',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200, verbose_name='計画名')),
|
||||||
|
('year', models.IntegerField(verbose_name='年度')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('variety', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='fertilization_plans', to='plans.variety', verbose_name='品種')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '施肥計画',
|
||||||
|
'verbose_name_plural': '施肥計画',
|
||||||
|
'ordering': ['-year', 'variety'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FertilizationEntry',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('bags', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='袋数')),
|
||||||
|
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fields.field', verbose_name='圃場')),
|
||||||
|
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='fertilizer.fertilizationplan')),
|
||||||
|
('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.fertilizer', verbose_name='肥料')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '施肥エントリ',
|
||||||
|
'verbose_name_plural': '施肥エントリ',
|
||||||
|
'ordering': ['field', 'fertilizer'],
|
||||||
|
'unique_together': {('plan', 'field', 'fertilizer')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/apps/fertilizer/migrations/__init__.py
Normal file
0
backend/apps/fertilizer/migrations/__init__.py
Normal file
69
backend/apps/fertilizer/models.py
Normal file
69
backend/apps/fertilizer/models.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Fertilizer(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True, verbose_name='肥料名')
|
||||||
|
maker = models.CharField(max_length=100, blank=True, null=True, verbose_name='メーカー')
|
||||||
|
capacity_kg = models.DecimalField(
|
||||||
|
max_digits=8, decimal_places=3, blank=True, null=True, verbose_name='1袋重量(kg)'
|
||||||
|
)
|
||||||
|
nitrogen_pct = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='窒素含有率(%)'
|
||||||
|
)
|
||||||
|
phosphorus_pct = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='リン酸含有率(%)'
|
||||||
|
)
|
||||||
|
potassium_pct = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)'
|
||||||
|
)
|
||||||
|
notes = models.TextField(blank=True, null=True, verbose_name='備考')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '肥料マスタ'
|
||||||
|
verbose_name_plural = '肥料マスタ'
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlan(models.Model):
|
||||||
|
name = models.CharField(max_length=200, verbose_name='計画名')
|
||||||
|
year = models.IntegerField(verbose_name='年度')
|
||||||
|
variety = models.ForeignKey(
|
||||||
|
'plans.Variety', on_delete=models.PROTECT,
|
||||||
|
related_name='fertilization_plans', verbose_name='品種'
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '施肥計画'
|
||||||
|
verbose_name_plural = '施肥計画'
|
||||||
|
ordering = ['-year', 'variety']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.year} {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationEntry(models.Model):
|
||||||
|
"""圃場 × 肥料 × 袋数 の中間テーブル"""
|
||||||
|
plan = models.ForeignKey(
|
||||||
|
FertilizationPlan, on_delete=models.CASCADE, related_name='entries'
|
||||||
|
)
|
||||||
|
field = models.ForeignKey(
|
||||||
|
'fields.Field', on_delete=models.CASCADE, verbose_name='圃場'
|
||||||
|
)
|
||||||
|
fertilizer = models.ForeignKey(
|
||||||
|
Fertilizer, on_delete=models.CASCADE, verbose_name='肥料'
|
||||||
|
)
|
||||||
|
bags = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='袋数')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '施肥エントリ'
|
||||||
|
verbose_name_plural = '施肥エントリ'
|
||||||
|
unique_together = [['plan', 'field', 'fertilizer']]
|
||||||
|
ordering = ['field', 'fertilizer']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}袋"
|
||||||
81
backend/apps/fertilizer/serializers.py
Normal file
81
backend/apps/fertilizer/serializers.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Fertilizer, FertilizationPlan, FertilizationEntry
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizerSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Fertilizer
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationEntrySerializer(serializers.ModelSerializer):
|
||||||
|
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||||
|
field_area_tan = serializers.DecimalField(
|
||||||
|
source='field.area_tan', max_digits=6, decimal_places=4, read_only=True
|
||||||
|
)
|
||||||
|
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FertilizationEntry
|
||||||
|
fields = ['id', 'field', 'field_name', 'field_area_tan', 'fertilizer', 'fertilizer_name', 'bags']
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanSerializer(serializers.ModelSerializer):
|
||||||
|
variety_name = serializers.SerializerMethodField()
|
||||||
|
crop_name = serializers.SerializerMethodField()
|
||||||
|
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
||||||
|
field_count = serializers.SerializerMethodField()
|
||||||
|
fertilizer_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FertilizationPlan
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
|
||||||
|
'entries', 'field_count', 'fertilizer_count', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_variety_name(self, obj):
|
||||||
|
return obj.variety.name
|
||||||
|
|
||||||
|
def get_crop_name(self, obj):
|
||||||
|
return obj.variety.crop.name
|
||||||
|
|
||||||
|
def get_field_count(self, obj):
|
||||||
|
return obj.entries.values('field').distinct().count()
|
||||||
|
|
||||||
|
def get_fertilizer_count(self, obj):
|
||||||
|
return obj.entries.values('fertilizer').distinct().count()
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
|
||||||
|
"""保存用(entries を一括で受け取る)"""
|
||||||
|
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FertilizationPlan
|
||||||
|
fields = ['id', 'name', 'year', 'variety', 'entries']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
entries_data = validated_data.pop('entries', [])
|
||||||
|
plan = FertilizationPlan.objects.create(**validated_data)
|
||||||
|
self._save_entries(plan, entries_data)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
entries_data = validated_data.pop('entries', None)
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
instance.save()
|
||||||
|
if entries_data is not None:
|
||||||
|
instance.entries.all().delete()
|
||||||
|
self._save_entries(instance, entries_data)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def _save_entries(self, plan, entries_data):
|
||||||
|
for entry in entries_data:
|
||||||
|
FertilizationEntry.objects.create(
|
||||||
|
plan=plan,
|
||||||
|
field_id=entry['field_id'],
|
||||||
|
fertilizer_id=entry['fertilizer_id'],
|
||||||
|
bags=entry['bags'],
|
||||||
|
)
|
||||||
58
backend/apps/fertilizer/templates/fertilizer/pdf.html
Normal file
58
backend/apps/fertilizer/templates/fertilizer/pdf.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@page { size: A4 landscape; margin: 15mm; }
|
||||||
|
body { font-family: "Noto Sans CJK JP", "Hiragino Kaku Gothic Pro", sans-serif; font-size: 10pt; }
|
||||||
|
h1 { font-size: 14pt; text-align: center; margin-bottom: 4px; }
|
||||||
|
.subtitle { text-align: center; font-size: 10pt; color: #555; margin-bottom: 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
||||||
|
th, td { border: 1px solid #888; padding: 4px 6px; text-align: right; }
|
||||||
|
th { background: #e8f5e9; text-align: center; }
|
||||||
|
.col-name { text-align: left; }
|
||||||
|
.col-area { text-align: right; }
|
||||||
|
tr.total-row { font-weight: bold; background: #f5f5f5; }
|
||||||
|
.zero { color: #bbb; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>施肥計画書</h1>
|
||||||
|
<p class="subtitle">{{ plan.year }}年度 {{ plan.variety.crop.name }} / {{ plan.variety.name }} 「{{ plan.name }}」</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-name">圃場名</th>
|
||||||
|
<th class="col-area">面積(反)</th>
|
||||||
|
{% for fert in fertilizers %}
|
||||||
|
<th>{{ fert.name }}<br><small>(袋)</small></th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>合計袋数</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="col-name">{{ row.field.name }}</td>
|
||||||
|
<td class="col-area">{{ row.field.area_tan }}</td>
|
||||||
|
{% for cell in row.cells %}
|
||||||
|
<td>{% if cell %}{{ cell }}{% else %}<span class="zero">-</span>{% endif %}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td>{{ row.total }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td class="col-name">合計</td>
|
||||||
|
<td></td>
|
||||||
|
{% for total in fert_totals %}
|
||||||
|
<td>{{ total }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td>{{ grand_total }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
backend/apps/fertilizer/urls.py
Normal file
13
backend/apps/fertilizer/urls.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
|
||||||
|
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
path('candidate_fields/', views.CandidateFieldsView.as_view(), name='candidate-fields'),
|
||||||
|
path('calculate/', views.CalculateView.as_view(), name='fertilizer-calculate'),
|
||||||
|
]
|
||||||
196
backend/apps/fertilizer/views.py
Normal file
196
backend/apps/fertilizer/views.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
|
from apps.fields.models import Field
|
||||||
|
from apps.plans.models import Plan, Variety
|
||||||
|
from .models import Fertilizer, FertilizationPlan
|
||||||
|
from .serializers import (
|
||||||
|
FertilizerSerializer,
|
||||||
|
FertilizationPlanSerializer,
|
||||||
|
FertilizationPlanWriteSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizerViewSet(viewsets.ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
queryset = Fertilizer.objects.all()
|
||||||
|
serializer_class = FertilizerSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
|
||||||
|
'entries', 'entries__field', 'entries__fertilizer'
|
||||||
|
)
|
||||||
|
year = self.request.query_params.get('year')
|
||||||
|
if year:
|
||||||
|
qs = qs.filter(year=year)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action in ['create', 'update', 'partial_update']:
|
||||||
|
return FertilizationPlanWriteSerializer
|
||||||
|
return FertilizationPlanSerializer
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def pdf(self, request, pk=None):
|
||||||
|
plan = self.get_object()
|
||||||
|
entries = plan.entries.select_related('field', 'fertilizer').order_by(
|
||||||
|
'field__display_order', 'field__id', 'fertilizer__name'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 圃場・肥料の一覧を整理
|
||||||
|
fields_map = {}
|
||||||
|
fertilizers_map = {}
|
||||||
|
for entry in entries:
|
||||||
|
fields_map[entry.field_id] = entry.field
|
||||||
|
fertilizers_map[entry.fertilizer_id] = entry.fertilizer
|
||||||
|
|
||||||
|
fields = sorted(fields_map.values(), key=lambda f: (f.display_order, f.id))
|
||||||
|
fertilizers = sorted(fertilizers_map.values(), key=lambda f: f.name)
|
||||||
|
|
||||||
|
# マトリクスデータ生成
|
||||||
|
matrix = {}
|
||||||
|
for entry in entries:
|
||||||
|
matrix[(entry.field_id, entry.fertilizer_id)] = entry.bags
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for field in fields:
|
||||||
|
cells = [matrix.get((field.id, fert.id), '') for fert in fertilizers]
|
||||||
|
total = sum(v for v in cells if v != '')
|
||||||
|
rows.append({
|
||||||
|
'field': field,
|
||||||
|
'cells': cells,
|
||||||
|
'total': total,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 肥料ごとの合計
|
||||||
|
fert_totals = []
|
||||||
|
for fert in fertilizers:
|
||||||
|
total = sum(
|
||||||
|
matrix.get((field.id, fert.id), Decimal('0'))
|
||||||
|
for field in fields
|
||||||
|
)
|
||||||
|
fert_totals.append(total)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'plan': plan,
|
||||||
|
'fertilizers': fertilizers,
|
||||||
|
'rows': rows,
|
||||||
|
'fert_totals': fert_totals,
|
||||||
|
'grand_total': sum(fert_totals),
|
||||||
|
}
|
||||||
|
html_string = render_to_string('fertilizer/pdf.html', context)
|
||||||
|
pdf_file = HTML(string=html_string).write_pdf()
|
||||||
|
response = HttpResponse(pdf_file, content_type='application/pdf')
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateFieldsView(APIView):
|
||||||
|
"""作付け計画から圃場候補を返す"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
year = request.query_params.get('year')
|
||||||
|
variety_id = request.query_params.get('variety_id')
|
||||||
|
if not year or not variety_id:
|
||||||
|
return Response({'error': 'year と variety_id が必要です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
field_ids = Plan.objects.filter(
|
||||||
|
year=year, variety_id=variety_id
|
||||||
|
).values_list('field_id', flat=True)
|
||||||
|
|
||||||
|
fields = Field.objects.filter(id__in=field_ids).order_by('display_order', 'id')
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'id': f.id,
|
||||||
|
'name': f.name,
|
||||||
|
'area_tan': str(f.area_tan),
|
||||||
|
'area_m2': f.area_m2,
|
||||||
|
'group_name': f.group_name,
|
||||||
|
}
|
||||||
|
for f in fields
|
||||||
|
]
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculateView(APIView):
|
||||||
|
"""自動計算(保存しない)"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
method = request.data.get('method') # 'nitrogen' | 'even' | 'per_tan'
|
||||||
|
param = request.data.get('param') # 数値パラメータ
|
||||||
|
fertilizer_id = request.data.get('fertilizer_id')
|
||||||
|
field_ids = request.data.get('field_ids', [])
|
||||||
|
|
||||||
|
if not method or param is None or not field_ids:
|
||||||
|
return Response({'error': 'method, param, field_ids が必要です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
param = Decimal(str(param))
|
||||||
|
except InvalidOperation:
|
||||||
|
return Response({'error': 'param は数値で指定してください'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
fields = Field.objects.filter(id__in=field_ids)
|
||||||
|
if not fields.exists():
|
||||||
|
return Response({'error': '圃場が見つかりません'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if method == 'per_tan':
|
||||||
|
# 反当袋数配分: S = Sa × A
|
||||||
|
for field in fields:
|
||||||
|
area = Decimal(str(field.area_tan))
|
||||||
|
bags = (param * area).quantize(Decimal('0.01'))
|
||||||
|
results.append({'field_id': field.id, 'bags': float(bags)})
|
||||||
|
|
||||||
|
elif method == 'even':
|
||||||
|
# 在庫/指定数量均等配分: S = (SS / Sum(A)) × A
|
||||||
|
total_area = sum(Decimal(str(f.area_tan)) for f in fields)
|
||||||
|
if total_area == 0:
|
||||||
|
return Response({'error': '圃場の面積が0です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
for field in fields:
|
||||||
|
area = Decimal(str(field.area_tan))
|
||||||
|
bags = (param * area / total_area).quantize(Decimal('0.01'))
|
||||||
|
results.append({'field_id': field.id, 'bags': float(bags)})
|
||||||
|
|
||||||
|
elif method == 'nitrogen':
|
||||||
|
# 反当チッソ成分量配分: S = (Nr / (C × Nd/100)) × A
|
||||||
|
if not fertilizer_id:
|
||||||
|
return Response({'error': 'nitrogen 方式には fertilizer_id が必要です'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
try:
|
||||||
|
fertilizer = Fertilizer.objects.get(id=fertilizer_id)
|
||||||
|
except Fertilizer.DoesNotExist:
|
||||||
|
return Response({'error': '肥料が見つかりません'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
if not fertilizer.capacity_kg or not fertilizer.nitrogen_pct:
|
||||||
|
return Response(
|
||||||
|
{'error': 'この肥料には1袋重量(kg)と窒素含有率(%)の登録が必要です'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
c = Decimal(str(fertilizer.capacity_kg))
|
||||||
|
nd = Decimal(str(fertilizer.nitrogen_pct))
|
||||||
|
# 1袋あたりの窒素量 (kg)
|
||||||
|
nc = c * nd / Decimal('100')
|
||||||
|
if nc == 0:
|
||||||
|
return Response({'error': '窒素含有量が0のため計算できません'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
for field in fields:
|
||||||
|
area = Decimal(str(field.area_tan))
|
||||||
|
bags = (param / nc * area).quantize(Decimal('0.01'))
|
||||||
|
results.append({'field_id': field.id, 'bags': float(bags)})
|
||||||
|
|
||||||
|
else:
|
||||||
|
return Response({'error': 'method は nitrogen / even / per_tan のいずれかです'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response(results)
|
||||||
@@ -43,6 +43,7 @@ INSTALLED_APPS = [
|
|||||||
'apps.reports',
|
'apps.reports',
|
||||||
'apps.mail',
|
'apps.mail',
|
||||||
'apps.weather',
|
'apps.weather',
|
||||||
|
'apps.fertilizer',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -57,4 +57,5 @@ urlpatterns = [
|
|||||||
path('api/auth/change-password/', ChangePasswordView.as_view(), name='change-password'),
|
path('api/auth/change-password/', ChangePasswordView.as_view(), name='change-password'),
|
||||||
path('api/mail/', include('apps.mail.urls')),
|
path('api/mail/', include('apps.mail.urls')),
|
||||||
path('api/weather/', include('apps.weather.urls')),
|
path('api/weather/', include('apps.weather.urls')),
|
||||||
|
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
## Cursor / Claude Code 連携ガイド
|
|
||||||
|
|
||||||
> **最終更新**: 2026-02-28
|
|
||||||
> **対象**: Cursor(GPT-5.1)と Claude Code の協調運用ルール
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## このドキュメントの目的
|
|
||||||
|
|
||||||
- Cursor と Claude Code を併用する際の **役割分担** と **情報の受け渡し方法** を明文化する。
|
|
||||||
- 両方のエージェントが、このリポジトリ内の同じドキュメントを参照することで、Trilium や外部 MCP に依存せずに進捗・設計情報を共有できるようにする。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 全体方針
|
|
||||||
|
|
||||||
- **設計相談・方針検討** は主に Cursor が担当する。
|
|
||||||
- **具体的な実装・継続作業** は主に Claude Code が担当する。
|
|
||||||
- 両者のあいだで **直接通信は行わず**、このリポジトリ配下の Markdown 文書を介して情報を引き継ぐ。
|
|
||||||
- ユーザーが Trilium に手作業でコピーすることを前提にせず、可能な限り **リポジトリ内ドキュメントの自動更新** で完結させる。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 役割分担
|
|
||||||
|
|
||||||
### Cursor(GPT-5.1)
|
|
||||||
|
|
||||||
- 設計方針や仕様の検討、代替案の比較、設計レビューなどを担当する。
|
|
||||||
- セッション中の議論内容・決定事項・未解決の論点を、以下のドキュメントに **逐次追記・更新** する。
|
|
||||||
- `document/20_Cursor_Claude連携ガイド.md`(このガイド。必要に応じて拡張)
|
|
||||||
- `document/21_Cursor_設計ログ.md`(時系列の詳細ログ、※必要になったら作成)
|
|
||||||
- `document/22_Cursor_からClaudeへの引き継ぎ.md`(Claude Code 向けの要約・ToDo、※必要になったら作成)
|
|
||||||
|
|
||||||
### Claude Code
|
|
||||||
|
|
||||||
- 実際のコード編集、リファクタリング、テスト整備などの「手を動かす」部分を中心に担当する。
|
|
||||||
- 作業前に必ず以下を読む:
|
|
||||||
1. `CLAUDE.md`
|
|
||||||
2. `document/20_Cursor_Claude連携ガイド.md`(このファイル)
|
|
||||||
3. 必要に応じて `document/21_Cursor_設計ログ.md` と `document/22_Cursor_からClaudeへの引き継ぎ.md`
|
|
||||||
- Cursor がまとめた設計方針や前提条件を尊重しつつ、実装詳細を詰める。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 共有ドキュメントと役割
|
|
||||||
|
|
||||||
- **`document/20_Cursor_Claude連携ガイド.md`(本ファイル)**
|
|
||||||
- Cursor / Claude Code の運用ルールそのものを定義する。
|
|
||||||
- 運用方針や役割分担に変更があった場合は、まずここを更新する。
|
|
||||||
|
|
||||||
- **`document/21_Cursor_設計ログ.md`(想定)**
|
|
||||||
- Cursor セッション中の思考過程や議論の詳細を、時系列で残すログ。
|
|
||||||
- 「なぜその設計にしたか」「どの案を捨てたか」など、後から振り返りたい情報を含める。
|
|
||||||
|
|
||||||
- **`document/22_Cursor_からClaudeへの引き継ぎ.md`(想定)**
|
|
||||||
- Claude Code が最初に読むべき、Cursor セッションの要約。
|
|
||||||
- 構成イメージ:
|
|
||||||
- 背景・前提
|
|
||||||
- 決定した方針
|
|
||||||
- 未完了タスク / ToDo
|
|
||||||
- 注意点(制約、やってはいけないこと など)
|
|
||||||
|
|
||||||
実際に `21` や `22` を使い始めるタイミングで、Cursor 側がひな型を作成し、以降はセッションごとに更新していく。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ワークフロー(例)
|
|
||||||
|
|
||||||
1. **Cursor セッション開始**
|
|
||||||
- ユーザーが設計相談や仕様検討を Cursor に依頼する。
|
|
||||||
- 必要に応じてこのガイドや既存ドキュメントを参照しつつ、方針を議論する。
|
|
||||||
|
|
||||||
2. **Cursor による記録**
|
|
||||||
- 議論の結果を `document/21_Cursor_設計ログ.md`(詳細ログ)と
|
|
||||||
`document/22_Cursor_からClaudeへの引き継ぎ.md`(要約・ToDo)に追記・更新する。
|
|
||||||
|
|
||||||
3. **Claude Code への引き継ぎ**
|
|
||||||
- Claude Code に作業を依頼する際、「まず `CLAUDE.md` と `document/22_Cursor_からClaudeへの引き継ぎ.md` を読むこと」と指示する。
|
|
||||||
- Claude Code が詳細を知りたくなった場合は、`document/21_Cursor_設計ログ.md` やその他ドキュメントを参照する。
|
|
||||||
|
|
||||||
4. **Claude Code セッション終了時**
|
|
||||||
- Claude Code 側で重要な設計判断や仕様変更があった場合は、`CLAUDE.md` や関連マスタードキュメント(10〜12など)を更新する。
|
|
||||||
- 必要に応じて、Cursor 側に再相談したい論点を `document/22_Cursor_からClaudeへの引き継ぎ.md` にメモしておく。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 注意点
|
|
||||||
|
|
||||||
- Cursor と Claude Code は **直接通信できない** ため、必ずこのリポジトリ内のドキュメントを介して情報を共有する。
|
|
||||||
- 運用ルールやフローに大きな変更があった場合は、
|
|
||||||
1. 本ガイド(`20_Cursor_Claude連携ガイド.md`)を更新し、
|
|
||||||
2. 必要があれば `CLAUDE.md` の「よくある作業パターン」などからリンクを追加・修正する。
|
|
||||||
|
|
||||||
このファイル自体も、今後の運用の中で少しずつ育てていくことを前提とする。
|
|
||||||
|
|
||||||
5
frontend/src/app/fertilizer/[id]/edit/page.tsx
Normal file
5
frontend/src/app/fertilizer/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import FertilizerEditPage from '../../_components/FertilizerEditPage';
|
||||||
|
|
||||||
|
export default function EditFertilizerPage({ params }: { params: { id: string } }) {
|
||||||
|
return <FertilizerEditPage planId={parseInt(params.id)} />;
|
||||||
|
}
|
||||||
606
frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx
Normal file
606
frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ChevronLeft, Plus, X, Calculator, Save, FileDown } from 'lucide-react';
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Fertilizer, FertilizationPlan, Crop, Field } from '@/types';
|
||||||
|
|
||||||
|
type CalcMethod = 'per_tan' | 'even' | 'nitrogen';
|
||||||
|
|
||||||
|
interface CalcSetting {
|
||||||
|
fertilizer_id: number;
|
||||||
|
method: CalcMethod;
|
||||||
|
param: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// matrix: field_id → fertilizer_id → bags
|
||||||
|
type Matrix = Record<number, Record<number, string>>;
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<CalcMethod, string> = {
|
||||||
|
per_tan: '反当袋数',
|
||||||
|
even: '均等配分',
|
||||||
|
nitrogen: '反当チッソ',
|
||||||
|
};
|
||||||
|
|
||||||
|
const METHOD_UNIT: Record<CalcMethod, string> = {
|
||||||
|
per_tan: '袋/反',
|
||||||
|
even: '袋(総数)',
|
||||||
|
nitrogen: 'kg/反 (N)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isNew = !planId;
|
||||||
|
|
||||||
|
// ─── ヘッダー情報
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [year, setYear] = useState(currentYear);
|
||||||
|
const [varietyId, setVarietyId] = useState<number | ''>('');
|
||||||
|
|
||||||
|
// ─── マスタデータ
|
||||||
|
const [crops, setCrops] = useState<Crop[]>([]);
|
||||||
|
const [allFertilizers, setAllFertilizers] = useState<Fertilizer[]>([]);
|
||||||
|
|
||||||
|
// ─── 圃場
|
||||||
|
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
|
||||||
|
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
|
||||||
|
const [showFieldPicker, setShowFieldPicker] = useState(false);
|
||||||
|
const [allFields, setAllFields] = useState<Field[]>([]);
|
||||||
|
|
||||||
|
// ─── 肥料(計画に使う肥料)
|
||||||
|
const [planFertilizers, setPlanFertilizers] = useState<Fertilizer[]>([]);
|
||||||
|
const [calcSettings, setCalcSettings] = useState<CalcSetting[]>([]);
|
||||||
|
const [showFertPicker, setShowFertPicker] = useState(false);
|
||||||
|
|
||||||
|
// ─── マトリクス(袋数)
|
||||||
|
const [matrix, setMatrix] = useState<Matrix>({});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// ─── 初期データ取得
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
const [cropsRes, fertsRes, fieldsRes] = await Promise.all([
|
||||||
|
api.get('/plans/crops/'),
|
||||||
|
api.get('/fertilizer/fertilizers/'),
|
||||||
|
api.get('/fields/?ordering=display_order,id'),
|
||||||
|
]);
|
||||||
|
setCrops(cropsRes.data);
|
||||||
|
setAllFertilizers(fertsRes.data);
|
||||||
|
setAllFields(fieldsRes.data);
|
||||||
|
|
||||||
|
if (!isNew && planId) {
|
||||||
|
const planRes = await api.get(`/fertilizer/plans/${planId}/`);
|
||||||
|
const plan: FertilizationPlan = planRes.data;
|
||||||
|
setName(plan.name);
|
||||||
|
setYear(plan.year);
|
||||||
|
setVarietyId(plan.variety);
|
||||||
|
|
||||||
|
// エントリからマトリクス・肥料リストを復元
|
||||||
|
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
|
||||||
|
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
|
||||||
|
setPlanFertilizers(ferts);
|
||||||
|
setCalcSettings(ferts.map((f: Fertilizer) => ({ fertilizer_id: f.id, method: 'per_tan' as CalcMethod, param: '' })));
|
||||||
|
|
||||||
|
const fieldIds = Array.from(new Set(plan.entries.map((e) => e.field)));
|
||||||
|
const fields = fieldsRes.data.filter((f: Field) => fieldIds.includes(f.id));
|
||||||
|
setSelectedFields(fields);
|
||||||
|
|
||||||
|
const newMatrix: Matrix = {};
|
||||||
|
plan.entries.forEach((e) => {
|
||||||
|
if (!newMatrix[e.field]) newMatrix[e.field] = {};
|
||||||
|
newMatrix[e.field][e.fertilizer] = String(e.bags);
|
||||||
|
});
|
||||||
|
setMatrix(newMatrix);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { status?: number; data?: unknown } };
|
||||||
|
console.error('初期データ取得エラー:', err);
|
||||||
|
alert(`データの読み込みに失敗しました (${err.response?.status ?? 'network error'})\n${JSON.stringify(err.response?.data ?? '')}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
}, [planId, isNew]);
|
||||||
|
|
||||||
|
// ─── 品種変更時: 候補圃場を取得して selectedFields をリセット
|
||||||
|
const fetchCandidates = useCallback(async (y: number, vId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/candidate_fields/?year=${y}&variety_id=${vId}`);
|
||||||
|
const candidates: Field[] = res.data;
|
||||||
|
setCandidateFields(candidates);
|
||||||
|
// 既存選択を候補で上書き(新規作成時のみ)
|
||||||
|
if (isNew) setSelectedFields(candidates);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}, [isNew]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (varietyId && year) {
|
||||||
|
fetchCandidates(year, varietyId as number);
|
||||||
|
}
|
||||||
|
}, [varietyId, year, fetchCandidates]);
|
||||||
|
|
||||||
|
// ─── 肥料追加
|
||||||
|
const addFertilizer = (fert: Fertilizer) => {
|
||||||
|
if (planFertilizers.find((f) => f.id === fert.id)) return;
|
||||||
|
setPlanFertilizers((prev) => [...prev, fert]);
|
||||||
|
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
|
||||||
|
setShowFertPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFertilizer = (id: number) => {
|
||||||
|
if (!confirm('この肥料を計画から削除しますか?')) return;
|
||||||
|
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
|
||||||
|
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
|
||||||
|
setMatrix((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
Object.keys(next).forEach((fid) => {
|
||||||
|
const row = { ...next[Number(fid)] };
|
||||||
|
delete row[id];
|
||||||
|
next[Number(fid)] = row;
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 圃場追加・削除
|
||||||
|
const addField = (field: Field) => {
|
||||||
|
if (selectedFields.find((f) => f.id === field.id)) return;
|
||||||
|
setSelectedFields((prev) => [...prev, field]);
|
||||||
|
setShowFieldPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeField = (id: number) => {
|
||||||
|
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
|
||||||
|
setMatrix((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 自動計算
|
||||||
|
const runCalc = async (setting: CalcSetting) => {
|
||||||
|
if (!setting.param) return alert('パラメータを入力してください');
|
||||||
|
if (selectedFields.length === 0) return alert('対象圃場を選択してください');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post('/fertilizer/calculate/', {
|
||||||
|
method: setting.method,
|
||||||
|
param: parseFloat(setting.param),
|
||||||
|
fertilizer_id: setting.fertilizer_id,
|
||||||
|
field_ids: selectedFields.map((f) => f.id),
|
||||||
|
});
|
||||||
|
const results: { field_id: number; bags: number }[] = res.data;
|
||||||
|
setMatrix((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
results.forEach(({ field_id, bags }) => {
|
||||||
|
if (!next[field_id]) next[field_id] = {};
|
||||||
|
next[field_id][setting.fertilizer_id] = String(bags);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
alert(err.response?.data?.error ?? '計算に失敗しました');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCalcSetting = (fertId: number, key: keyof CalcSetting, value: string) => {
|
||||||
|
setCalcSettings((prev) =>
|
||||||
|
prev.map((s) => (s.fertilizer_id === fertId ? { ...s, [key]: value } : s))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── セル更新
|
||||||
|
const updateCell = (fieldId: number, fertId: number, value: string) => {
|
||||||
|
setMatrix((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (!next[fieldId]) next[fieldId] = {};
|
||||||
|
next[fieldId][fertId] = value;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 保存
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) return alert('計画名を入力してください');
|
||||||
|
if (!varietyId) return alert('品種を選択してください');
|
||||||
|
if (selectedFields.length === 0) return alert('圃場を1つ以上選択してください');
|
||||||
|
|
||||||
|
const entries: { field_id: number; fertilizer_id: number; bags: number }[] = [];
|
||||||
|
selectedFields.forEach((field) => {
|
||||||
|
planFertilizers.forEach((fert) => {
|
||||||
|
const v = matrix[field.id]?.[fert.id];
|
||||||
|
if (v && parseFloat(v) > 0) {
|
||||||
|
entries.push({ field_id: field.id, fertilizer_id: fert.id, bags: parseFloat(v) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = { name, year, variety: varietyId, entries };
|
||||||
|
if (isNew) {
|
||||||
|
await api.post('/fertilizer/plans/', payload);
|
||||||
|
} else {
|
||||||
|
await api.put(`/fertilizer/plans/${planId}/`, payload);
|
||||||
|
}
|
||||||
|
router.push('/fertilizer');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: unknown } };
|
||||||
|
console.error(err);
|
||||||
|
alert('保存に失敗しました: ' + JSON.stringify(err.response?.data));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── PDF出力
|
||||||
|
const handlePdf = async () => {
|
||||||
|
if (!planId) return;
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/plans/${planId}/pdf/`, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `施肥計画_${year}_${name}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
alert('PDF出力に失敗しました');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 集計
|
||||||
|
const rowTotal = (fieldId: number) =>
|
||||||
|
planFertilizers.reduce((sum, f) => {
|
||||||
|
const v = parseFloat(matrix[fieldId]?.[f.id] ?? '0') || 0;
|
||||||
|
return sum + v;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const colTotal = (fertId: number) =>
|
||||||
|
selectedFields.reduce((sum, f) => {
|
||||||
|
const v = parseFloat(matrix[f.id]?.[fertId] ?? '0') || 0;
|
||||||
|
return sum + v;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 0);
|
||||||
|
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||||||
|
const availableFerts = allFertilizers.filter((f) => !planFertilizers.find((pf) => pf.id === f.id));
|
||||||
|
const unselectedFields = allFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8 text-gray-500">読み込み中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
{/* ヘッダー */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">
|
||||||
|
{isNew ? '施肥計画 新規作成' : '施肥計画 編集'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isNew && (
|
||||||
|
<button
|
||||||
|
onClick={handlePdf}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<FileDown className="h-4 w-4" />
|
||||||
|
PDF出力
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 基本情報 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||||||
|
<div className="flex-1 min-w-48">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">計画名</label>
|
||||||
|
<input
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="例: 2025年度 コシヒカリ 元肥"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">年度</label>
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
{years.map((y) => <option key={y} value={y}>{y}年度</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-48">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 mb-1">品種</label>
|
||||||
|
<select
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
value={varietyId}
|
||||||
|
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
|
||||||
|
>
|
||||||
|
<option value="">品種を選択</option>
|
||||||
|
{crops.map((crop) => (
|
||||||
|
<optgroup key={crop.id} label={crop.name}>
|
||||||
|
{crop.varieties.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>{v.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 対象圃場 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700">
|
||||||
|
対象圃場
|
||||||
|
<span className="ml-2 text-gray-400 font-normal">
|
||||||
|
{selectedFields.length}筆 /
|
||||||
|
{selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)}反
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFieldPicker(true)}
|
||||||
|
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />圃場を追加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedFields.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
品種を選択すると作付け計画から圃場が自動抽出されます
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{selectedFields.map((f) => (
|
||||||
|
<span
|
||||||
|
key={f.id}
|
||||||
|
className="flex items-center gap-1 bg-green-50 border border-green-200 rounded-full px-3 py-1 text-xs text-green-800"
|
||||||
|
>
|
||||||
|
{f.name}({f.area_tan}反)
|
||||||
|
<button onClick={() => removeField(f.id)} className="text-green-400 hover:text-red-500">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自動計算設定パネル */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700">自動計算設定</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFertPicker(true)}
|
||||||
|
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />肥料を追加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{planFertilizers.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">肥料を追加してください</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{planFertilizers.map((fert) => {
|
||||||
|
const setting = calcSettings.find((s) => s.fertilizer_id === fert.id);
|
||||||
|
if (!setting) return null;
|
||||||
|
return (
|
||||||
|
<div key={fert.id} className="flex items-center gap-3 py-2 border-b last:border-b-0">
|
||||||
|
<span className="font-medium text-sm w-40 truncate" title={fert.name}>
|
||||||
|
{fert.name}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||||
|
value={setting.method}
|
||||||
|
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(METHOD_LABELS).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="border border-gray-300 rounded px-2 py-1 text-xs w-24 text-right"
|
||||||
|
value={setting.param}
|
||||||
|
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
|
||||||
|
placeholder="値"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => runCalc(setting)}
|
||||||
|
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100"
|
||||||
|
>
|
||||||
|
<Calculator className="h-3 w-3" />計算
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFertilizer(fert.id)}
|
||||||
|
className="ml-auto text-gray-300 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* マトリクス表 */}
|
||||||
|
{selectedFields.length > 0 && planFertilizers.length > 0 && (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">圃場名</th>
|
||||||
|
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">面積(反)</th>
|
||||||
|
{planFertilizers.map((f) => (
|
||||||
|
<th key={f.id} className="text-center px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">
|
||||||
|
{f.name}
|
||||||
|
<span className="block text-xs font-normal text-gray-400">(袋)</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">合計袋数</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{selectedFields.map((field) => (
|
||||||
|
<tr key={field.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2 border border-gray-200 whitespace-nowrap">{field.name}</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200 text-right text-gray-600">{field.area_tan}</td>
|
||||||
|
{planFertilizers.map((fert) => (
|
||||||
|
<td key={fert.id} className="px-2 py-1 border border-gray-200">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="w-full text-right border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-green-400 rounded px-1 py-1"
|
||||||
|
value={matrix[field.id]?.[fert.id] ?? ''}
|
||||||
|
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
||||||
|
placeholder="-"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-3 py-2 border border-gray-200 text-right font-medium">
|
||||||
|
{rowTotal(field.id) > 0 ? rowTotal(field.id).toFixed(2) : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="bg-gray-50 font-semibold">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-2 border border-gray-200">合計</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200 text-right text-gray-500">
|
||||||
|
{selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
{planFertilizers.map((f) => (
|
||||||
|
<td key={f.id} className="px-3 py-2 border border-gray-200 text-right">
|
||||||
|
{colTotal(f.id) > 0 ? colTotal(f.id).toFixed(2) : '-'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-3 py-2 border border-gray-200 text-right text-green-700">
|
||||||
|
{grandTotal > 0 ? grandTotal.toFixed(2) : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 圃場選択ピッカー */}
|
||||||
|
{showFieldPicker && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<h3 className="font-bold">圃場を追加</h3>
|
||||||
|
<button onClick={() => setShowFieldPicker(false)}><X className="h-5 w-5 text-gray-400" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1 p-2">
|
||||||
|
{candidateFields.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-gray-500 px-2 py-1">作付け計画から({year}年度 / 選択品種)</p>
|
||||||
|
{candidateFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id)).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => addField(f)}
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between"
|
||||||
|
>
|
||||||
|
<span>{f.name}</span>
|
||||||
|
<span className="text-gray-400">{f.area_tan}反</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<hr className="my-2" />
|
||||||
|
<p className="text-xs text-gray-500 px-2 py-1">その他の圃場</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{unselectedFields.filter((f) => !candidateFields.find((cf) => cf.id === f.id)).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => addField(f)}
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between"
|
||||||
|
>
|
||||||
|
<span>{f.name}</span>
|
||||||
|
<span className="text-gray-400">{f.area_tan}反</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 肥料選択ピッカー */}
|
||||||
|
{showFertPicker && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<h3 className="font-bold">肥料を追加</h3>
|
||||||
|
<button onClick={() => setShowFertPicker(false)}><X className="h-5 w-5 text-gray-400" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1 p-2">
|
||||||
|
{availableFerts.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 px-3 py-4">追加できる肥料がありません</p>
|
||||||
|
) : (
|
||||||
|
availableFerts.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => addFertilizer(f)}
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{f.name}</span>
|
||||||
|
{f.maker && <span className="ml-2 text-gray-400 text-xs">{f.maker}</span>}
|
||||||
|
{f.nitrogen_pct && (
|
||||||
|
<span className="ml-2 text-blue-500 text-xs">N:{f.nitrogen_pct}%</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div className="border-t mt-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowFertPicker(false); router.push('/fertilizer/masters'); }}
|
||||||
|
className="w-full text-left px-3 py-2 text-xs text-green-600 hover:bg-green-50 rounded"
|
||||||
|
>
|
||||||
|
+ 新しい肥料をマスタに登録する
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
frontend/src/app/fertilizer/masters/page.tsx
Normal file
316
frontend/src/app/fertilizer/masters/page.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Plus, Pencil, Trash2, ChevronLeft, Check, X } from 'lucide-react';
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Fertilizer } from '@/types';
|
||||||
|
|
||||||
|
const emptyForm = (): Omit<Fertilizer, 'id'> => ({
|
||||||
|
name: '',
|
||||||
|
maker: null,
|
||||||
|
capacity_kg: null,
|
||||||
|
nitrogen_pct: null,
|
||||||
|
phosphorus_pct: null,
|
||||||
|
potassium_pct: null,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function FertilizerMastersPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [fertilizers, setFertilizers] = useState<Fertilizer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
|
||||||
|
const [form, setForm] = useState(emptyForm());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFertilizers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchFertilizers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/fertilizer/fertilizers/');
|
||||||
|
setFertilizers(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startNew = () => {
|
||||||
|
setForm(emptyForm());
|
||||||
|
setEditingId('new');
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (f: Fertilizer) => {
|
||||||
|
setForm({
|
||||||
|
name: f.name,
|
||||||
|
maker: f.maker,
|
||||||
|
capacity_kg: f.capacity_kg,
|
||||||
|
nitrogen_pct: f.nitrogen_pct,
|
||||||
|
phosphorus_pct: f.phosphorus_pct,
|
||||||
|
potassium_pct: f.potassium_pct,
|
||||||
|
notes: f.notes,
|
||||||
|
});
|
||||||
|
setEditingId(f.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim()) return alert('肥料名を入力してください');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
maker: form.maker || null,
|
||||||
|
capacity_kg: form.capacity_kg || null,
|
||||||
|
nitrogen_pct: form.nitrogen_pct || null,
|
||||||
|
phosphorus_pct: form.phosphorus_pct || null,
|
||||||
|
potassium_pct: form.potassium_pct || null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
};
|
||||||
|
if (editingId === 'new') {
|
||||||
|
await api.post('/fertilizer/fertilizers/', payload);
|
||||||
|
} else {
|
||||||
|
await api.put(`/fertilizer/fertilizers/${editingId}/`, payload);
|
||||||
|
}
|
||||||
|
await fetchFertilizers();
|
||||||
|
setEditingId(null);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: unknown } };
|
||||||
|
console.error(err);
|
||||||
|
alert('保存に失敗しました: ' + JSON.stringify(err.response?.data));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number, name: string) => {
|
||||||
|
if (!confirm(`「${name}」を削除しますか?`)) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/fertilizer/fertilizers/${id}/`);
|
||||||
|
await fetchFertilizers();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('削除に失敗しました(施肥計画で使用中の肥料は削除できません)');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setField = (key: keyof typeof form, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value || null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer')}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">肥料マスタ</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={startNew}
|
||||||
|
disabled={editingId !== null}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新規追加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500">読み込み中...</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-700">肥料名</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-700">メーカー</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-gray-700">1袋(kg)</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-gray-700">窒素(%)</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-gray-700">リン酸(%)</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-gray-700">カリ(%)</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-700">備考</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{editingId === 'new' && (
|
||||||
|
<EditRow
|
||||||
|
form={form}
|
||||||
|
setField={setField}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={cancelEdit}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{fertilizers.map((f) =>
|
||||||
|
editingId === f.id ? (
|
||||||
|
<EditRow
|
||||||
|
key={f.id}
|
||||||
|
form={form}
|
||||||
|
setField={setField}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={cancelEdit}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<tr key={f.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium">{f.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">{f.maker ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{f.capacity_kg ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{f.nitrogen_pct ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{f.phosphorus_pct ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{f.potassium_pct ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 max-w-xs truncate">{f.notes ?? '-'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(f)}
|
||||||
|
disabled={editingId !== null}
|
||||||
|
className="text-gray-400 hover:text-blue-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(f.id, f.name)}
|
||||||
|
disabled={editingId !== null}
|
||||||
|
className="text-gray-400 hover:text-red-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{fertilizers.length === 0 && editingId === null && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||||
|
肥料が登録されていません
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditRow({
|
||||||
|
form,
|
||||||
|
setField,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
form: Omit<Fertilizer, 'id'>;
|
||||||
|
setField: (key: keyof Omit<Fertilizer, 'id'>, value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
|
const inputCls = 'w-full border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500';
|
||||||
|
const numCls = inputCls + ' text-right';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="bg-green-50">
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setField('name', e.target.value)}
|
||||||
|
placeholder="肥料名(必須)"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={form.maker ?? ''}
|
||||||
|
onChange={(e) => setField('maker', e.target.value)}
|
||||||
|
placeholder="メーカー"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={numCls}
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
value={form.capacity_kg ?? ''}
|
||||||
|
onChange={(e) => setField('capacity_kg', e.target.value)}
|
||||||
|
placeholder="kg"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={numCls}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.nitrogen_pct ?? ''}
|
||||||
|
onChange={(e) => setField('nitrogen_pct', e.target.value)}
|
||||||
|
placeholder="%"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={numCls}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.phosphorus_pct ?? ''}
|
||||||
|
onChange={(e) => setField('phosphorus_pct', e.target.value)}
|
||||||
|
placeholder="%"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={numCls}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.potassium_pct ?? ''}
|
||||||
|
onChange={(e) => setField('potassium_pct', e.target.value)}
|
||||||
|
placeholder="%"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={form.notes ?? ''}
|
||||||
|
onChange={(e) => setField('notes', e.target.value)}
|
||||||
|
placeholder="備考"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-green-600 hover:text-green-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/app/fertilizer/new/page.tsx
Normal file
5
frontend/src/app/fertilizer/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import FertilizerEditPage from '../_components/FertilizerEditPage';
|
||||||
|
|
||||||
|
export default function NewFertilizerPage() {
|
||||||
|
return <FertilizerEditPage />;
|
||||||
|
}
|
||||||
177
frontend/src/app/fertilizer/page.tsx
Normal file
177
frontend/src/app/fertilizer/page.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Plus, Pencil, Trash2, FileDown, Sprout } from 'lucide-react';
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { FertilizationPlan } from '@/types';
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
export default function FertilizerPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [year, setYear] = useState<number>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem('fertilizerYear');
|
||||||
|
if (saved) return parseInt(saved);
|
||||||
|
}
|
||||||
|
return currentYear;
|
||||||
|
});
|
||||||
|
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('fertilizerYear', String(year));
|
||||||
|
fetchPlans();
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/plans/?year=${year}`);
|
||||||
|
setPlans(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number, name: string) => {
|
||||||
|
if (!confirm(`「${name}」を削除しますか?`)) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/fertilizer/plans/${id}/`);
|
||||||
|
await fetchPlans();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('削除に失敗しました');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePdf = async (id: number, name: string) => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `施肥計画_${year}_${name}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('PDF出力に失敗しました');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Sprout className="h-6 w-6 text-green-600" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">施肥計画</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer/masters')}
|
||||||
|
className="px-4 py-2 text-sm border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
肥料マスタ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer/new')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新規作成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 年度セレクタ */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||||
|
<select
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||||
|
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>{y}年度</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500">読み込み中...</p>
|
||||||
|
) : plans.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg shadow p-12 text-center text-gray-400">
|
||||||
|
<Sprout className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>{year}年度の施肥計画はありません</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer/new')}
|
||||||
|
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
|
||||||
|
>
|
||||||
|
最初の計画を作成する
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-700">計画名</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-700">作物 / 品種</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-gray-700">圃場数</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-gray-700">肥料種数</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<tr key={plan.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium">{plan.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">
|
||||||
|
{plan.crop_name} / {plan.variety_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}筆</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}種</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePdf(plan.id, plan.name)}
|
||||||
|
className="text-gray-400 hover:text-blue-600"
|
||||||
|
title="PDF出力"
|
||||||
|
>
|
||||||
|
<FileDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/fertilizer/${plan.id}/edit`)}
|
||||||
|
className="text-gray-400 hover:text-green-600"
|
||||||
|
title="編集"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(plan.id, plan.name)}
|
||||||
|
className="text-gray-400 hover:text-red-600"
|
||||||
|
title="削除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud } from 'lucide-react';
|
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout } from 'lucide-react';
|
||||||
import { logout } from '@/lib/api';
|
import { logout } from '@/lib/api';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -111,6 +111,17 @@ export default function Navbar() {
|
|||||||
<Cloud className="h-4 w-4 mr-2" />
|
<Cloud className="h-4 w-4 mr-2" />
|
||||||
気象
|
気象
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/fertilizer')}
|
||||||
|
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
|
pathname?.startsWith('/fertilizer')
|
||||||
|
? '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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
|
|||||||
@@ -56,6 +56,41 @@ export interface Plan {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Fertilizer {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
maker: string | null;
|
||||||
|
capacity_kg: string | null;
|
||||||
|
nitrogen_pct: string | null;
|
||||||
|
phosphorus_pct: string | null;
|
||||||
|
potassium_pct: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FertilizationEntry {
|
||||||
|
id?: number;
|
||||||
|
field: number;
|
||||||
|
field_name?: string;
|
||||||
|
field_area_tan?: string;
|
||||||
|
fertilizer: number;
|
||||||
|
fertilizer_name?: string;
|
||||||
|
bags: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FertilizationPlan {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
variety: number;
|
||||||
|
variety_name: string;
|
||||||
|
crop_name: string;
|
||||||
|
entries: FertilizationEntry[];
|
||||||
|
field_count: number;
|
||||||
|
fertilizer_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MailSender {
|
export interface MailSender {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'address' | 'domain';
|
type: 'address' | 'domain';
|
||||||
|
|||||||
Reference in New Issue
Block a user