From 466eef128c2c59144b895b15c54c90f1acacaf4d Mon Sep 17 00:00:00 2001 From: Akira Date: Mon, 2 Mar 2026 09:43:20 +0900 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E9=85=8D=E8=A8=88=E7=94=BB=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 施肥計画の圃場を配置場所単位でグループ化し、グループ×肥料の集計表を 表示・PDF出力できる機能を追加。 - Backend: DistributionPlan/Group/GroupField モデル (migration 0003) - API: GET/POST/PUT/DELETE/PDF (/api/fertilizer/distribution/) - Frontend: 一覧・新規作成・編集画面 (/distribution) - Navbar に分配計画メニューを追加 - 集計プレビューはクライアントサイド計算(API不要) Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 28 +- backend/apps/fertilizer/admin.py | 26 +- ...ributionplan_distributiongroup_and_more.py | 60 ++ backend/apps/fertilizer/models.py | 61 ++ backend/apps/fertilizer/serializers.py | 139 +++- .../fertilizer/distribution_pdf.html | 74 ++ backend/apps/fertilizer/urls.py | 1 + backend/apps/fertilizer/views.py | 130 +++- .../14_マスタードキュメント_分配計画編.md | 241 +++++++ .../src/app/distribution/[id]/edit/page.tsx | 5 + .../_components/DistributionEditPage.tsx | 651 ++++++++++++++++++ frontend/src/app/distribution/new/page.tsx | 5 + frontend/src/app/distribution/page.tsx | 180 +++++ frontend/src/components/Navbar.tsx | 13 +- frontend/src/types/index.ts | 47 ++ 15 files changed, 1656 insertions(+), 5 deletions(-) create mode 100644 backend/apps/fertilizer/migrations/0003_distributionplan_distributiongroup_and_more.py create mode 100644 backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html create mode 100644 document/14_マスタードキュメント_分配計画編.md create mode 100644 frontend/src/app/distribution/[id]/edit/page.tsx create mode 100644 frontend/src/app/distribution/_components/DistributionEditPage.tsx create mode 100644 frontend/src/app/distribution/new/page.tsx create mode 100644 frontend/src/app/distribution/page.tsx diff --git a/CLAUDE.md b/CLAUDE.md index a1943a2..7633de6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,6 +190,23 @@ FertilizationEntry (施肥エントリ・中間テーブル) ├── fertilizer (FK to Fertilizer, PROTECT) ← 使用中の肥料は削除不可 ├── bags(袋数、Decimal) └── unique_together = ['plan', 'field', 'fertilizer'] + +DistributionPlan (分配計画) +├── fertilization_plan (FK to FertilizationPlan, CASCADE) +├── name(計画名) +└── groups → DistributionGroup + +DistributionGroup (分配グループ) +├── distribution_plan (FK to DistributionPlan, CASCADE) +├── name(グループ名) +├── order(表示順) +└── unique_together = ['distribution_plan', 'name'] + +DistributionGroupField (グループ圃場割り当て) +├── distribution_plan (FK to DistributionPlan, CASCADE) ← 一意制約用 +├── group (FK to DistributionGroup, CASCADE) +├── field (FK to fields.Field, PROTECT) +└── unique_together = ['distribution_plan', 'field'] ← 1圃場=1グループ/1計画 ``` ### 重要な設計判断 @@ -324,7 +341,14 @@ 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/`(肥料マスタ) - - スコープ外(将来): 購入管理、配置計画 + - スコープ外(将来): 購入管理 +11. **分配計画機能**(2026-03-02 実装): + - Django `apps/fertilizer` アプリに3モデル追加(DistributionPlan, DistributionGroup, DistributionGroupField) + - API(JWT認証): `GET/POST /api/fertilizer/distribution/?year=`, `GET/PUT/DELETE /api/fertilizer/distribution/{id}/`, `GET /api/fertilizer/distribution/{id}/pdf/` + - 施肥計画を元に圃場をカスタムグループに割り当て、グループ×肥料の集計表を生成 + - PDF出力(A4横向き・グループ合計行★+圃場サブ行) + - フロントエンド: `/distribution/`(一覧), `/distribution/new`・`/distribution/[id]/edit`(編集) + - マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md` ### 🚧 既知の課題・技術的負債 @@ -451,6 +475,7 @@ docker-compose exec backend python manage.py migrate - **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md` - **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md` - **施肥計画機能**: `document/13_マスタードキュメント_施肥計画編.md` +- **分配計画機能**: `document/14_マスタードキュメント_分配計画編.md` ### 設計ドキュメント(プロジェクト横断) @@ -476,6 +501,7 @@ docker-compose exec backend python manage.py migrate ## 📝 更新履歴 - 2026-02-28: Cursor連携を廃止。Claude Code 単独運用に変更。`document/20_Cursor_Claude連携ガイド.md` を削除 +- 2026-03-02: 分配計画機能を実装。`apps/fertilizer` に DistributionPlan/DistributionGroup/DistributionGroupField 追加、API `/api/fertilizer/distribution/`、PDF出力(A4横・グループ★行+圃場サブ行)、フロントエンド `/distribution/`。マスタードキュメント `document/14_マスタードキュメント_分配計画編.md` 追加 - 2026-03-01: 施肥計画機能を実装・本番稼働。`apps/fertilizer`(Fertilizer, FertilizationPlan, FertilizationEntry, 自動計算3方式, PDF出力, PROTECT migration 0002)、フロントエンド `/fertilizer/`(一覧・編集・肥料マスタ)。施肥機能全体で alert/confirm 廃止・インラインバナーに統一。マスタードキュメント `document/13_マスタードキュメント_施肥計画編.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` 必須)をトラブルシューティングに追加 diff --git a/backend/apps/fertilizer/admin.py b/backend/apps/fertilizer/admin.py index 12503c4..49824ad 100644 --- a/backend/apps/fertilizer/admin.py +++ b/backend/apps/fertilizer/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Fertilizer, FertilizationPlan, FertilizationEntry +from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField @admin.register(Fertilizer) @@ -17,3 +17,27 @@ class FertilizationPlanAdmin(admin.ModelAdmin): list_display = ['name', 'year', 'variety'] list_filter = ['year'] inlines = [FertilizationEntryInline] + + +class DistributionGroupFieldInline(admin.TabularInline): + model = DistributionGroupField + extra = 0 + readonly_fields = ['distribution_plan'] + + +class DistributionGroupInline(admin.TabularInline): + model = DistributionGroup + extra = 0 + + +@admin.register(DistributionPlan) +class DistributionPlanAdmin(admin.ModelAdmin): + list_display = ['name', 'fertilization_plan', 'created_at'] + list_filter = ['fertilization_plan__year'] + inlines = [DistributionGroupInline] + + +@admin.register(DistributionGroup) +class DistributionGroupAdmin(admin.ModelAdmin): + list_display = ['name', 'distribution_plan', 'order'] + inlines = [DistributionGroupFieldInline] diff --git a/backend/apps/fertilizer/migrations/0003_distributionplan_distributiongroup_and_more.py b/backend/apps/fertilizer/migrations/0003_distributionplan_distributiongroup_and_more.py new file mode 100644 index 0000000..f2b2bab --- /dev/null +++ b/backend/apps/fertilizer/migrations/0003_distributionplan_distributiongroup_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 5.0 on 2026-03-01 15:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fertilizer', '0002_alter_fertilizationentry_fertilizer'), + ('fields', '0006_e1c_chusankan_17_fields'), + ] + + operations = [ + migrations.CreateModel( + name='DistributionPlan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='計画名')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('fertilization_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='distribution_plans', to='fertilizer.fertilizationplan', verbose_name='施肥計画')), + ], + options={ + 'verbose_name': '分配計画', + 'verbose_name_plural': '分配計画', + 'ordering': ['-fertilization_plan__year', 'name'], + }, + ), + migrations.CreateModel( + name='DistributionGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='グループ名')), + ('order', models.PositiveIntegerField(default=0, verbose_name='表示順')), + ('distribution_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='fertilizer.distributionplan', verbose_name='分配計画')), + ], + options={ + 'verbose_name': '分配グループ', + 'verbose_name_plural': '分配グループ', + 'ordering': ['order', 'id'], + 'unique_together': {('distribution_plan', 'name')}, + }, + ), + migrations.CreateModel( + name='DistributionGroupField', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_assignments', to='fertilizer.distributiongroup', verbose_name='グループ')), + ('distribution_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.distributionplan', verbose_name='分配計画')), + ], + options={ + 'verbose_name': 'グループ圃場割り当て', + 'verbose_name_plural': 'グループ圃場割り当て', + 'ordering': ['field__display_order', 'field__id'], + 'unique_together': {('distribution_plan', 'field')}, + }, + ), + ] diff --git a/backend/apps/fertilizer/models.py b/backend/apps/fertilizer/models.py index 22cc524..b50da4c 100644 --- a/backend/apps/fertilizer/models.py +++ b/backend/apps/fertilizer/models.py @@ -67,3 +67,64 @@ class FertilizationEntry(models.Model): def __str__(self): return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}袋" + + +class DistributionPlan(models.Model): + """分配計画:施肥計画の圃場をカスタムグループに割り当て、配置場所単位で集計する""" + fertilization_plan = models.ForeignKey( + FertilizationPlan, on_delete=models.CASCADE, + related_name='distribution_plans', verbose_name='施肥計画' + ) + name = models.CharField(max_length=200, 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 = ['-fertilization_plan__year', 'name'] + + def __str__(self): + return f"{self.fertilization_plan.year} {self.name}" + + +class DistributionGroup(models.Model): + """分配グループ:ある場所にまとめて置く圃場のグループ""" + distribution_plan = models.ForeignKey( + DistributionPlan, on_delete=models.CASCADE, + related_name='groups', verbose_name='分配計画' + ) + name = models.CharField(max_length=100, verbose_name='グループ名') + order = models.PositiveIntegerField(default=0, verbose_name='表示順') + + class Meta: + verbose_name = '分配グループ' + verbose_name_plural = '分配グループ' + unique_together = [['distribution_plan', 'name']] + ordering = ['order', 'id'] + + def __str__(self): + return f"{self.distribution_plan} / {self.name}" + + +class DistributionGroupField(models.Model): + """圃場のグループへの割り当て(1圃場=1グループ/1分配計画)""" + distribution_plan = models.ForeignKey( + DistributionPlan, on_delete=models.CASCADE, verbose_name='分配計画' + ) + group = models.ForeignKey( + DistributionGroup, on_delete=models.CASCADE, + related_name='field_assignments', verbose_name='グループ' + ) + field = models.ForeignKey( + 'fields.Field', on_delete=models.PROTECT, verbose_name='圃場' + ) + + class Meta: + verbose_name = 'グループ圃場割り当て' + verbose_name_plural = 'グループ圃場割り当て' + unique_together = [['distribution_plan', 'field']] + ordering = ['field__display_order', 'field__id'] + + def __str__(self): + return f"{self.group.name} / {self.field.name}" diff --git a/backend/apps/fertilizer/serializers.py b/backend/apps/fertilizer/serializers.py index 538a038..7308b07 100644 --- a/backend/apps/fertilizer/serializers.py +++ b/backend/apps/fertilizer/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Fertilizer, FertilizationPlan, FertilizationEntry +from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField class FertilizerSerializer(serializers.ModelSerializer): @@ -79,3 +79,140 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer): fertilizer_id=entry['fertilizer_id'], bags=entry['bags'], ) + + +# ─── 分配計画 ──────────────────────────────────────────────────────────── + +class DistributionGroupFieldSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(source='field.id', read_only=True) + name = serializers.CharField(source='field.name', read_only=True) + area_tan = serializers.DecimalField( + source='field.area_tan', max_digits=6, decimal_places=4, read_only=True + ) + + class Meta: + model = DistributionGroupField + fields = ['id', 'name', 'area_tan'] + + +class DistributionGroupReadSerializer(serializers.ModelSerializer): + fields = DistributionGroupFieldSerializer(source='field_assignments', many=True, read_only=True) + + class Meta: + model = DistributionGroup + fields = ['id', 'name', 'order', 'fields'] + + +class FertilizationPlanForDistributionSerializer(serializers.ModelSerializer): + """分配計画詳細に埋め込む施肥計画情報(肥料一覧・entries 含む)""" + variety_name = serializers.SerializerMethodField() + crop_name = serializers.SerializerMethodField() + fertilizers = serializers.SerializerMethodField() + entries = serializers.SerializerMethodField() + + class Meta: + model = FertilizationPlan + fields = ['id', 'name', 'year', 'variety_name', 'crop_name', 'fertilizers', 'entries'] + + def get_variety_name(self, obj): + return obj.variety.name + + def get_crop_name(self, obj): + return obj.variety.crop.name + + def get_fertilizers(self, obj): + fert_ids = obj.entries.values_list('fertilizer_id', flat=True).distinct() + from .models import Fertilizer as F + fertilizers = F.objects.filter(id__in=fert_ids).order_by('name') + return [{'id': f.id, 'name': f.name} for f in fertilizers] + + def get_entries(self, obj): + return [ + {'field': e.field_id, 'fertilizer': e.fertilizer_id, 'bags': str(e.bags)} + for e in obj.entries.all() + ] + + +class DistributionPlanListSerializer(serializers.ModelSerializer): + fertilization_plan_id = serializers.IntegerField(source='fertilization_plan.id', read_only=True) + fertilization_plan_name = serializers.CharField(source='fertilization_plan.name', read_only=True) + year = serializers.IntegerField(source='fertilization_plan.year', read_only=True) + variety_name = serializers.SerializerMethodField() + crop_name = serializers.SerializerMethodField() + group_count = serializers.SerializerMethodField() + field_count = serializers.SerializerMethodField() + + class Meta: + model = DistributionPlan + fields = [ + 'id', 'name', 'fertilization_plan_id', 'fertilization_plan_name', + 'year', 'variety_name', 'crop_name', 'group_count', 'field_count', + 'created_at', 'updated_at', + ] + + def get_variety_name(self, obj): + return obj.fertilization_plan.variety.name + + def get_crop_name(self, obj): + return obj.fertilization_plan.variety.crop.name + + def get_group_count(self, obj): + return obj.groups.count() + + def get_field_count(self, obj): + return obj.distributiongroupfield_set.count() + + +class DistributionPlanReadSerializer(serializers.ModelSerializer): + fertilization_plan = FertilizationPlanForDistributionSerializer(read_only=True) + groups = DistributionGroupReadSerializer(many=True, read_only=True) + unassigned_fields = serializers.SerializerMethodField() + + class Meta: + model = DistributionPlan + fields = ['id', 'name', 'fertilization_plan', 'groups', 'unassigned_fields', 'created_at', 'updated_at'] + + def get_unassigned_fields(self, obj): + assigned_ids = obj.distributiongroupfield_set.values_list('field_id', flat=True) + plan_field_ids = obj.fertilization_plan.entries.values_list('field_id', flat=True).distinct() + from apps.fields.models import Field as F + unassigned = F.objects.filter(id__in=plan_field_ids).exclude(id__in=assigned_ids).order_by('display_order', 'id') + return [{'id': f.id, 'name': f.name, 'area_tan': str(f.area_tan)} for f in unassigned] + + +class DistributionPlanWriteSerializer(serializers.ModelSerializer): + fertilization_plan_id = serializers.IntegerField(write_only=True) + groups = serializers.ListField(child=serializers.DictField(), write_only=True, required=False) + + class Meta: + model = DistributionPlan + fields = ['id', 'name', 'fertilization_plan_id', 'groups'] + + def create(self, validated_data): + groups_data = validated_data.pop('groups', []) + plan = DistributionPlan.objects.create(**validated_data) + self._save_groups(plan, groups_data) + return plan + + def update(self, instance, validated_data): + groups_data = validated_data.pop('groups', None) + instance.name = validated_data.get('name', instance.name) + instance.save() + if groups_data is not None: + instance.groups.all().delete() + self._save_groups(instance, groups_data) + return instance + + def _save_groups(self, plan, groups_data): + for g_data in groups_data: + group = DistributionGroup.objects.create( + distribution_plan=plan, + name=g_data['name'], + order=g_data.get('order', 0), + ) + for field_id in g_data.get('field_ids', []): + DistributionGroupField.objects.create( + distribution_plan=plan, + group=group, + field_id=field_id, + ) diff --git a/backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html b/backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html new file mode 100644 index 0000000..b1bb436 --- /dev/null +++ b/backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html @@ -0,0 +1,74 @@ + + + + + + + +

分配計画書

+

+ {{ fert_plan.year }}年度 {{ fert_plan.variety.crop.name }} / {{ fert_plan.variety.name }} + /施肥計画「{{ fert_plan.name }}」 + /分配計画「{{ dist_plan.name }}」 +

+ + + + + + {% for fert in fertilizers %} + + {% endfor %} + + + + + {% for group in group_rows %} + {# グループ合計行 #} + + + {% for total in group.totals %} + + {% endfor %} + + + {# 圃場サブ行 #} + {% for row in group.field_rows %} + + + {% for cell in row.cells %} + + {% endfor %} + + + {% endfor %} + {% endfor %} + + + + + {% for total in fert_totals %} + + {% endfor %} + + + +
グループ / 圃場{{ fert.name }}
(袋)
合計袋数
{{ group.name }}{% if total %}{{ total }}{% else %}-{% endif %}{{ group.row_total }}
{{ 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 index 27ffd9b..b7f7728 100644 --- a/backend/apps/fertilizer/urls.py +++ b/backend/apps/fertilizer/urls.py @@ -5,6 +5,7 @@ from . import views router = DefaultRouter() router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer') router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan') +router.register(r'distribution', views.DistributionPlanViewSet, basename='distribution-plan') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/apps/fertilizer/views.py b/backend/apps/fertilizer/views.py index 0ab310e..3d0522f 100644 --- a/backend/apps/fertilizer/views.py +++ b/backend/apps/fertilizer/views.py @@ -11,11 +11,14 @@ from weasyprint import HTML from apps.fields.models import Field from apps.plans.models import Plan, Variety -from .models import Fertilizer, FertilizationPlan +from .models import Fertilizer, FertilizationPlan, DistributionPlan from .serializers import ( FertilizerSerializer, FertilizationPlanSerializer, FertilizationPlanWriteSerializer, + DistributionPlanListSerializer, + DistributionPlanReadSerializer, + DistributionPlanWriteSerializer, ) @@ -194,3 +197,128 @@ class CalculateView(APIView): return Response({'error': 'method は nitrogen / even / per_tan のいずれかです'}, status=status.HTTP_400_BAD_REQUEST) return Response(results) + + +class DistributionPlanViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + + def get_queryset(self): + qs = DistributionPlan.objects.select_related( + 'fertilization_plan', 'fertilization_plan__variety', 'fertilization_plan__variety__crop' + ).prefetch_related( + 'groups', 'groups__field_assignments', 'groups__field_assignments__field', + 'fertilization_plan__entries', 'fertilization_plan__entries__field', + 'fertilization_plan__entries__fertilizer', + 'distributiongroupfield_set', + ) + year = self.request.query_params.get('year') + if year: + qs = qs.filter(fertilization_plan__year=year) + return qs + + def get_serializer_class(self): + if self.action in ['create', 'update', 'partial_update']: + return DistributionPlanWriteSerializer + if self.action == 'list': + return DistributionPlanListSerializer + return DistributionPlanReadSerializer + + @action(detail=True, methods=['get']) + def pdf(self, request, pk=None): + dist_plan = self.get_object() + fert_plan = dist_plan.fertilization_plan + + # 施肥計画の肥料一覧(名前順) + fert_ids = fert_plan.entries.values_list('fertilizer_id', flat=True).distinct() + fertilizers = sorted( + Fertilizer.objects.filter(id__in=fert_ids), + key=lambda f: f.name + ) + + # entries を (field_id, fertilizer_id) → bags のマトリクスに変換 + entry_map = {} + for e in fert_plan.entries.all(): + entry_map[(e.field_id, e.fertilizer_id)] = e.bags + + # グループ行の構築 + groups = dist_plan.groups.prefetch_related('field_assignments__field').all() + group_rows = [] + for group in groups: + fields_in_group = [ + a.field for a in group.field_assignments.select_related('field').order_by('field__display_order', 'field__id') + ] + # グループ合計(肥料ごと) + group_totals = [] + for fert in fertilizers: + total = sum( + entry_map.get((f.id, fert.id), Decimal('0')) + for f in fields_in_group + ) + group_totals.append(total) + group_row_total = sum(group_totals) + + # 圃場サブ行 + field_rows = [] + for field in fields_in_group: + cells = [entry_map.get((field.id, fert.id), '') for fert in fertilizers] + row_total = sum(v for v in cells if v != '') + field_rows.append({'field': field, 'cells': cells, 'total': row_total}) + + group_rows.append({ + 'name': group.name, + 'totals': group_totals, + 'row_total': group_row_total, + 'field_rows': field_rows, + }) + + # 未割り当て圃場 + assigned_ids = dist_plan.distributiongroupfield_set.values_list('field_id', flat=True) + plan_field_ids = fert_plan.entries.values_list('field_id', flat=True).distinct() + unassigned_fields = Field.objects.filter( + id__in=plan_field_ids + ).exclude(id__in=assigned_ids).order_by('display_order', 'id') + + unassigned_rows = [] + if unassigned_fields.exists(): + ua_totals = [] + for fert in fertilizers: + total = sum( + entry_map.get((f.id, fert.id), Decimal('0')) + for f in unassigned_fields + ) + ua_totals.append(total) + unassigned_rows = [{ + 'name': '未割り当て', + 'totals': ua_totals, + 'row_total': sum(ua_totals), + 'field_rows': [ + { + 'field': f, + 'cells': [entry_map.get((f.id, fert.id), '') for fert in fertilizers], + 'total': sum(entry_map.get((f.id, fert.id), Decimal('0')) for fert in fertilizers), + } + for f in unassigned_fields + ], + }] + + all_group_rows = group_rows + unassigned_rows + fert_totals = [ + sum(r['totals'][i] for r in all_group_rows) + for i in range(len(fertilizers)) + ] + + context = { + 'dist_plan': dist_plan, + 'fert_plan': fert_plan, + 'fertilizers': fertilizers, + 'group_rows': all_group_rows, + 'fert_totals': fert_totals, + 'grand_total': sum(fert_totals), + } + html_string = render_to_string('fertilizer/distribution_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="distribution_{fert_plan.year}_{dist_plan.id}.pdf"' + ) + return response diff --git a/document/14_マスタードキュメント_分配計画編.md b/document/14_マスタードキュメント_分配計画編.md new file mode 100644 index 0000000..536c1dc --- /dev/null +++ b/document/14_マスタードキュメント_分配計画編.md @@ -0,0 +1,241 @@ +# マスタードキュメント:分配計画機能 + +> **作成**: 2026-03-02 +> **最終更新**: 2026-03-02 +> **対象機能**: 分配計画(施肥計画の圃場をグループ化し配置場所単位で集計) +> **実装状況**: 実装完了 + +--- + +## 概要 + +施肥計画(FertilizationPlan)で決めた圃場ごとの袋数を、**実際に肥料を配置する場所の単位**でまとめる機能。 +例:「田中エリアにはA肥料12袋・B肥料6袋を持っていく」という単位で計画・PDF出力できる。 + +### 機能スコープ + +| IN(実装済み) | OUT(対象外) | +|---|---| +| 施肥計画を元に圃場をカスタムグループに割り当て | 購入管理 | +| グループ×肥料の集計表(画面表示) | 実施記録 | +| PDF出力(グループ合計行+圃場サブ行) | | +| グループの順序変更・名前変更 | | + +--- + +## データモデル + +### DistributionPlan(分配計画) + +| フィールド | 型 | 制約 | 説明 | +|---|---|---|---| +| id | int | PK | | +| fertilization_plan | FK(FertilizationPlan) | CASCADE | | +| name | varchar(200) | required | 計画名 | +| created_at / updated_at | datetime | auto | | + +- `ordering = ['-fertilization_plan__year', 'name']` +- 1つの施肥計画に対して複数の分配計画を作れる(OneToOneではなくFK) + +### DistributionGroup(分配グループ) + +| フィールド | 型 | 制約 | 説明 | +|---|---|---|---| +| id | int | PK | | +| distribution_plan | FK(DistributionPlan) | CASCADE | | +| name | varchar(100) | required | グループ名 | +| order | PositiveIntegerField | default=0 | 表示順 | + +- `unique_together = [['distribution_plan', 'name']]` → 同一計画内でグループ名重複不可 +- `ordering = ['order', 'id']` + +### DistributionGroupField(グループ圃場割り当て) + +| フィールド | 型 | 制約 | 説明 | +|---|---|---|---| +| id | int | PK | | +| distribution_plan | FK(DistributionPlan) | CASCADE | 一意制約のために冗長保持 | +| group | FK(DistributionGroup) | CASCADE | | +| field | FK(fields.Field) | PROTECT | 圃場 | + +- `unique_together = [['distribution_plan', 'field']]` → 1圃場=1グループ/1計画 +- `ordering = ['field__display_order', 'field__id']` + +--- + +## API エンドポイント + +すべて JWT 認証(`Authorization: Bearer `)。 + +| メソッド | URL | 説明 | +|---|---|---| +| GET | `/api/fertilizer/distribution/?year={year}` | 一覧(年度フィルタ) | +| POST | `/api/fertilizer/distribution/` | 新規作成 | +| GET | `/api/fertilizer/distribution/{id}/` | 詳細(groups/entries/unassigned込み) | +| PUT | `/api/fertilizer/distribution/{id}/` | 更新(groups全置換) | +| DELETE | `/api/fertilizer/distribution/{id}/` | 削除 | +| GET | `/api/fertilizer/distribution/{id}/pdf/` | PDF出力(application/pdf) | + +### 一覧レスポンス(DistributionPlanListSerializer) + +```json +{ + "id": 1, + "name": "2025年コシヒカリ 分配計画", + "fertilization_plan_id": 3, + "fertilization_plan_name": "2025年コシヒカリ施肥計画", + "year": 2025, + "variety_name": "コシヒカリ", + "crop_name": "米", + "group_count": 3, + "field_count": 12, + "created_at": "...", + "updated_at": "..." +} +``` + +### 詳細レスポンス(DistributionPlanReadSerializer) + +```json +{ + "id": 1, + "name": "2025年コシヒカリ 分配計画", + "fertilization_plan": { + "id": 3, + "name": "2025年コシヒカリ施肥計画", + "year": 2025, + "variety_name": "コシヒカリ", + "crop_name": "米", + "fertilizers": [{"id": 1, "name": "一発肥料"}], + "entries": [{"field": 5, "fertilizer": 1, "bags": "2.40"}] + }, + "groups": [ + { + "id": 10, + "name": "田中エリア", + "order": 0, + "fields": [{"id": 5, "name": "田中上", "area_tan": "1.2000"}] + } + ], + "unassigned_fields": [{"id": 7, "name": "未割り当て圃場", "area_tan": "0.5000"}] +} +``` + +### 書き込みリクエスト(POST/PUT) + +```json +{ + "name": "2025年コシヒカリ 分配計画", + "fertilization_plan_id": 3, + "groups": [ + {"name": "田中エリア", "order": 0, "field_ids": [5, 6]}, + {"name": "奥地エリア", "order": 1, "field_ids": [7]} + ] +} +``` + +PUT は groups を全削除→再作成する全置換方式。 + +--- + +## フロントエンド画面 + +### 分配計画一覧 `/distribution` + +- 年度セレクタ(`localStorage distributionYear` で保持) +- テーブル: 計画名・施肥計画・作物/品種・グループ数・圃場数 +- アクション: PDF・編集・削除 +- 削除エラー: インラインバナー(確認なし・失敗したらバナー表示) + +### 分配計画編集 `/distribution/new` / `/distribution/[id]/edit` + +**共通コンポーネント**: `frontend/src/app/distribution/_components/DistributionEditPage.tsx` + +#### State構成 + +```typescript +// 基本情報 +const [name, setName] = useState('') +const [fertilizationPlanId, setFertilizationPlanId] = useState('') + +// 施肥計画詳細(施肥計画選択後に取得) +const [fertPlanDetail, setFertPlanDetail] = useState(null) + +// ローカルグループ(tempId で管理、保存時にサーバーへ送信) +const [groups, setGroups] = useState([]) +// LocalGroup = { tempId: string, name: string, order: number, fieldIds: number[], isRenamingName?: string } +``` + +#### UI構成 + +1. **計画基本情報**: 計画名テキスト + 施肥計画セレクタ +2. **グループ割り当て**: + - 新規グループ追加(名前入力 + 追加ボタン) + - グループカード(↑↓順序変更・鉛筆名前変更・×削除) + - グループ内圃場(×解除)+ 肥料別袋数をインライン表示 + - 未割り当て圃場セクション(グループ選択ドロップダウンで割り当て) +3. **集計プレビュー**: グループ×肥料マトリクス(リアルタイム・サーバー通信なし) + +--- + +## PDF 出力 + +`GET /api/fertilizer/distribution/{id}/pdf/` + +- WeasyPrint(既存施肥計画PDFと同じ仕組み) +- テンプレート: `backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html` +- フォーマット: A4横向き +- 内容: + - ★グループ合計行(太字・緑背景) + - 圃場サブ行(小フォント・灰色背景) + - 肥料列合計・総合計 +- ファイル名: `distribution_{year}_{plan_id}.pdf` + +--- + +## ファイル構成 + +### Backend + +``` +backend/apps/fertilizer/ +├── models.py # DistributionPlan/Group/GroupField 追加(migration 0003) +├── serializers.py # Distribution* シリアライザ追加 +├── views.py # DistributionPlanViewSet 追加 +├── urls.py # router.register('distribution', ...) 追加 +├── admin.py # DistributionPlan/Group の admin 登録 +└── templates/fertilizer/ + └── distribution_pdf.html # A4横 PDF テンプレート +``` + +### Frontend + +``` +frontend/src/app/distribution/ +├── page.tsx # 一覧ページ +├── new/page.tsx # 新規作成(ラッパー) +├── [id]/edit/page.tsx # 編集(ラッパー) +└── _components/DistributionEditPage.tsx # 編集共通コンポーネント +``` + +--- + +## 注意点 + +### 集計は全クライアントサイド計算 + +集計プレビューは API を呼ばず、`fertPlanDetail.entries` と `groups.fieldIds` からクライアントで計算する。 +PDF生成時のみサーバーサイドで同じ計算を実施。 + +### PUT の全置換方式 + +PUT 時は `groups.all().delete()` → 再作成。部分更新は非対応。 + +### 未割り当て圃場の扱い + +- 施肥計画に含まれる圃場のうちグループに割り当てられていないものは「未割り当て」として表示 +- PDF にも「未割り当て」グループとして出力される(ゼロの場合は出力なし) + +### エラー表示方針 + +施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。 diff --git a/frontend/src/app/distribution/[id]/edit/page.tsx b/frontend/src/app/distribution/[id]/edit/page.tsx new file mode 100644 index 0000000..4902177 --- /dev/null +++ b/frontend/src/app/distribution/[id]/edit/page.tsx @@ -0,0 +1,5 @@ +import DistributionEditPage from '../../_components/DistributionEditPage'; + +export default function DistributionEditRoute({ params }: { params: { id: string } }) { + return ; +} diff --git a/frontend/src/app/distribution/_components/DistributionEditPage.tsx b/frontend/src/app/distribution/_components/DistributionEditPage.tsx new file mode 100644 index 0000000..66682a8 --- /dev/null +++ b/frontend/src/app/distribution/_components/DistributionEditPage.tsx @@ -0,0 +1,651 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Plus, X, ChevronUp, ChevronDown, Pencil, Check } from 'lucide-react'; +import Navbar from '@/components/Navbar'; +import { DistributionPlan, FertilizationPlan } from '@/types'; +import { api } from '@/lib/api'; + +const CURRENT_YEAR = new Date().getFullYear(); + +// ローカル管理用のグループ型(ID未採番の新規グループも持てる) +interface LocalGroup { + tempId: string; + name: string; + order: number; + fieldIds: number[]; + isRenamingName?: string; // 名前変更中の一時値 +} + +interface FieldInfo { + id: number; + name: string; + area_tan: string; +} + +interface Props { + planId?: number; // 編集時のみ +} + +export default function DistributionEditPage({ planId }: Props) { + const router = useRouter(); + const isEdit = planId !== undefined; + + // 基本情報 + const [name, setName] = useState(''); + const [fertilizationPlanId, setFertilizationPlanId] = useState(''); + const [year] = useState(() => { + if (typeof window !== 'undefined') { + return parseInt(localStorage.getItem('distributionYear') || String(CURRENT_YEAR), 10); + } + return CURRENT_YEAR; + }); + + // 施肥計画一覧(セレクタ用) + const [fertilizationPlans, setFertilizationPlans] = useState([]); + // 選択中の施肥計画の詳細(肥料・entries) + const [fertPlanDetail, setFertPlanDetail] = useState(null); + + // ローカルグループ状態 + const [groups, setGroups] = useState([]); + const [newGroupName, setNewGroupName] = useState(''); + + // UI状態 + const [saveError, setSaveError] = useState(null); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + + // ── 初期データ読み込み ────────────────────────────────── + + useEffect(() => { + const init = async () => { + try { + // 施肥計画一覧を全年度取得(分配計画のベースになる) + const res = await api.get('/fertilizer/plans/'); + setFertilizationPlans(res.data); + } catch (e) { + console.error(e); + } + + if (isEdit && planId) { + try { + // 既存の分配計画を読み込む + const detailRes = await api.get(`/fertilizer/distribution/${planId}/`); + const detail: DistributionPlan = detailRes.data; + setName(detail.name); + setFertilizationPlanId(detail.fertilization_plan.id); + setFertPlanDetail(detail.fertilization_plan); + // グループを LocalGroup 形式に変換 + setGroups( + detail.groups.map((g, i) => ({ + tempId: String(g.id), + name: g.name, + order: g.order ?? i, + fieldIds: g.fields.map(f => f.id), + })) + ); + } catch (e) { + console.error(e); + } + } + setLoading(false); + }; + init(); + }, [planId]); + + // 施肥計画が変わったら詳細を取得 + useEffect(() => { + if (!fertilizationPlanId) { + setFertPlanDetail(null); + if (!isEdit) setGroups([]); + return; + } + if (isEdit && fertPlanDetail?.id === fertilizationPlanId) return; + + const fetchDetail = async () => { + try { + const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`); + const data: FertilizationPlan = res.data; + // FertilizationPlanForDistributionSerializer と同じ構造に合わせる + const ferts = Array.from( + new Map( + data.entries.map(e => [e.fertilizer, { id: e.fertilizer, name: e.fertilizer_name || '' }]) + ).values() + ).sort((a, b) => a.name.localeCompare(b.name)); + setFertPlanDetail({ + id: data.id, + name: data.name, + year: data.year, + variety_name: data.variety_name, + crop_name: data.crop_name, + fertilizers: ferts, + entries: data.entries.map(e => ({ + field: e.field, + fertilizer: e.fertilizer, + bags: String(e.bags), + })), + }); + if (!isEdit) setGroups([]); + } catch (e) { + console.error(e); + } + }; + fetchDetail(); + }, [fertilizationPlanId]); + + // ── 計算ヘルパー ────────────────────────────────────── + + // 全圃場一覧(施肥計画のentries に含まれる圃場) + const allPlanFields: FieldInfo[] = (() => { + if (!fertPlanDetail) return []; + const seen = new Map(); + for (const e of fertPlanDetail.entries) { + if (!seen.has(e.field)) { + // field名は後述の fertilizationPlans から取る + seen.set(e.field, { id: e.field, name: String(e.field), area_tan: '0' }); + } + } + return Array.from(seen.values()); + })(); + + // fertilizationPlans から field情報を取得(FertilizationPlanSerializer の entries に field_name が含まれる) + const fieldInfoMap = (() => { + const map = new Map(); + if (!fertPlanDetail) return map; + const plan = fertilizationPlans.find(p => p.id === fertPlanDetail.id); + if (plan) { + for (const e of plan.entries) { + if (e.field && !map.has(e.field)) { + map.set(e.field, { + id: e.field, + name: e.field_name || String(e.field), + area_tan: e.field_area_tan || '0', + }); + } + } + } + return map; + })(); + + const getFieldInfo = (fieldId: number): FieldInfo => + fieldInfoMap.get(fieldId) ?? { id: fieldId, name: `圃場#${fieldId}`, area_tan: '0' }; + + // 割り当て済みフィールドIDセット + const assignedFieldIds = new Set(groups.flatMap(g => g.fieldIds)); + + // 未割り当て圃場 + const unassignedFields = fertPlanDetail + ? Array.from( + new Map( + fertPlanDetail.entries + .map(e => e.field) + .filter(id => !assignedFieldIds.has(id)) + .map(id => [id, getFieldInfo(id)]) + ).values() + ) + : []; + + // bags取得 + const getBags = (fieldId: number, fertilizerId: number): number => { + if (!fertPlanDetail) return 0; + const entry = fertPlanDetail.entries.find( + e => e.field === fieldId && e.fertilizer === fertilizerId + ); + return entry ? parseFloat(entry.bags) : 0; + }; + + // グループごとの集計 + const groupSummaries = groups.map(g => { + const fertTotals = (fertPlanDetail?.fertilizers || []).map(fert => ({ + fertilizerId: fert.id, + fertilizerName: fert.name, + total: g.fieldIds.reduce((sum, fId) => sum + getBags(fId, fert.id), 0), + })); + const rowTotal = fertTotals.reduce((s, f) => s + f.total, 0); + return { ...g, fertTotals, rowTotal }; + }); + + // 未割り当てグループの集計 + const unassignedSummary = { + fertTotals: (fertPlanDetail?.fertilizers || []).map(fert => ({ + fertilizerId: fert.id, + fertilizerName: fert.name, + total: unassignedFields.reduce((sum, f) => sum + getBags(f.id, fert.id), 0), + })), + rowTotal: 0 as number, + }; + unassignedSummary.rowTotal = unassignedSummary.fertTotals.reduce((s, f) => s + f.total, 0); + + // 肥料合計行 + const fertColumnTotals = (fertPlanDetail?.fertilizers || []).map(fert => { + const groupTotal = groupSummaries.reduce( + (sum, g) => sum + (g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0), + 0 + ); + const unassignedTotal = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0; + return { id: fert.id, total: groupTotal + unassignedTotal }; + }); + const grandTotal = fertColumnTotals.reduce((s, f) => s + f.total, 0); + + // ── グループ操作 ────────────────────────────────────── + + const addGroup = () => { + const n = newGroupName.trim(); + if (!n) return; + if (groups.some(g => g.name === n)) { + setSaveError(`グループ名「${n}」はすでに存在します`); + return; + } + setSaveError(null); + setGroups(prev => [ + ...prev, + { tempId: crypto.randomUUID(), name: n, order: prev.length, fieldIds: [] }, + ]); + setNewGroupName(''); + }; + + const removeGroup = (tempId: string) => { + setGroups(prev => prev.filter(g => g.tempId !== tempId)); + }; + + const moveGroup = (tempId: string, dir: -1 | 1) => { + setGroups(prev => { + const idx = prev.findIndex(g => g.tempId === tempId); + if (idx < 0 || idx + dir < 0 || idx + dir >= prev.length) return prev; + const next = [...prev]; + [next[idx], next[idx + dir]] = [next[idx + dir], next[idx]]; + return next.map((g, i) => ({ ...g, order: i })); + }); + }; + + const startRename = (tempId: string) => { + setGroups(prev => + prev.map(g => (g.tempId === tempId ? { ...g, isRenamingName: g.name } : g)) + ); + }; + + const commitRename = (tempId: string) => { + setGroups(prev => + prev.map(g => { + if (g.tempId !== tempId) return g; + const newName = (g.isRenamingName || '').trim(); + if (!newName || newName === g.name) return { ...g, isRenamingName: undefined }; + if (prev.some(other => other.tempId !== tempId && other.name === newName)) { + setSaveError(`グループ名「${newName}」はすでに存在します`); + return { ...g, isRenamingName: undefined }; + } + return { ...g, name: newName, isRenamingName: undefined }; + }) + ); + }; + + const assignFieldToGroup = (fieldId: number, groupTempId: string) => { + setGroups(prev => + prev.map(g => { + if (g.tempId === groupTempId) { + return { ...g, fieldIds: [...g.fieldIds, fieldId] }; + } + return { ...g, fieldIds: g.fieldIds.filter(id => id !== fieldId) }; + }) + ); + }; + + const removeFieldFromGroup = (fieldId: number, groupTempId: string) => { + setGroups(prev => + prev.map(g => + g.tempId === groupTempId ? { ...g, fieldIds: g.fieldIds.filter(id => id !== fieldId) } : g + ) + ); + }; + + // ── 保存 ────────────────────────────────────────────── + + const handleSave = async () => { + setSaveError(null); + if (!name.trim()) { setSaveError('計画名を入力してください'); return; } + if (!fertilizationPlanId) { setSaveError('施肥計画を選択してください'); return; } + + setSaving(true); + const payload = { + name: name.trim(), + fertilization_plan_id: fertilizationPlanId, + groups: groups.map((g, i) => ({ + name: g.name, + order: i, + field_ids: g.fieldIds, + })), + }; + + try { + if (isEdit) { + await api.put(`/fertilizer/distribution/${planId}/`, payload); + } else { + await api.post('/fertilizer/distribution/', payload); + } + setSaving(false); + router.push('/distribution'); + } catch (e: unknown) { + setSaving(false); + const axiosErr = e as { response?: { data?: unknown } }; + const errData = axiosErr?.response?.data; + setSaveError(errData ? JSON.stringify(errData) : '保存に失敗しました'); + } + }; + + // ── レンダリング ────────────────────────────────────── + + if (loading) { + return ( +
+ +
読み込み中...
+
+ ); + } + + const fertilizers = fertPlanDetail?.fertilizers || []; + + return ( +
+ +
+ {/* ヘッダー */} +
+ +
+ +

+ {isEdit ? '分配計画を編集' : '分配計画を新規作成'} +

+ + {saveError && ( +
+ {saveError} + +
+ )} + + {/* 基本情報 */} +
+
+
+ + setName(e.target.value)} + placeholder="例: 2025年コシヒカリ 分配計画" + className="flex-1 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+
+ + +
+
+
+ + {!fertPlanDetail ? ( +
+ 施肥計画を選択するとグループ割り当て画面が表示されます +
+ ) : ( + <> + {/* グループ割り当て */} +
+

グループ割り当て

+ + {/* 新規グループ追加 */} +
+ setNewGroupName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addGroup()} + placeholder="新規グループ名" + className="border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500 w-48" + /> + +
+ + {/* グループ一覧 */} +
+ {groups.map((group, idx) => ( +
+ {/* グループヘッダー */} +
+ {group.isRenamingName !== undefined ? ( + <> + + setGroups(prev => + prev.map(g => + g.tempId === group.tempId ? { ...g, isRenamingName: e.target.value } : g + ) + ) + } + onKeyDown={e => e.key === 'Enter' && commitRename(group.tempId)} + className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500" + autoFocus + /> + + + ) : ( + <> + {group.name} + + + + + + )} +
+ {/* グループ内圃場 */} +
+ {group.fieldIds.length === 0 ? ( +

圃場が割り当てられていません

+ ) : ( + group.fieldIds.map(fId => { + const fi = getFieldInfo(fId); + const bags = fertilizers.map(fert => getBags(fId, fert.id)); + return ( +
+ + {fi.name} + {fi.area_tan}反 + + {fertilizers.map((fert, i) => ( + + {i > 0 && ' / '} + {fert.name}: {bags[i].toFixed(2)}袋 + + ))} + +
+ ); + }) + )} +
+
+ ))} +
+ + {/* 未割り当て圃場 */} + {unassignedFields.length > 0 && ( +
+

未割り当て圃場

+
+ {unassignedFields.map(fi => ( +
+ {fi.name} + {fi.area_tan}反 + +
+ ))} +
+
+ )} +
+ + {/* 集計プレビュー */} + {(groups.length > 0 || unassignedFields.length > 0) && fertilizers.length > 0 && ( +
+

集計プレビュー

+
+ + + + + {fertilizers.map(fert => ( + + ))} + + + + + {groupSummaries.map(g => ( + + + {fertilizers.map(fert => { + const t = g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0; + return ( + + ); + })} + + + ))} + {unassignedSummary.rowTotal > 0 && ( + + + {fertilizers.map(fert => { + const t = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0; + return ( + + ); + })} + + + )} + + + + + {fertColumnTotals.map(f => ( + + ))} + + + +
グループ + {fert.name} + 合計(袋)
{g.name} + {t > 0 ? t.toFixed(2) : -} + + {g.rowTotal.toFixed(2)} +
未割り当て + {t > 0 ? t.toFixed(2) : -} + + {unassignedSummary.rowTotal.toFixed(2)} +
合計 + {f.total.toFixed(2)} + + {grandTotal.toFixed(2)} +
+
+
+ )} + + )} + + {/* フッターボタン */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/app/distribution/new/page.tsx b/frontend/src/app/distribution/new/page.tsx new file mode 100644 index 0000000..96525b8 --- /dev/null +++ b/frontend/src/app/distribution/new/page.tsx @@ -0,0 +1,5 @@ +import DistributionEditPage from '../_components/DistributionEditPage'; + +export default function DistributionNewPage() { + return ; +} diff --git a/frontend/src/app/distribution/page.tsx b/frontend/src/app/distribution/page.tsx new file mode 100644 index 0000000..6696bad --- /dev/null +++ b/frontend/src/app/distribution/page.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { FlaskConical, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react'; +import Navbar from '@/components/Navbar'; +import { api } from '@/lib/api'; +import { DistributionPlanListItem } from '@/types'; + +const CURRENT_YEAR = new Date().getFullYear(); +const YEAR_KEY = 'distributionYear'; + +export default function DistributionListPage() { + const router = useRouter(); + const [year, setYear] = useState(() => { + if (typeof window !== 'undefined') { + return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10); + } + return CURRENT_YEAR; + }); + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteError, setDeleteError] = useState(null); + + const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i); + + useEffect(() => { + localStorage.setItem(YEAR_KEY, String(year)); + fetchPlans(); + }, [year]); + + const fetchPlans = async () => { + setLoading(true); + try { + const res = await api.get(`/fertilizer/distribution/?year=${year}`); + setPlans(res.data); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (id: number) => { + setDeleteError(null); + try { + await api.delete(`/fertilizer/distribution/${id}/`); + setPlans(prev => prev.filter(p => p.id !== id)); + } catch (e) { + console.error(e); + setDeleteError('削除できませんでした。'); + } + }; + + const handlePdf = async (id: number, planName: string) => { + try { + const res = await api.get(`/fertilizer/distribution/${id}/pdf/`, { responseType: 'blob' }); + const url = URL.createObjectURL(res.data); + const a = document.createElement('a'); + a.href = url; + a.download = `distribution_${planName}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + console.error(e); + } + }; + + return ( +
+ +
+
+
+ +

分配計画

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

読み込み中...

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

{year}年の分配計画はありません

+

施肥計画を元に分配計画を作成できます

+ +
+ ) : ( +
+ + + + + + + + + + + + + {plans.map(plan => ( + + + + + + + + + ))} + +
計画名施肥計画作物/品種グループ数圃場数
{plan.name}{plan.fertilization_plan_name}{plan.crop_name} / {plan.variety_name}{plan.group_count}{plan.field_count} +
+ + + +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 26fcbf9..fe46e8b 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, Sprout } from 'lucide-react'; +import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout, FlaskConical } from 'lucide-react'; import { logout } from '@/lib/api'; export default function Navbar() { @@ -122,6 +122,17 @@ export default function Navbar() { 施肥計画 +
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5155a9a..b722e9f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -91,6 +91,53 @@ export interface FertilizationPlan { updated_at: string; } +export interface DistributionGroupField { + id: number; + name: string; + area_tan: string; +} + +export interface DistributionGroup { + id: number; + name: string; + order: number; + fields: DistributionGroupField[]; +} + +export interface DistributionPlanFertilizationPlan { + id: number; + name: string; + year: number; + variety_name: string; + crop_name: string; + fertilizers: { id: number; name: string }[]; + entries: { field: number; fertilizer: number; bags: string }[]; +} + +export interface DistributionPlan { + id: number; + name: string; + fertilization_plan: DistributionPlanFertilizationPlan; + groups: DistributionGroup[]; + unassigned_fields: DistributionGroupField[]; + created_at: string; + updated_at: string; +} + +export interface DistributionPlanListItem { + id: number; + name: string; + fertilization_plan_id: number; + fertilization_plan_name: string; + year: number; + variety_name: string; + crop_name: string; + group_count: number; + field_count: number; + created_at: string; + updated_at: string; +} + export interface MailSender { id: number; type: 'address' | 'domain';