diff --git a/.cursor/rules/30_Cursorガイド.md b/.cursor/rules/30_Cursorガイド.md deleted file mode 100644 index 4b64fc3..0000000 --- a/.cursor/rules/30_Cursorガイド.md +++ /dev/null @@ -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` にも必要な範囲で反映する。 - diff --git a/.cursor/rules/cursor-agent.mdc b/.cursor/rules/cursor-agent.mdc deleted file mode 100644 index 188d308..0000000 --- a/.cursor/rules/cursor-agent.mdc +++ /dev/null @@ -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 に何を伝えるべきか」の資料として参照する。 - diff --git a/CLAUDE.md b/CLAUDE.md index 654cc1f..30996ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,6 +171,25 @@ WeatherRecord (日次気象記録) └── pressure_min (最低気圧hPa) ※ 観測地点: 窪川 (lat=33.213, lon=133.133)、データソース: Open-Meteo archive API ※ 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) - **将来計画**: 開花・収穫予測(品種ごとの目標GDD設定 → 到達日予測) - マスタードキュメント: `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//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-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` diff --git a/backend/apps/fertilizer/__init__.py b/backend/apps/fertilizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/fertilizer/admin.py b/backend/apps/fertilizer/admin.py new file mode 100644 index 0000000..12503c4 --- /dev/null +++ b/backend/apps/fertilizer/admin.py @@ -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] diff --git a/backend/apps/fertilizer/apps.py b/backend/apps/fertilizer/apps.py new file mode 100644 index 0000000..441682c --- /dev/null +++ b/backend/apps/fertilizer/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class FertilizerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.fertilizer' + verbose_name = '施肥計画' diff --git a/backend/apps/fertilizer/migrations/0001_initial.py b/backend/apps/fertilizer/migrations/0001_initial.py new file mode 100644 index 0000000..60f90f2 --- /dev/null +++ b/backend/apps/fertilizer/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/backend/apps/fertilizer/migrations/__init__.py b/backend/apps/fertilizer/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/fertilizer/models.py b/backend/apps/fertilizer/models.py new file mode 100644 index 0000000..3a5ffee --- /dev/null +++ b/backend/apps/fertilizer/models.py @@ -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}袋" diff --git a/backend/apps/fertilizer/serializers.py b/backend/apps/fertilizer/serializers.py new file mode 100644 index 0000000..538a038 --- /dev/null +++ b/backend/apps/fertilizer/serializers.py @@ -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'], + ) diff --git a/backend/apps/fertilizer/templates/fertilizer/pdf.html b/backend/apps/fertilizer/templates/fertilizer/pdf.html new file mode 100644 index 0000000..fa0849c --- /dev/null +++ b/backend/apps/fertilizer/templates/fertilizer/pdf.html @@ -0,0 +1,58 @@ + + + + + + + +

施肥計画書

+

{{ plan.year }}年度 {{ plan.variety.crop.name }} / {{ plan.variety.name }} 「{{ plan.name }}」

+ + + + + + + {% for fert in fertilizers %} + + {% endfor %} + + + + + {% for row in rows %} + + + + {% for cell in row.cells %} + + {% endfor %} + + + {% endfor %} + + + + + + {% for total in fert_totals %} + + {% endfor %} + + + +
圃場名面積(反){{ fert.name }}
(袋)
合計袋数
{{ row.field.name }}{{ row.field.area_tan }}{% if cell %}{{ cell }}{% else %}-{% endif %}{{ row.total }}
合計{{ total }}{{ grand_total }}
+ + diff --git a/backend/apps/fertilizer/urls.py b/backend/apps/fertilizer/urls.py new file mode 100644 index 0000000..27ffd9b --- /dev/null +++ b/backend/apps/fertilizer/urls.py @@ -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'), +] diff --git a/backend/apps/fertilizer/views.py b/backend/apps/fertilizer/views.py new file mode 100644 index 0000000..0ab310e --- /dev/null +++ b/backend/apps/fertilizer/views.py @@ -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) diff --git a/backend/keinasystem/settings.py b/backend/keinasystem/settings.py index e969a68..aff8d1f 100644 --- a/backend/keinasystem/settings.py +++ b/backend/keinasystem/settings.py @@ -43,6 +43,7 @@ INSTALLED_APPS = [ 'apps.reports', 'apps.mail', 'apps.weather', + 'apps.fertilizer', ] MIDDLEWARE = [ diff --git a/backend/keinasystem/urls.py b/backend/keinasystem/urls.py index 4d7a81b..c52caaf 100644 --- a/backend/keinasystem/urls.py +++ b/backend/keinasystem/urls.py @@ -57,4 +57,5 @@ urlpatterns = [ path('api/auth/change-password/', ChangePasswordView.as_view(), name='change-password'), path('api/mail/', include('apps.mail.urls')), path('api/weather/', include('apps.weather.urls')), + path('api/fertilizer/', include('apps.fertilizer.urls')), ] diff --git a/document/20_Cursor_Claude連携ガイド.md b/document/20_Cursor_Claude連携ガイド.md deleted file mode 100644 index ecf7656..0000000 --- a/document/20_Cursor_Claude連携ガイド.md +++ /dev/null @@ -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` の「よくある作業パターン」などからリンクを追加・修正する。 - -このファイル自体も、今後の運用の中で少しずつ育てていくことを前提とする。 - diff --git a/frontend/src/app/fertilizer/[id]/edit/page.tsx b/frontend/src/app/fertilizer/[id]/edit/page.tsx new file mode 100644 index 0000000..89bcc73 --- /dev/null +++ b/frontend/src/app/fertilizer/[id]/edit/page.tsx @@ -0,0 +1,5 @@ +import FertilizerEditPage from '../../_components/FertilizerEditPage'; + +export default function EditFertilizerPage({ params }: { params: { id: string } }) { + return ; +} diff --git a/frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx b/frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx new file mode 100644 index 0000000..ab85a77 --- /dev/null +++ b/frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx @@ -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>; + +const METHOD_LABELS: Record = { + per_tan: '反当袋数', + even: '均等配分', + nitrogen: '反当チッソ', +}; + +const METHOD_UNIT: Record = { + 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(''); + + // ─── マスタデータ + const [crops, setCrops] = useState([]); + const [allFertilizers, setAllFertilizers] = useState([]); + + // ─── 圃場 + const [selectedFields, setSelectedFields] = useState([]); + const [candidateFields, setCandidateFields] = useState([]); + const [showFieldPicker, setShowFieldPicker] = useState(false); + const [allFields, setAllFields] = useState([]); + + // ─── 肥料(計画に使う肥料) + const [planFertilizers, setPlanFertilizers] = useState([]); + const [calcSettings, setCalcSettings] = useState([]); + const [showFertPicker, setShowFertPicker] = useState(false); + + // ─── マトリクス(袋数) + const [matrix, setMatrix] = useState({}); + + 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 ( +
+ +
読み込み中...
+
+ ); + } + + return ( +
+ +
+ {/* ヘッダー */} +
+
+ +

+ {isNew ? '施肥計画 新規作成' : '施肥計画 編集'} +

+
+
+ {!isNew && ( + + )} + +
+
+ + {/* 基本情報 */} +
+
+ + setName(e.target.value)} + placeholder="例: 2025年度 コシヒカリ 元肥" + /> +
+
+ + +
+
+ + +
+
+ + {/* 対象圃場 */} +
+
+

+ 対象圃場 + + {selectedFields.length}筆 / + {selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)}反 + +

+ +
+
+ {selectedFields.length === 0 && ( +

+ 品種を選択すると作付け計画から圃場が自動抽出されます +

+ )} + {selectedFields.map((f) => ( + + {f.name}({f.area_tan}反) + + + ))} +
+
+ + {/* 自動計算設定パネル */} +
+
+

自動計算設定

+ +
+ {planFertilizers.length === 0 ? ( +

肥料を追加してください

+ ) : ( +
+ {planFertilizers.map((fert) => { + const setting = calcSettings.find((s) => s.fertilizer_id === fert.id); + if (!setting) return null; + return ( +
+ + {fert.name} + + + updateCalcSetting(fert.id, 'param', e.target.value)} + placeholder="値" + /> + {METHOD_UNIT[setting.method]} + + +
+ ); + })} +
+ )} +
+ + {/* マトリクス表 */} + {selectedFields.length > 0 && planFertilizers.length > 0 && ( +
+ + + + + + {planFertilizers.map((f) => ( + + ))} + + + + + {selectedFields.map((field) => ( + + + + {planFertilizers.map((fert) => ( + + ))} + + + ))} + + + + + + {planFertilizers.map((f) => ( + + ))} + + + +
圃場名面積(反) + {f.name} + (袋) + 合計袋数
{field.name}{field.area_tan} + updateCell(field.id, fert.id, e.target.value)} + placeholder="-" + /> + + {rowTotal(field.id) > 0 ? rowTotal(field.id).toFixed(2) : '-'} +
合計 + {selectedFields.reduce((s, f) => s + parseFloat(f.area_tan), 0).toFixed(2)} + + {colTotal(f.id) > 0 ? colTotal(f.id).toFixed(2) : '-'} + + {grandTotal > 0 ? grandTotal.toFixed(2) : '-'} +
+
+ )} +
+ + {/* 圃場選択ピッカー */} + {showFieldPicker && ( +
+
+
+

圃場を追加

+ +
+
+ {candidateFields.length > 0 && ( + <> +

作付け計画から({year}年度 / 選択品種)

+ {candidateFields.filter((f) => !selectedFields.find((sf) => sf.id === f.id)).map((f) => ( + + ))} +
+

その他の圃場

+ + )} + {unselectedFields.filter((f) => !candidateFields.find((cf) => cf.id === f.id)).map((f) => ( + + ))} +
+
+
+ )} + + {/* 肥料選択ピッカー */} + {showFertPicker && ( +
+
+
+

肥料を追加

+ +
+
+ {availableFerts.length === 0 ? ( +

追加できる肥料がありません

+ ) : ( + availableFerts.map((f) => ( + + )) + )} +
+ +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/fertilizer/masters/page.tsx b/frontend/src/app/fertilizer/masters/page.tsx new file mode 100644 index 0000000..a9ef22c --- /dev/null +++ b/frontend/src/app/fertilizer/masters/page.tsx @@ -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 => ({ + 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([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(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 ( +
+ +
+
+
+ +

肥料マスタ

+
+ +
+ + {loading ? ( +

読み込み中...

+ ) : ( +
+ + + + + + + + + + + + + + + {editingId === 'new' && ( + + )} + {fertilizers.map((f) => + editingId === f.id ? ( + + ) : ( + + + + + + + + + + + ) + )} + {fertilizers.length === 0 && editingId === null && ( + + + + )} + +
肥料名メーカー1袋(kg)窒素(%)リン酸(%)カリ(%)備考
{f.name}{f.maker ?? '-'}{f.capacity_kg ?? '-'}{f.nitrogen_pct ?? '-'}{f.phosphorus_pct ?? '-'}{f.potassium_pct ?? '-'}{f.notes ?? '-'} +
+ + +
+
+ 肥料が登録されていません +
+
+ )} +
+
+ ); +} + +function EditRow({ + form, + setField, + onSave, + onCancel, + saving, +}: { + form: Omit; + setField: (key: keyof Omit, 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 ( + + + setField('name', e.target.value)} + placeholder="肥料名(必須)" + autoFocus + /> + + + setField('maker', e.target.value)} + placeholder="メーカー" + /> + + + setField('capacity_kg', e.target.value)} + placeholder="kg" + /> + + + setField('nitrogen_pct', e.target.value)} + placeholder="%" + /> + + + setField('phosphorus_pct', e.target.value)} + placeholder="%" + /> + + + setField('potassium_pct', e.target.value)} + placeholder="%" + /> + + + setField('notes', e.target.value)} + placeholder="備考" + /> + + +
+ + +
+ + + ); +} diff --git a/frontend/src/app/fertilizer/new/page.tsx b/frontend/src/app/fertilizer/new/page.tsx new file mode 100644 index 0000000..98d1062 --- /dev/null +++ b/frontend/src/app/fertilizer/new/page.tsx @@ -0,0 +1,5 @@ +import FertilizerEditPage from '../_components/FertilizerEditPage'; + +export default function NewFertilizerPage() { + return ; +} diff --git a/frontend/src/app/fertilizer/page.tsx b/frontend/src/app/fertilizer/page.tsx new file mode 100644 index 0000000..7e98a63 --- /dev/null +++ b/frontend/src/app/fertilizer/page.tsx @@ -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(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('fertilizerYear'); + if (saved) return parseInt(saved); + } + return currentYear; + }); + const [plans, setPlans] = useState([]); + 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 ( +
+ +
+
+
+ +

施肥計画

+
+
+ + +
+
+ + {/* 年度セレクタ */} +
+ + +
+ + {loading ? ( +

読み込み中...

+ ) : plans.length === 0 ? ( +
+ +

{year}年度の施肥計画はありません

+ +
+ ) : ( +
+ + + + + + + + + + + + {plans.map((plan) => ( + + + + + + + + ))} + +
計画名作物 / 品種圃場数肥料種数
{plan.name} + {plan.crop_name} / {plan.variety_name} + {plan.field_count}筆{plan.fertilizer_count}種 +
+ + + +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 832a27c..26fcbf9 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,7 +1,7 @@ 'use client'; 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'; export default function Navbar() { @@ -111,6 +111,17 @@ export default function Navbar() { 気象 +
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 894d67d..5155a9a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -56,6 +56,41 @@ export interface Plan { 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 { id: number; type: 'address' | 'domain';