分配計画機能を実装
施肥計画の圃場を配置場所単位でグループ化し、グループ×肥料の集計表を 表示・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>
This commit is contained in:
28
CLAUDE.md
28
CLAUDE.md
@@ -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 (施肥エントリ・中間テーブル)
|
|||||||
- 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/`
|
- 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)
|
- 自動計算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)
|
||||||
|
- 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/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` 必須)をトラブルシューティングに追加
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
241
document/14_マスタードキュメント_分配計画編.md
Normal file
241
document/14_マスタードキュメント_分配計画編.md
Normal 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 廃止・インラインバナーに統一。
|
||||||
5
frontend/src/app/distribution/[id]/edit/page.tsx
Normal file
5
frontend/src/app/distribution/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import DistributionEditPage from '../../_components/DistributionEditPage';
|
||||||
|
|
||||||
|
export default function DistributionEditRoute({ params }: { params: { id: string } }) {
|
||||||
|
return <DistributionEditPage planId={Number(params.id)} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/app/distribution/new/page.tsx
Normal file
5
frontend/src/app/distribution/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import DistributionEditPage from '../_components/DistributionEditPage';
|
||||||
|
|
||||||
|
export default function DistributionNewPage() {
|
||||||
|
return <DistributionEditPage />;
|
||||||
|
}
|
||||||
180
frontend/src/app/distribution/page.tsx
Normal file
180
frontend/src/app/distribution/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user