Compare commits

..

2 Commits

Author SHA1 Message Date
Akira
466eef128c 分配計画機能を実装
施肥計画の圃場を配置場所単位でグループ化し、グループ×肥料の集計表を
表示・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 <noreply@anthropic.com>
2026-03-02 09:43:20 +09:00
Akira
0d321df1c4 ブラウザテスト後のクリーンアップ 2026-03-01 18:25:58 +09:00
16 changed files with 1734 additions and 5 deletions

View File

@@ -0,0 +1,78 @@
---
description: ブラウザテスト後のクリーンアップ手順
---
# ブラウザテスト後のクリーンアップ
ブラウザサブエージェントを使ったテスト実施後、必ず以下のクリーンアップを行うこと。
## ルール
1. **テスト関連ファイルはすべて `testing/` フォルダ以下に保存する**
- スクリーンショット → `testing/screenshots/<カテゴリ>/`
- 録画(.webp`testing/recordings/`
- テスト結果レポート → `testing/test_report.md`
- サブエージェントが自動生成した一時ファイル → `testing/subagent_generated/`
2. **プロジェクトの既存ファイルを変更しない**
- ブラウザサブエージェントは内部的に Playwright を使っており、以下のファイルを勝手に生成・変更することがある:
- `frontend/playwright_*.mjs`(テストスクリプト)
- `frontend/e2e/`(テストディレクトリ)
- `frontend/test-results/`(テスト結果)
- `.gitignore`(追記されることがある)
- `docker-compose.yml``WATCHPACK_POLLING` が追加されることがある)
- `frontend/src/` 内のソースコード(稀に変更されることがある)
## テスト終了後のクリーンアップ手順
// turbo-all
### 1. git で変更されたファイルを確認する
```powershell
git diff --name-only
```
### 2. サブエージェントによる既存ファイルの変更を元に戻す
```powershell
# 典型的な復元対象
git checkout .gitignore
git checkout docker-compose.yml
git checkout frontend/src/ 2>$null
git checkout frontend/tsconfig.tsbuildinfo 2>$null
```
### 3. サブエージェントが生成した一時ファイルを `testing/subagent_generated/` に移動する
```powershell
$project = "C:\Users\akira\Develop\keinasystem_t02"
$dest = "$project\testing\subagent_generated"
New-Item -ItemType Directory -Force -Path $dest | Out-Null
# playwright系ファイル
Get-ChildItem "$project\frontend\playwright_*.mjs" -ErrorAction SilentlyContinue | Move-Item -Destination $dest -Force
# e2eフォルダ
if (Test-Path "$project\frontend\e2e") { Move-Item "$project\frontend\e2e" "$dest\e2e" -Force }
# test-resultsフォルダ
if (Test-Path "$project\frontend\test-results") { Move-Item "$project\frontend\test-results" "$dest\test-results" -Force }
```
### 4. git管理に追加されてしまったファイルを除外する
```powershell
git rm --cached "frontend/e2e/*" 2>$null
git rm --cached "frontend/test-results/*" 2>$null
git rm --cached "frontend/playwright_*.mjs" 2>$null
```
### 5. 最終確認
```powershell
# testing/ 以外に未追跡・変更ファイルがないことを確認
git status --short | Where-Object { $_ -notmatch "testing/" }
```
上記の結果が空であればクリーンアップ完了。

View File

@@ -190,6 +190,23 @@ FertilizationEntry (施肥エントリ・中間テーブル)
├── fertilizer (FK to Fertilizer, PROTECT) ← 使用中の肥料は削除不可 ├── fertilizer (FK to Fertilizer, PROTECT) ← 使用中の肥料は削除不可
├── bags袋数、Decimal ├── bags袋数、Decimal
└── unique_together = ['plan', 'field', 'fertilizer'] └── 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 (施肥エントリ・中間テーブル)
- APIJWT認証: `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/` - APIJWT認証: `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) - 自動計算3方式: 反当袋数(per_tan)、均等配分(even)、反当チッソ(nitrogen)
- フロントエンド: `/fertilizer/`(一覧), `/fertilizer/new``/fertilizer/[id]/edit`(編集・マトリクス表), `/fertilizer/masters/`(肥料マスタ) - フロントエンド: `/fertilizer/`(一覧), `/fertilizer/new``/fertilizer/[id]/edit`(編集・マトリクス表), `/fertilizer/masters/`(肥料マスタ)
- スコープ外(将来): 購入管理、配置計画 - スコープ外(将来): 購入管理
11. **分配計画機能**2026-03-02 実装):
- Django `apps/fertilizer` アプリに3モデル追加DistributionPlan, DistributionGroup, DistributionGroupField
- APIJWT認証: `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/11_マスタードキュメント_メール通知関連編.md`
- **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md` - **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md`
- **施肥計画機能**: `document/13_マスタードキュメント_施肥計画編.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-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-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-28: 気象データ機能を実装・本番稼働。`apps/weather`WeatherRecord, 5 API、Windmill `f/weather/weather_sync`毎朝6時、フロントエンド `/weather`年別集計・期間指定・Rechartsグラフ`Crop.base_temp` 追加。デプロイコマンドの本番パス修正(/home/keinasystem/)。マスタードキュメント `document/12_マスタードキュメント_気象データ編.md` 追加
- 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加 - 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加

View File

@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Fertilizer, FertilizationPlan, FertilizationEntry from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
@admin.register(Fertilizer) @admin.register(Fertilizer)
@@ -17,3 +17,27 @@ class FertilizationPlanAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'variety'] list_display = ['name', 'year', 'variety']
list_filter = ['year'] list_filter = ['year']
inlines = [FertilizationEntryInline] 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]

View File

@@ -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')},
},
),
]

View File

@@ -67,3 +67,64 @@ class FertilizationEntry(models.Model):
def __str__(self): def __str__(self):
return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}" 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}"

View File

@@ -1,5 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Fertilizer, FertilizationPlan, FertilizationEntry from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
class FertilizerSerializer(serializers.ModelSerializer): class FertilizerSerializer(serializers.ModelSerializer):
@@ -79,3 +79,140 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
fertilizer_id=entry['fertilizer_id'], fertilizer_id=entry['fertilizer_id'],
bags=entry['bags'], 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,
)

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4 landscape; margin: 12mm; }
body { font-family: "Noto Sans CJK JP", "Hiragino Kaku Gothic Pro", sans-serif; font-size: 10pt; }
h1 { font-size: 14pt; text-align: center; margin-bottom: 4px; }
.subtitle { text-align: center; font-size: 9pt; color: #555; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin-top: 6px; }
th, td { border: 1px solid #888; padding: 4px 6px; text-align: right; }
th { background: #e8f5e9; text-align: center; }
.col-name { text-align: left; }
.group-row { font-weight: bold; background: #c8e6c9; }
.group-row td { font-size: 10pt; }
.group-star { color: #2e7d32; margin-right: 2px; }
.field-row td { font-size: 8.5pt; color: #444; background: #fafafa; }
.field-indent { padding-left: 14px; }
tr.total-row { font-weight: bold; background: #f5f5f5; }
.zero { color: #bbb; }
</style>
</head>
<body>
<h1>分配計画書</h1>
<p class="subtitle">
{{ fert_plan.year }}年度 {{ fert_plan.variety.crop.name }} / {{ fert_plan.variety.name }}
/施肥計画「{{ fert_plan.name }}」
/分配計画「{{ dist_plan.name }}」
</p>
<table>
<thead>
<tr>
<th class="col-name">グループ / 圃場</th>
{% for fert in fertilizers %}
<th>{{ fert.name }}<br><small>(袋)</small></th>
{% endfor %}
<th>合計袋数</th>
</tr>
</thead>
<tbody>
{% for group in group_rows %}
{# グループ合計行 #}
<tr class="group-row">
<td class="col-name"><span class="group-star"></span>{{ group.name }}</td>
{% for total in group.totals %}
<td>{% if total %}{{ total }}{% else %}<span class="zero">-</span>{% endif %}</td>
{% endfor %}
<td>{{ group.row_total }}</td>
</tr>
{# 圃場サブ行 #}
{% for row in group.field_rows %}
<tr class="field-row">
<td class="col-name field-indent">{{ row.field.name }}{{ row.field.area_tan }}反)</td>
{% for cell in row.cells %}
<td>{% if cell %}{{ cell }}{% else %}<span class="zero">-</span>{% endif %}</td>
{% endfor %}
<td>{{ row.total }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td class="col-name">合計</td>
{% for total in fert_totals %}
<td>{{ total }}</td>
{% endfor %}
<td>{{ grand_total }}</td>
</tr>
</tfoot>
</table>
</body>
</html>

View File

@@ -5,6 +5,7 @@ from . import views
router = DefaultRouter() router = DefaultRouter()
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer') router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan') router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
router.register(r'distribution', views.DistributionPlanViewSet, basename='distribution-plan')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),

View File

@@ -11,11 +11,14 @@ from weasyprint import HTML
from apps.fields.models import Field from apps.fields.models import Field
from apps.plans.models import Plan, Variety from apps.plans.models import Plan, Variety
from .models import Fertilizer, FertilizationPlan from .models import Fertilizer, FertilizationPlan, DistributionPlan
from .serializers import ( from .serializers import (
FertilizerSerializer, FertilizerSerializer,
FertilizationPlanSerializer, FertilizationPlanSerializer,
FertilizationPlanWriteSerializer, 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({'error': 'method は nitrogen / even / per_tan のいずれかです'}, status=status.HTTP_400_BAD_REQUEST)
return Response(results) 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

View File

@@ -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 <token>`)。
| メソッド | 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<number|''>('')
// 施肥計画詳細(施肥計画選択後に取得)
const [fertPlanDetail, setFertPlanDetail] = useState<DistributionPlan['fertilization_plan'] | null>(null)
// ローカルグループtempId で管理、保存時にサーバーへ送信)
const [groups, setGroups] = useState<LocalGroup[]>([])
// 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 廃止・インラインバナーに統一。

View File

@@ -0,0 +1,5 @@
import DistributionEditPage from '../../_components/DistributionEditPage';
export default function DistributionEditRoute({ params }: { params: { id: string } }) {
return <DistributionEditPage planId={Number(params.id)} />;
}

View File

@@ -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<number | ''>('');
const [year] = useState<number>(() => {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem('distributionYear') || String(CURRENT_YEAR), 10);
}
return CURRENT_YEAR;
});
// 施肥計画一覧(セレクタ用)
const [fertilizationPlans, setFertilizationPlans] = useState<FertilizationPlan[]>([]);
// 選択中の施肥計画の詳細肥料・entries
const [fertPlanDetail, setFertPlanDetail] = useState<DistributionPlan['fertilization_plan'] | null>(null);
// ローカルグループ状態
const [groups, setGroups] = useState<LocalGroup[]>([]);
const [newGroupName, setNewGroupName] = useState('');
// UI状態
const [saveError, setSaveError] = useState<string | null>(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<number, FieldInfo>();
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<number, FieldInfo>();
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 (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="max-w-5xl mx-auto px-4 py-8 text-gray-500 text-sm">...</main>
</div>
);
}
const fertilizers = fertPlanDetail?.fertilizers || [];
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="max-w-5xl mx-auto px-4 py-8">
{/* ヘッダー */}
<div className="flex items-center gap-2 mb-6">
<button onClick={() => router.push('/distribution')} className="text-sm text-gray-500 hover:text-gray-700">
</button>
</div>
<h1 className="text-xl font-bold text-gray-900 mb-6">
{isEdit ? '分配計画を編集' : '分配計画を新規作成'}
</h1>
{saveError && (
<div className="flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-md px-4 py-3 mb-4 text-sm">
<span className="flex-1">{saveError}</span>
<button onClick={() => setSaveError(null)}><X className="h-4 w-4" /></button>
</div>
)}
{/* 基本情報 */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2 flex-1 min-w-48">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap"></label>
<input
type="text"
value={name}
onChange={e => 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"
/>
</div>
<div className="flex items-center gap-2 flex-1 min-w-64">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap"></label>
<select
value={fertilizationPlanId}
onChange={e => setFertilizationPlanId(e.target.value ? Number(e.target.value) : '')}
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"
>
<option value="">-- --</option>
{fertilizationPlans.map(p => (
<option key={p.id} value={p.id}>
{p.year} {p.name}{p.crop_name}/{p.variety_name}
</option>
))}
</select>
</div>
</div>
</div>
{!fertPlanDetail ? (
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center text-gray-400 text-sm">
</div>
) : (
<>
{/* グループ割り当て */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<h2 className="text-sm font-semibold text-gray-700 mb-4"></h2>
{/* 新規グループ追加 */}
<div className="flex items-center gap-2 mb-4">
<input
type="text"
value={newGroupName}
onChange={e => 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"
/>
<button
onClick={addGroup}
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* グループ一覧 */}
<div className="space-y-3">
{groups.map((group, idx) => (
<div key={group.tempId} className="border border-gray-200 rounded-md overflow-hidden">
{/* グループヘッダー */}
<div className="flex items-center gap-2 bg-green-50 px-3 py-2">
{group.isRenamingName !== undefined ? (
<>
<input
type="text"
value={group.isRenamingName}
onChange={e =>
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
/>
<button
onClick={() => commitRename(group.tempId)}
className="p-1 text-green-700 hover:text-green-900"
>
<Check className="h-4 w-4" />
</button>
</>
) : (
<>
<span className="font-medium text-sm text-gray-800 flex-1">{group.name}</span>
<button
onClick={() => moveGroup(group.tempId, -1)}
disabled={idx === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
onClick={() => moveGroup(group.tempId, 1)}
disabled={idx === groups.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
onClick={() => startRename(group.tempId)}
className="p-1 text-gray-400 hover:text-gray-600"
title="名前変更"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => removeGroup(group.tempId)}
className="p-1 text-gray-400 hover:text-red-600"
title="グループを削除"
>
<X className="h-4 w-4" />
</button>
</>
)}
</div>
{/* グループ内圃場 */}
<div className="px-3 py-2 space-y-1">
{group.fieldIds.length === 0 ? (
<p className="text-xs text-gray-400 italic"></p>
) : (
group.fieldIds.map(fId => {
const fi = getFieldInfo(fId);
const bags = fertilizers.map(fert => getBags(fId, fert.id));
return (
<div key={fId} className="flex items-center gap-2 text-sm">
<button
onClick={() => removeFieldFromGroup(fId, group.tempId)}
className="text-gray-400 hover:text-red-500 flex-shrink-0"
title="グループから外す"
>
<X className="h-3.5 w-3.5" />
</button>
<span className="text-gray-800 font-medium w-32 truncate">{fi.name}</span>
<span className="text-gray-400 text-xs w-16 text-right">{fi.area_tan}</span>
<span className="text-gray-400 text-xs">
{fertilizers.map((fert, i) => (
<span key={fert.id}>
{i > 0 && ' / '}
{fert.name}: {bags[i].toFixed(2)}
</span>
))}
</span>
</div>
);
})
)}
</div>
</div>
))}
</div>
{/* 未割り当て圃場 */}
{unassignedFields.length > 0 && (
<div className="mt-4 border-t border-gray-200 pt-4">
<p className="text-xs font-medium text-gray-500 uppercase mb-2"></p>
<div className="space-y-1">
{unassignedFields.map(fi => (
<div key={fi.id} className="flex items-center gap-2 text-sm">
<span className="text-gray-800 font-medium w-32 truncate">{fi.name}</span>
<span className="text-gray-400 text-xs w-16 text-right">{fi.area_tan}</span>
<select
defaultValue=""
onChange={e => {
if (e.target.value) {
assignFieldToGroup(fi.id, e.target.value);
e.target.value = '';
}
}}
className="border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500"
>
<option value="">...</option>
{groups.map(g => (
<option key={g.tempId} value={g.tempId}>{g.name}</option>
))}
</select>
</div>
))}
</div>
</div>
)}
</div>
{/* 集計プレビュー */}
{(groups.length > 0 || unassignedFields.length > 0) && fertilizers.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<h2 className="text-sm font-semibold text-gray-700 mb-3"></h2>
<div className="overflow-x-auto">
<table className="min-w-full text-sm border-collapse">
<thead>
<tr>
<th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-medium text-gray-600 text-xs"></th>
{fertilizers.map(fert => (
<th key={fert.id} className="border border-gray-200 bg-gray-50 px-3 py-2 text-right font-medium text-gray-600 text-xs">
{fert.name}
</th>
))}
<th className="border border-gray-200 bg-gray-50 px-3 py-2 text-right font-medium text-gray-600 text-xs"></th>
</tr>
</thead>
<tbody>
{groupSummaries.map(g => (
<tr key={g.tempId} className="hover:bg-green-50">
<td className="border border-gray-200 px-3 py-2 font-medium text-gray-800">{g.name}</td>
{fertilizers.map(fert => {
const t = g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
return (
<td key={fert.id} className="border border-gray-200 px-3 py-2 text-right text-gray-700">
{t > 0 ? t.toFixed(2) : <span className="text-gray-300">-</span>}
</td>
);
})}
<td className="border border-gray-200 px-3 py-2 text-right font-medium text-gray-800">
{g.rowTotal.toFixed(2)}
</td>
</tr>
))}
{unassignedSummary.rowTotal > 0 && (
<tr className="bg-yellow-50">
<td className="border border-gray-200 px-3 py-2 text-gray-500 italic"></td>
{fertilizers.map(fert => {
const t = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
return (
<td key={fert.id} className="border border-gray-200 px-3 py-2 text-right text-gray-500">
{t > 0 ? t.toFixed(2) : <span className="text-gray-300">-</span>}
</td>
);
})}
<td className="border border-gray-200 px-3 py-2 text-right text-gray-500">
{unassignedSummary.rowTotal.toFixed(2)}
</td>
</tr>
)}
</tbody>
<tfoot>
<tr className="font-bold bg-gray-50">
<td className="border border-gray-200 px-3 py-2 text-gray-800"></td>
{fertColumnTotals.map(f => (
<td key={f.id} className="border border-gray-200 px-3 py-2 text-right text-gray-800">
{f.total.toFixed(2)}
</td>
))}
<td className="border border-gray-200 px-3 py-2 text-right text-gray-800">
{grandTotal.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
</>
)}
{/* フッターボタン */}
<div className="flex justify-end gap-3">
<button
onClick={() => router.push('/distribution')}
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-100 text-gray-700"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 font-medium"
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DistributionEditPage from '../_components/DistributionEditPage';
export default function DistributionNewPage() {
return <DistributionEditPage />;
}

View File

@@ -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<number>(() => {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
}
return CURRENT_YEAR;
});
const [plans, setPlans] = useState<DistributionPlanListItem[]>([]);
const [loading, setLoading] = useState(true);
const [deleteError, setDeleteError] = useState<string | null>(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 (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="max-w-6xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<FlaskConical className="h-7 w-7 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<button
onClick={() => router.push('/distribution/new')}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* 年度セレクタ */}
<div className="flex items-center gap-3 mb-6">
<label className="text-sm font-medium text-gray-700">:</label>
<select
value={year}
onChange={e => setYear(Number(e.target.value))}
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
{years.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
{deleteError && (
<div className="flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-md px-4 py-3 mb-4 text-sm">
<span className="flex-1">{deleteError}</span>
<button onClick={() => setDeleteError(null)}><X className="h-4 w-4" /></button>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm">...</p>
) : plans.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<FlaskConical className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium mb-1">{year}</p>
<p className="text-sm mb-6"></p>
<button
onClick={() => router.push('/distribution/new')}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
>
+
</button>
</div>
) : (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">/</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{plans.map(plan => (
<tr key={plan.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{plan.name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{plan.fertilization_plan_name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{plan.crop_name} / {plan.variety_name}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.group_count}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.field_count}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handlePdf(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"
title="PDF出力"
>
<FileDown className="h-3.5 w-3.5" />
PDF
</button>
<button
onClick={() => router.push(`/distribution/${plan.id}/edit`)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-blue-300 rounded hover:bg-blue-50 text-blue-700"
title="編集"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(plan.id)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-red-300 rounded hover:bg-red-50 text-red-600"
title="削除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, 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'; import { logout } from '@/lib/api';
export default function Navbar() { export default function Navbar() {
@@ -122,6 +122,17 @@ export default function Navbar() {
<Sprout className="h-4 w-4 mr-2" /> <Sprout className="h-4 w-4 mr-2" />
</button> </button>
<button
onClick={() => router.push('/distribution')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/distribution')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<FlaskConical className="h-4 w-4 mr-2" />
</button>
</div> </div>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">

View File

@@ -91,6 +91,53 @@ export interface FertilizationPlan {
updated_at: string; 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 { export interface MailSender {
id: number; id: number;
type: 'address' | 'domain'; type: 'address' | 'domain';