diff --git a/backend/apps/fertilizer/admin.py b/backend/apps/fertilizer/admin.py
index 49824ad..f19acaa 100644
--- a/backend/apps/fertilizer/admin.py
+++ b/backend/apps/fertilizer/admin.py
@@ -1,5 +1,8 @@
from django.contrib import admin
-from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
+from .models import (
+ Fertilizer, FertilizationPlan, FertilizationEntry,
+ DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
+)
@admin.register(Fertilizer)
@@ -19,25 +22,41 @@ class FertilizationPlanAdmin(admin.ModelAdmin):
inlines = [FertilizationEntryInline]
-class DistributionGroupFieldInline(admin.TabularInline):
- model = DistributionGroupField
+class DeliveryGroupFieldInline(admin.TabularInline):
+ model = DeliveryGroupField
extra = 0
- readonly_fields = ['distribution_plan']
+ readonly_fields = ['delivery_plan']
-class DistributionGroupInline(admin.TabularInline):
- model = DistributionGroup
+class DeliveryGroupInline(admin.TabularInline):
+ model = DeliveryGroup
extra = 0
-@admin.register(DistributionPlan)
-class DistributionPlanAdmin(admin.ModelAdmin):
- list_display = ['name', 'fertilization_plan', 'created_at']
- list_filter = ['fertilization_plan__year']
- inlines = [DistributionGroupInline]
+class DeliveryTripItemInline(admin.TabularInline):
+ model = DeliveryTripItem
+ extra = 0
-@admin.register(DistributionGroup)
-class DistributionGroupAdmin(admin.ModelAdmin):
- list_display = ['name', 'distribution_plan', 'order']
- inlines = [DistributionGroupFieldInline]
+class DeliveryTripInline(admin.TabularInline):
+ model = DeliveryTrip
+ extra = 0
+
+
+@admin.register(DeliveryPlan)
+class DeliveryPlanAdmin(admin.ModelAdmin):
+ list_display = ['name', 'year', 'created_at']
+ list_filter = ['year']
+ inlines = [DeliveryGroupInline, DeliveryTripInline]
+
+
+@admin.register(DeliveryGroup)
+class DeliveryGroupAdmin(admin.ModelAdmin):
+ list_display = ['name', 'delivery_plan', 'order']
+ inlines = [DeliveryGroupFieldInline]
+
+
+@admin.register(DeliveryTrip)
+class DeliveryTripAdmin(admin.ModelAdmin):
+ list_display = ['delivery_plan', 'order', 'name', 'date']
+ inlines = [DeliveryTripItemInline]
diff --git a/backend/apps/fertilizer/migrations/0007_delivery_models.py b/backend/apps/fertilizer/migrations/0007_delivery_models.py
new file mode 100644
index 0000000..f7bb960
--- /dev/null
+++ b/backend/apps/fertilizer/migrations/0007_delivery_models.py
@@ -0,0 +1,127 @@
+# Generated by Django 5.0 on 2026-03-16 07:11
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fertilizer', '0006_fertilizationplan_confirmation'),
+ ('fields', '0006_e1c_chusankan_17_fields'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='DeliveryGroup',
+ 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='表示順')),
+ ],
+ options={
+ 'verbose_name': '配送先グループ',
+ 'verbose_name_plural': '配送先グループ',
+ 'ordering': ['order', 'id'],
+ },
+ ),
+ migrations.CreateModel(
+ name='DeliveryPlan',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('year', models.IntegerField(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)),
+ ],
+ options={
+ 'verbose_name': '運搬計画',
+ 'verbose_name_plural': '運搬計画',
+ 'ordering': ['-year', 'name'],
+ },
+ ),
+ migrations.RemoveField(
+ model_name='distributiongroupfield',
+ name='group',
+ ),
+ migrations.AlterUniqueTogether(
+ name='distributiongroupfield',
+ unique_together=None,
+ ),
+ migrations.RemoveField(
+ model_name='distributiongroupfield',
+ name='distribution_plan',
+ ),
+ migrations.RemoveField(
+ model_name='distributiongroupfield',
+ name='field',
+ ),
+ migrations.RemoveField(
+ model_name='distributionplan',
+ name='fertilization_plan',
+ ),
+ migrations.CreateModel(
+ name='DeliveryGroupField',
+ 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.deliverygroup', verbose_name='グループ')),
+ ('delivery_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.deliveryplan', verbose_name='運搬計画')),
+ ],
+ options={
+ 'verbose_name': 'グループ圃場割り当て',
+ 'verbose_name_plural': 'グループ圃場割り当て',
+ 'ordering': ['field__display_order', 'field__id'],
+ 'unique_together': {('delivery_plan', 'field')},
+ },
+ ),
+ migrations.AddField(
+ model_name='deliverygroup',
+ name='delivery_plan',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='fertilizer.deliveryplan', verbose_name='運搬計画'),
+ ),
+ migrations.CreateModel(
+ name='DeliveryTrip',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('order', models.PositiveIntegerField(default=0, verbose_name='何回目')),
+ ('name', models.CharField(blank=True, max_length=100, verbose_name='名前')),
+ ('date', models.DateField(blank=True, null=True, verbose_name='運搬日')),
+ ('delivery_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trips', to='fertilizer.deliveryplan', verbose_name='運搬計画')),
+ ],
+ options={
+ 'verbose_name': '運搬回',
+ 'verbose_name_plural': '運搬回',
+ 'ordering': ['order', 'id'],
+ },
+ ),
+ migrations.CreateModel(
+ name='DeliveryTripItem',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('bags', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='袋数')),
+ ('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fertilizer.fertilizer', verbose_name='肥料')),
+ ('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
+ ('trip', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fertilizer.deliverytrip', verbose_name='運搬回')),
+ ],
+ options={
+ 'verbose_name': '運搬明細',
+ 'verbose_name_plural': '運搬明細',
+ 'ordering': ['field__display_order', 'field__id', 'fertilizer__name'],
+ 'unique_together': {('trip', 'field', 'fertilizer')},
+ },
+ ),
+ migrations.DeleteModel(
+ name='DistributionGroup',
+ ),
+ migrations.DeleteModel(
+ name='DistributionGroupField',
+ ),
+ migrations.DeleteModel(
+ name='DistributionPlan',
+ ),
+ migrations.AlterUniqueTogether(
+ name='deliverygroup',
+ unique_together={('delivery_plan', 'name')},
+ ),
+ ]
diff --git a/backend/apps/fertilizer/models.py b/backend/apps/fertilizer/models.py
index 2cd5259..3c87975 100644
--- a/backend/apps/fertilizer/models.py
+++ b/backend/apps/fertilizer/models.py
@@ -80,51 +80,48 @@ class FertilizationEntry(models.Model):
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='施肥計画'
- )
+class DeliveryPlan(models.Model):
+ """運搬計画:施肥計画の肥料を軽トラで運ぶ単位で計画・記録する"""
+ year = models.IntegerField(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']
+ verbose_name = '運搬計画'
+ verbose_name_plural = '運搬計画'
+ ordering = ['-year', 'name']
def __str__(self):
- return f"{self.fertilization_plan.year} {self.name}"
+ return f"{self.year} {self.name}"
-class DistributionGroup(models.Model):
- """分配グループ:ある場所にまとめて置く圃場のグループ"""
- distribution_plan = models.ForeignKey(
- DistributionPlan, on_delete=models.CASCADE,
- related_name='groups', verbose_name='分配計画'
+class DeliveryGroup(models.Model):
+ """配送先グループ:まとめて運ぶ圃場のグループ"""
+ delivery_plan = models.ForeignKey(
+ DeliveryPlan, 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']]
+ verbose_name = '配送先グループ'
+ verbose_name_plural = '配送先グループ'
+ unique_together = [['delivery_plan', 'name']]
ordering = ['order', 'id']
def __str__(self):
- return f"{self.distribution_plan} / {self.name}"
+ return f"{self.delivery_plan} / {self.name}"
-class DistributionGroupField(models.Model):
- """圃場のグループへの割り当て(1圃場=1グループ/1分配計画)"""
- distribution_plan = models.ForeignKey(
- DistributionPlan, on_delete=models.CASCADE, verbose_name='分配計画'
+class DeliveryGroupField(models.Model):
+ """圃場のグループへの割り当て(1圃場=1グループ/1運搬計画)"""
+ delivery_plan = models.ForeignKey(
+ DeliveryPlan, on_delete=models.CASCADE, verbose_name='運搬計画'
)
group = models.ForeignKey(
- DistributionGroup, on_delete=models.CASCADE,
+ DeliveryGroup, on_delete=models.CASCADE,
related_name='field_assignments', verbose_name='グループ'
)
field = models.ForeignKey(
@@ -134,8 +131,51 @@ class DistributionGroupField(models.Model):
class Meta:
verbose_name = 'グループ圃場割り当て'
verbose_name_plural = 'グループ圃場割り当て'
- unique_together = [['distribution_plan', 'field']]
+ unique_together = [['delivery_plan', 'field']]
ordering = ['field__display_order', 'field__id']
def __str__(self):
return f"{self.group.name} / {self.field.name}"
+
+
+class DeliveryTrip(models.Model):
+ """運搬回:軽トラ1回分の積載"""
+ delivery_plan = models.ForeignKey(
+ DeliveryPlan, on_delete=models.CASCADE,
+ related_name='trips', verbose_name='運搬計画'
+ )
+ order = models.PositiveIntegerField(default=0, verbose_name='何回目')
+ name = models.CharField(max_length=100, blank=True, verbose_name='名前')
+ date = models.DateField(null=True, blank=True, verbose_name='運搬日')
+
+ class Meta:
+ verbose_name = '運搬回'
+ verbose_name_plural = '運搬回'
+ ordering = ['order', 'id']
+
+ def __str__(self):
+ return f"{self.delivery_plan} / {self.order + 1}回目"
+
+
+class DeliveryTripItem(models.Model):
+ """運搬明細:圃場×肥料単位の袋数"""
+ trip = models.ForeignKey(
+ DeliveryTrip, on_delete=models.CASCADE,
+ related_name='items', verbose_name='運搬回'
+ )
+ field = models.ForeignKey(
+ 'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
+ )
+ fertilizer = models.ForeignKey(
+ Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
+ )
+ bags = models.DecimalField(max_digits=10, decimal_places=4, verbose_name='袋数')
+
+ class Meta:
+ verbose_name = '運搬明細'
+ verbose_name_plural = '運搬明細'
+ unique_together = [['trip', 'field', 'fertilizer']]
+ ordering = ['field__display_order', 'field__id', 'fertilizer__name']
+
+ def __str__(self):
+ return f"{self.trip} / {self.field.name} / {self.fertilizer.name}: {self.bags}袋"
diff --git a/backend/apps/fertilizer/serializers.py b/backend/apps/fertilizer/serializers.py
index 23e9db2..1789370 100644
--- a/backend/apps/fertilizer/serializers.py
+++ b/backend/apps/fertilizer/serializers.py
@@ -1,5 +1,8 @@
from rest_framework import serializers
-from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
+from .models import (
+ Fertilizer, FertilizationPlan, FertilizationEntry,
+ DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
+)
class FertilizerSerializer(serializers.ModelSerializer):
@@ -100,9 +103,9 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
)
-# ─── 分配計画 ────────────────────────────────────────────────────────────
+# ─── 運搬計画 ────────────────────────────────────────────────────────────
-class DistributionGroupFieldSerializer(serializers.ModelSerializer):
+class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source='field.id', read_only=True)
name = serializers.CharField(source='field.name', read_only=True)
area_tan = serializers.DecimalField(
@@ -110,128 +113,163 @@ class DistributionGroupFieldSerializer(serializers.ModelSerializer):
)
class Meta:
- model = DistributionGroupField
+ model = DeliveryGroupField
fields = ['id', 'name', 'area_tan']
-class DistributionGroupReadSerializer(serializers.ModelSerializer):
- fields = DistributionGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
+class DeliveryGroupReadSerializer(serializers.ModelSerializer):
+ fields = DeliveryGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
class Meta:
- model = DistributionGroup
+ model = DeliveryGroup
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 DeliveryTripItemSerializer(serializers.ModelSerializer):
+ field_name = serializers.CharField(source='field.name', read_only=True)
+ fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
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()
- ]
+ model = DeliveryTripItem
+ fields = ['id', 'field', 'field_name', 'fertilizer', 'fertilizer_name', 'bags']
-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()
+class DeliveryTripReadSerializer(serializers.ModelSerializer):
+ items = DeliveryTripItemSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = DeliveryTrip
+ fields = ['id', 'order', 'name', 'date', 'items']
+
+
+class DeliveryPlanListSerializer(serializers.ModelSerializer):
group_count = serializers.SerializerMethodField()
- field_count = serializers.SerializerMethodField()
+ trip_count = serializers.SerializerMethodField()
class Meta:
- model = DistributionPlan
+ model = DeliveryPlan
fields = [
- 'id', 'name', 'fertilization_plan_id', 'fertilization_plan_name',
- 'year', 'variety_name', 'crop_name', 'group_count', 'field_count',
+ 'id', 'year', 'name', 'group_count', 'trip_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()
+ def get_trip_count(self, obj):
+ return obj.trips.count()
-class DistributionPlanReadSerializer(serializers.ModelSerializer):
- fertilization_plan = FertilizationPlanForDistributionSerializer(read_only=True)
- groups = DistributionGroupReadSerializer(many=True, read_only=True)
+class DeliveryPlanReadSerializer(serializers.ModelSerializer):
+ groups = DeliveryGroupReadSerializer(many=True, read_only=True)
+ trips = DeliveryTripReadSerializer(many=True, read_only=True)
unassigned_fields = serializers.SerializerMethodField()
+ available_fertilizers = serializers.SerializerMethodField()
+ all_entries = serializers.SerializerMethodField()
class Meta:
- model = DistributionPlan
- fields = ['id', 'name', 'fertilization_plan', 'groups', 'unassigned_fields', 'created_at', 'updated_at']
+ model = DeliveryPlan
+ fields = [
+ 'id', 'year', 'name', 'groups', 'trips',
+ 'unassigned_fields', 'available_fertilizers', 'all_entries',
+ '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')
+ assigned_ids = DeliveryGroupField.objects.filter(
+ delivery_plan=obj
+ ).values_list('field_id', flat=True)
+ # 年度の施肥計画に含まれる全圃場
+ plan_field_ids = FertilizationEntry.objects.filter(
+ plan__year=obj.year
+ ).values_list('field_id', flat=True).distinct()
+ from apps.fields.models import Field
+ unassigned = Field.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]
+ def get_available_fertilizers(self, obj):
+ fert_ids = FertilizationEntry.objects.filter(
+ plan__year=obj.year
+ ).values_list('fertilizer_id', flat=True).distinct()
+ fertilizers = Fertilizer.objects.filter(id__in=fert_ids).order_by('name')
+ return [{'id': f.id, 'name': f.name} for f in fertilizers]
-class DistributionPlanWriteSerializer(serializers.ModelSerializer):
- fertilization_plan_id = serializers.IntegerField(write_only=True)
+ def get_all_entries(self, obj):
+ """年度の全施肥計画のエントリ(フロントで袋数計算に使用)"""
+ entries = FertilizationEntry.objects.filter(
+ plan__year=obj.year
+ ).select_related('field', 'fertilizer')
+ return [
+ {
+ 'field': e.field_id,
+ 'field_name': e.field.name,
+ 'field_area_tan': str(e.field.area_tan),
+ 'fertilizer': e.fertilizer_id,
+ 'fertilizer_name': e.fertilizer.name,
+ 'bags': str(e.bags),
+ }
+ for e in entries
+ ]
+
+
+class DeliveryPlanWriteSerializer(serializers.ModelSerializer):
groups = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
+ trips = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
class Meta:
- model = DistributionPlan
- fields = ['id', 'name', 'fertilization_plan_id', 'groups']
+ model = DeliveryPlan
+ fields = ['id', 'year', 'name', 'groups', 'trips']
def create(self, validated_data):
groups_data = validated_data.pop('groups', [])
- plan = DistributionPlan.objects.create(**validated_data)
+ trips_data = validated_data.pop('trips', [])
+ plan = DeliveryPlan.objects.create(**validated_data)
self._save_groups(plan, groups_data)
+ self._save_trips(plan, trips_data)
return plan
def update(self, instance, validated_data):
groups_data = validated_data.pop('groups', None)
+ trips_data = validated_data.pop('trips', None)
instance.name = validated_data.get('name', instance.name)
+ instance.year = validated_data.get('year', instance.year)
instance.save()
if groups_data is not None:
instance.groups.all().delete()
self._save_groups(instance, groups_data)
+ if trips_data is not None:
+ instance.trips.all().delete()
+ self._save_trips(instance, trips_data)
return instance
def _save_groups(self, plan, groups_data):
for g_data in groups_data:
- group = DistributionGroup.objects.create(
- distribution_plan=plan,
+ group = DeliveryGroup.objects.create(
+ delivery_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,
+ DeliveryGroupField.objects.create(
+ delivery_plan=plan,
group=group,
field_id=field_id,
)
+
+ def _save_trips(self, plan, trips_data):
+ for t_data in trips_data:
+ trip = DeliveryTrip.objects.create(
+ delivery_plan=plan,
+ order=t_data.get('order', 0),
+ name=t_data.get('name', ''),
+ date=t_data.get('date'),
+ )
+ for item in t_data.get('items', []):
+ DeliveryTripItem.objects.create(
+ trip=trip,
+ field_id=item['field_id'],
+ fertilizer_id=item['fertilizer_id'],
+ bags=item['bags'],
+ )
diff --git a/backend/apps/fertilizer/templates/fertilizer/delivery_pdf.html b/backend/apps/fertilizer/templates/fertilizer/delivery_pdf.html
new file mode 100644
index 0000000..dd8acb5
--- /dev/null
+++ b/backend/apps/fertilizer/templates/fertilizer/delivery_pdf.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+{% for page in trip_pages %}
+{% if not forloop.first %}{% endif %}
+
+運搬計画書 {{ page.trip.order|add:1 }}回目
+
+ {{ plan.year }}年度 「{{ plan.name }}」
+ {% if page.trip.name %}/{{ page.trip.name }}{% endif %}
+ {% if page.trip.date %}/{{ page.trip.date }}{% endif %}
+
+
+
+
+
+ | グループ / 圃場 |
+ {% for fert in page.fertilizers %}
+ {{ fert.name }} (袋) |
+ {% endfor %}
+
+
+
+ {% for group in page.group_rows %}
+ {# グループ合計行 #}
+
+ | ★{{ group.name }} |
+ {% for total in group.totals %}
+ {% if total %}{{ total }}{% else %}-{% endif %} |
+ {% endfor %}
+
+ {# 圃場サブ行 #}
+ {% for row in group.field_rows %}
+
+ | {{ row.field.name }}({{ row.field.area_tan }}反) |
+ {% for cell in row.cells %}
+ {% if cell %}{{ cell }}{% else %}-{% endif %} |
+ {% endfor %}
+
+ {% endfor %}
+ {% endfor %}
+
+
+
+ | 合計 |
+ {% for total in page.fert_totals %}
+ {{ total }} |
+ {% endfor %}
+
+
+
+{% endfor %}
+
+
diff --git a/backend/apps/fertilizer/urls.py b/backend/apps/fertilizer/urls.py
index b7f7728..3ad7fea 100644
--- a/backend/apps/fertilizer/urls.py
+++ b/backend/apps/fertilizer/urls.py
@@ -5,7 +5,7 @@ from . import views
router = DefaultRouter()
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
-router.register(r'distribution', views.DistributionPlanViewSet, basename='distribution-plan')
+router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
urlpatterns = [
path('', include(router.urls)),
diff --git a/backend/apps/fertilizer/views.py b/backend/apps/fertilizer/views.py
index 5357c87..51506aa 100644
--- a/backend/apps/fertilizer/views.py
+++ b/backend/apps/fertilizer/views.py
@@ -18,14 +18,17 @@ from apps.materials.stock_service import (
unconfirm_spreading,
)
from apps.plans.models import Plan, Variety
-from .models import Fertilizer, FertilizationPlan, DistributionPlan
+from .models import (
+ Fertilizer, FertilizationPlan, FertilizationEntry,
+ DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
+)
from .serializers import (
FertilizerSerializer,
FertilizationPlanSerializer,
FertilizationPlanWriteSerializer,
- DistributionPlanListSerializer,
- DistributionPlanReadSerializer,
- DistributionPlanWriteSerializer,
+ DeliveryPlanListSerializer,
+ DeliveryPlanReadSerializer,
+ DeliveryPlanWriteSerializer,
)
@@ -281,126 +284,140 @@ class CalculateView(APIView):
return Response(results)
-class DistributionPlanViewSet(viewsets.ModelViewSet):
+class DeliveryPlanViewSet(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(
+ qs = DeliveryPlan.objects.prefetch_related(
'groups', 'groups__field_assignments', 'groups__field_assignments__field',
- 'fertilization_plan__entries', 'fertilization_plan__entries__field',
- 'fertilization_plan__entries__fertilizer',
- 'distributiongroupfield_set',
+ 'trips', 'trips__items', 'trips__items__field', 'trips__items__fertilizer',
)
year = self.request.query_params.get('year')
if year:
- qs = qs.filter(fertilization_plan__year=year)
+ qs = qs.filter(year=year)
return qs
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
- return DistributionPlanWriteSerializer
+ return DeliveryPlanWriteSerializer
if self.action == 'list':
- return DistributionPlanListSerializer
- return DistributionPlanReadSerializer
+ return DeliveryPlanListSerializer
+ return DeliveryPlanReadSerializer
@action(detail=True, methods=['get'])
def pdf(self, request, pk=None):
- dist_plan = self.get_object()
- fert_plan = dist_plan.fertilization_plan
+ plan = self.get_object()
- # 施肥計画の肥料一覧(名前順)
- fert_ids = fert_plan.entries.values_list('fertilizer_id', flat=True).distinct()
+ # 全tripのitemから使用肥料を収集
+ all_items = DeliveryTripItem.objects.filter(
+ trip__delivery_plan=plan
+ ).select_related('field', 'fertilizer')
+
+ fert_ids = all_items.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
+ # グループ情報: field_id → group_name
+ field_group_map = {}
+ for gf in DeliveryGroupField.objects.filter(
+ delivery_plan=plan
+ ).select_related('group', 'field'):
+ field_group_map[gf.field_id] = gf.group
- # グループ行の構築
- 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')
+ # 回ごとにページを構築
+ trip_pages = []
+ for trip in plan.trips.prefetch_related('items__field', 'items__fertilizer').all():
+ items = trip.items.all()
+ if not items:
+ continue
+
+ # この回の肥料一覧
+ trip_fert_ids = set(item.fertilizer_id for item in items)
+ trip_fertilizers = [f for f in fertilizers if f.id in trip_fert_ids]
+
+ # items を (field_id, fertilizer_id) → bags のマトリクスに変換
+ item_map = {}
+ for item in items:
+ item_map[(item.field_id, item.fertilizer_id)] = item.bags
+
+ # グループごとにまとめる
+ groups_dict = {} # group_name → {'group': group, 'fields': [field, ...]}
+ ungrouped_fields = []
+ for item in items:
+ group = field_group_map.get(item.field_id)
+ if group:
+ if group.name not in groups_dict:
+ groups_dict[group.name] = {'group': group, 'fields': []}
+ if item.field not in groups_dict[group.name]['fields']:
+ groups_dict[group.name]['fields'].append(item.field)
+ else:
+ if item.field not in ungrouped_fields:
+ ungrouped_fields.append(item.field)
+
+ # グループを order 順にソート
+ sorted_groups = sorted(groups_dict.values(), key=lambda g: (g['group'].order, g['group'].id))
+
+ group_rows = []
+ for g_data in sorted_groups:
+ fields_in_group = sorted(g_data['fields'], key=lambda f: (f.display_order, f.id))
+ group_totals = []
+ for fert in trip_fertilizers:
+ total = sum(
+ item_map.get((f.id, fert.id), Decimal('0'))
+ for f in fields_in_group
+ )
+ group_totals.append(total)
+
+ field_rows = []
+ for field in fields_in_group:
+ cells = [item_map.get((field.id, fert.id), '') for fert in trip_fertilizers]
+ field_rows.append({'field': field, 'cells': cells})
+
+ group_rows.append({
+ 'name': g_data['group'].name,
+ 'totals': group_totals,
+ 'field_rows': field_rows,
+ })
+
+ # 未グループ圃場
+ if ungrouped_fields:
+ ungrouped_fields = sorted(ungrouped_fields, key=lambda f: (f.display_order, f.id))
+ ua_totals = [
+ sum(item_map.get((f.id, fert.id), Decimal('0')) for f in ungrouped_fields)
+ for fert in trip_fertilizers
+ ]
+ field_rows = []
+ for field in ungrouped_fields:
+ cells = [item_map.get((field.id, fert.id), '') for fert in trip_fertilizers]
+ field_rows.append({'field': field, 'cells': cells})
+ group_rows.append({
+ 'name': '未グループ',
+ 'totals': ua_totals,
+ 'field_rows': field_rows,
+ })
+
+ fert_totals = [
+ sum(r['totals'][i] for r in group_rows)
+ for i in range(len(trip_fertilizers))
]
- # グループ合計(肥料ごと)
- 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,
+ trip_pages.append({
+ 'trip': trip,
+ 'fertilizers': trip_fertilizers,
+ 'group_rows': group_rows,
+ 'fert_totals': fert_totals,
})
- # 未割り当て圃場
- 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),
+ 'plan': plan,
+ 'trip_pages': trip_pages,
}
- html_string = render_to_string('fertilizer/distribution_pdf.html', context)
+ html_string = render_to_string('fertilizer/delivery_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"'
+ f'attachment; filename="delivery_{plan.year}_{plan.id}.pdf"'
)
return response
diff --git a/frontend/src/app/distribution/[id]/edit/page.tsx b/frontend/src/app/distribution/[id]/edit/page.tsx
index 4902177..71d7ce6 100644
--- a/frontend/src/app/distribution/[id]/edit/page.tsx
+++ b/frontend/src/app/distribution/[id]/edit/page.tsx
@@ -1,5 +1,5 @@
-import DistributionEditPage from '../../_components/DistributionEditPage';
+import DeliveryEditPage from '../../_components/DeliveryEditPage';
-export default function DistributionEditRoute({ params }: { params: { id: string } }) {
- return ;
+export default function DeliveryEditRoute({ params }: { params: { id: string } }) {
+ return ;
}
diff --git a/frontend/src/app/distribution/_components/DeliveryEditPage.tsx b/frontend/src/app/distribution/_components/DeliveryEditPage.tsx
new file mode 100644
index 0000000..07af0a8
--- /dev/null
+++ b/frontend/src/app/distribution/_components/DeliveryEditPage.tsx
@@ -0,0 +1,1061 @@
+'use client';
+
+import { useEffect, useState, useMemo, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import { Plus, X, ChevronUp, ChevronDown, Pencil, Check, Truck, ArrowLeft } from 'lucide-react';
+import Navbar from '@/components/Navbar';
+import { DeliveryPlan, DeliveryAllEntry } from '@/types';
+import { api } from '@/lib/api';
+
+const CURRENT_YEAR = new Date().getFullYear();
+
+// ─── ローカル型定義 ──────────────────────────────────
+
+interface LocalGroup {
+ tempId: string;
+ name: string;
+ order: number;
+ fieldIds: number[];
+ isRenamingName?: string;
+}
+
+interface LocalTripItem {
+ fieldId: number;
+ fertilizerId: number;
+ bags: number;
+}
+
+interface LocalTrip {
+ tempId: string;
+ order: number;
+ name: string;
+ date: string;
+ items: LocalTripItem[];
+}
+
+interface FieldInfo {
+ id: number;
+ name: string;
+ area_tan: string;
+}
+
+interface Props {
+ planId?: number;
+}
+
+export default function DeliveryEditPage({ planId }: Props) {
+ const router = useRouter();
+ const isEdit = planId !== undefined;
+
+ // 基本情報
+ const [name, setName] = useState('');
+ const [year, setYear] = useState(() => {
+ if (typeof window !== 'undefined') {
+ return parseInt(localStorage.getItem('distributionYear') || String(CURRENT_YEAR), 10);
+ }
+ return CURRENT_YEAR;
+ });
+
+ // 施肥計画から取得した全エントリ(年度ベース)
+ const [allEntries, setAllEntries] = useState([]);
+ // 利用可能な肥料一覧
+ const [availableFertilizers, setAvailableFertilizers] = useState<{ id: number; name: string }[]>([]);
+ // 選択中の肥料ID
+ const [selectedFertilizerIds, setSelectedFertilizerIds] = useState>(new Set());
+
+ // ローカルグループ
+ const [groups, setGroups] = useState([]);
+ const [newGroupName, setNewGroupName] = useState('');
+
+ // ローカルTrip
+ const [trips, setTrips] = useState([]);
+
+ // UI
+ const [saveError, setSaveError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
+
+ // ── 初期データ読み込み ──────────────────────────────────
+
+ const loadYearEntries = useCallback(async (y: number) => {
+ try {
+ // 年度の全施肥計画のエントリを取得するために空のプランで詳細取得
+ // → 新規時は計画がないので、施肥計画のエントリを直接取得する
+ const plansRes = await api.get(`/fertilizer/plans/?year=${y}`);
+ const plans = plansRes.data;
+ const entries: DeliveryAllEntry[] = [];
+ const fertMap = new Map();
+ for (const p of plans) {
+ for (const e of p.entries) {
+ entries.push({
+ field: e.field,
+ field_name: e.field_name || '',
+ field_area_tan: e.field_area_tan || '0',
+ fertilizer: e.fertilizer,
+ fertilizer_name: e.fertilizer_name || '',
+ bags: String(e.bags),
+ });
+ if (!fertMap.has(e.fertilizer)) {
+ fertMap.set(e.fertilizer, { id: e.fertilizer, name: e.fertilizer_name || '' });
+ }
+ }
+ }
+ setAllEntries(entries);
+ const ferts = Array.from(fertMap.values()).sort((a, b) => a.name.localeCompare(b.name));
+ setAvailableFertilizers(ferts);
+ return { entries, ferts };
+ } catch (e) {
+ console.error(e);
+ return { entries: [], ferts: [] };
+ }
+ }, []);
+
+ useEffect(() => {
+ const init = async () => {
+ if (isEdit && planId) {
+ try {
+ const detailRes = await api.get(`/fertilizer/delivery/${planId}/`);
+ const detail: DeliveryPlan = detailRes.data;
+ setName(detail.name);
+ setYear(detail.year);
+ setAllEntries(detail.all_entries);
+ setAvailableFertilizers(detail.available_fertilizers);
+
+ // グループを 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),
+ }))
+ );
+
+ // Trips を LocalTrip に変換
+ setTrips(
+ detail.trips.map(t => ({
+ tempId: String(t.id),
+ order: t.order,
+ name: t.name,
+ date: t.date || '',
+ items: t.items.map(item => ({
+ fieldId: item.field,
+ fertilizerId: item.fertilizer,
+ bags: parseFloat(item.bags),
+ })),
+ }))
+ );
+
+ // 選択肥料: tripに含まれる肥料 + 全部
+ const usedFertIds = new Set();
+ for (const t of detail.trips) {
+ for (const item of t.items) {
+ usedFertIds.add(item.fertilizer);
+ }
+ }
+ setSelectedFertilizerIds(
+ usedFertIds.size > 0 ? usedFertIds : new Set(detail.available_fertilizers.map(f => f.id))
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ } else {
+ const { ferts } = await loadYearEntries(year);
+ setSelectedFertilizerIds(new Set(ferts.map(f => f.id)));
+ }
+ setLoading(false);
+ };
+ init();
+ }, [planId]);
+
+ // 年度変更時(新規のみ)
+ useEffect(() => {
+ if (!isEdit && !loading) {
+ loadYearEntries(year).then(({ ferts }) => {
+ setSelectedFertilizerIds(new Set(ferts.map(f => f.id)));
+ setGroups([]);
+ setTrips([]);
+ });
+ }
+ }, [year, isEdit, loading, loadYearEntries]);
+
+ // ── 計算ヘルパー ──────────────────────────────────────
+
+ // 圃場情報マップ
+ const fieldInfoMap = useMemo(() => {
+ const map = new Map();
+ for (const e of allEntries) {
+ if (!map.has(e.field)) {
+ map.set(e.field, { id: e.field, name: e.field_name, area_tan: e.field_area_tan });
+ }
+ }
+ return map;
+ }, [allEntries]);
+
+ const getFieldInfo = useCallback((fieldId: number): FieldInfo =>
+ fieldInfoMap.get(fieldId) ?? { id: fieldId, name: `圃場#${fieldId}`, area_tan: '0' },
+ [fieldInfoMap]
+ );
+
+ // 選択中の肥料
+ const selectedFertilizers = useMemo(
+ () => availableFertilizers.filter(f => selectedFertilizerIds.has(f.id)),
+ [availableFertilizers, selectedFertilizerIds]
+ );
+
+ // (field, fertilizer) → bags マップ(施肥計画ベース)
+ const entryBagsMap = useMemo(() => {
+ const map = new Map();
+ for (const e of allEntries) {
+ const key = `${e.field}-${e.fertilizer}`;
+ map.set(key, (map.get(key) || 0) + parseFloat(e.bags));
+ }
+ return map;
+ }, [allEntries]);
+
+ const getPlannedBags = useCallback((fieldId: number, fertilizerId: number): number => {
+ return entryBagsMap.get(`${fieldId}-${fertilizerId}`) || 0;
+ }, [entryBagsMap]);
+
+ // 全グループの全圃場ID
+ const allGroupFieldIds = useMemo(
+ () => new Set(groups.flatMap(g => g.fieldIds)),
+ [groups]
+ );
+
+ // グループに属する圃場 × 選択肥料 で実際にエントリがある圃場
+ const relevantFieldIds = useMemo(() => {
+ const ids = new Set();
+ for (const e of allEntries) {
+ if (selectedFertilizerIds.has(e.fertilizer)) {
+ ids.add(e.field);
+ }
+ }
+ return ids;
+ }, [allEntries, selectedFertilizerIds]);
+
+ // 未割り当て圃場(グループに入っていない + 選択肥料のエントリがある)
+ const unassignedFields = useMemo(() => {
+ const result: FieldInfo[] = [];
+ for (const fId of relevantFieldIds) {
+ if (!allGroupFieldIds.has(fId)) {
+ result.push(getFieldInfo(fId));
+ }
+ }
+ return result.sort((a, b) => a.name.localeCompare(b.name));
+ }, [relevantFieldIds, allGroupFieldIds, getFieldInfo]);
+
+ // tripに割り当て済みの (field, fertilizer) → 合計bags
+ const assignedBagsMap = useMemo(() => {
+ const map = new Map();
+ for (const trip of trips) {
+ for (const item of trip.items) {
+ const key = `${item.fieldId}-${item.fertilizerId}`;
+ map.set(key, (map.get(key) || 0) + item.bags);
+ }
+ }
+ return map;
+ }, [trips]);
+
+ // 未割り当てのbags(施肥計画の合計 - 既にtripに割り当て済み)
+ const getUnassignedBags = useCallback((fieldId: number, fertilizerId: number): number => {
+ const planned = getPlannedBags(fieldId, fertilizerId);
+ const assigned = assignedBagsMap.get(`${fieldId}-${fertilizerId}`) || 0;
+ return Math.max(0, planned - assigned);
+ }, [getPlannedBags, assignedBagsMap]);
+
+ // trip積載合計
+ const getTripTotal = useCallback((trip: LocalTrip): number => {
+ return trip.items.reduce((sum, item) => sum + item.bags, 0);
+ }, []);
+
+ // tripの肥料ごと合計
+ const getTripFertTotal = useCallback((trip: LocalTrip, fertilizerId: number): number => {
+ return trip.items
+ .filter(item => item.fertilizerId === fertilizerId)
+ .reduce((sum, item) => sum + item.bags, 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
+ )
+ );
+ };
+
+ // ── Trip操作 ──────────────────────────────────────
+
+ const addTrip = () => {
+ const defaultDate = trips.length > 0 ? trips[0].date : '';
+ setTrips(prev => [
+ ...prev,
+ { tempId: crypto.randomUUID(), order: prev.length, name: '', date: defaultDate, items: [] },
+ ]);
+ };
+
+ const removeTrip = (tempId: string) => {
+ setTrips(prev => prev.filter(t => t.tempId !== tempId));
+ };
+
+ const updateTripName = (tempId: string, name: string) => {
+ setTrips(prev => prev.map(t => t.tempId === tempId ? { ...t, name } : t));
+ };
+
+ const updateTripDate = (tempId: string, date: string) => {
+ setTrips(prev => prev.map(t => t.tempId === tempId ? { ...t, date } : t));
+ };
+
+ // 圃場をtripに割り当て(その圃場の選択肥料の未割り当て分全部)
+ const assignFieldToTrip = (fieldId: number, tripTempId: string) => {
+ setTrips(prev => prev.map(t => {
+ if (t.tempId !== tripTempId) return t;
+ const newItems = [...t.items];
+ for (const fert of selectedFertilizers) {
+ const remaining = getUnassignedBags(fieldId, fert.id);
+ if (remaining > 0) {
+ // 既に同じ field+fertilizer がこのtripにあれば加算
+ const existing = newItems.find(
+ item => item.fieldId === fieldId && item.fertilizerId === fert.id
+ );
+ if (existing) {
+ existing.bags += remaining;
+ } else {
+ newItems.push({ fieldId, fertilizerId: fert.id, bags: remaining });
+ }
+ }
+ }
+ return { ...t, items: newItems };
+ }));
+ };
+
+ // 圃場をtripから外す(その圃場の全肥料をtripから削除)
+ const removeFieldFromTrip = (fieldId: number, tripTempId: string) => {
+ setTrips(prev => prev.map(t => {
+ if (t.tempId !== tripTempId) return t;
+ return { ...t, items: t.items.filter(item => item.fieldId !== fieldId) };
+ }));
+ };
+
+ // 残り全部を新しいtripに
+ const assignAllRemaining = () => {
+ const items: LocalTripItem[] = [];
+ // グループ内の圃場を順に
+ for (const group of groups) {
+ for (const fieldId of group.fieldIds) {
+ for (const fert of selectedFertilizers) {
+ const remaining = getUnassignedBags(fieldId, fert.id);
+ if (remaining > 0) {
+ items.push({ fieldId, fertilizerId: fert.id, bags: remaining });
+ }
+ }
+ }
+ }
+ // 未割り当て圃場も
+ for (const fi of unassignedFields) {
+ for (const fert of selectedFertilizers) {
+ const remaining = getUnassignedBags(fi.id, fert.id);
+ if (remaining > 0) {
+ items.push({ fieldId: fi.id, fertilizerId: fert.id, bags: remaining });
+ }
+ }
+ }
+ if (items.length === 0) return;
+ const defaultDate = trips.length > 0 ? trips[0].date : '';
+ setTrips(prev => [
+ ...prev,
+ { tempId: crypto.randomUUID(), order: prev.length, name: '', date: defaultDate, items },
+ ]);
+ };
+
+ // tripの中の圃場IDを取得(表示用・重複なし)
+ const getTripFieldIds = useCallback((trip: LocalTrip): number[] => {
+ const seen = new Set();
+ const result: number[] = [];
+ for (const item of trip.items) {
+ if (!seen.has(item.fieldId)) {
+ seen.add(item.fieldId);
+ result.push(item.fieldId);
+ }
+ }
+ return result;
+ }, []);
+
+ // tripの中の圃場をグループでまとめる
+ const getTripGroupedFields = useCallback((trip: LocalTrip) => {
+ const fieldIds = getTripFieldIds(trip);
+ const fieldGroupMap = new Map(); // fieldId → groupName
+ for (const g of groups) {
+ for (const fId of g.fieldIds) {
+ fieldGroupMap.set(fId, g.name);
+ }
+ }
+
+ const groupedMap = new Map(); // groupName → fieldIds
+ const ungrouped: number[] = [];
+
+ for (const fId of fieldIds) {
+ const groupName = fieldGroupMap.get(fId);
+ if (groupName) {
+ if (!groupedMap.has(groupName)) groupedMap.set(groupName, []);
+ groupedMap.get(groupName)!.push(fId);
+ } else {
+ ungrouped.push(fId);
+ }
+ }
+
+ // groups の order 順で返す
+ const result: { groupName: string; fieldIds: number[] }[] = [];
+ for (const g of groups) {
+ if (groupedMap.has(g.name)) {
+ result.push({ groupName: g.name, fieldIds: groupedMap.get(g.name)! });
+ }
+ }
+ if (ungrouped.length > 0) {
+ result.push({ groupName: '未グループ', fieldIds: ungrouped });
+ }
+ return result;
+ }, [groups, getTripFieldIds]);
+
+ // 圃場を別のtripに移動(元のtripから削除して移動先に追加)
+ const moveFieldToTrip = (fieldId: number, fromTripTempId: string, toTripTempId: string) => {
+ setTrips(prev => {
+ // 元のtripからこの圃場のitemsを取り出す
+ const fromTrip = prev.find(t => t.tempId === fromTripTempId);
+ if (!fromTrip) return prev;
+ const movingItems = fromTrip.items.filter(item => item.fieldId === fieldId);
+ if (movingItems.length === 0) return prev;
+
+ return prev.map(t => {
+ if (t.tempId === fromTripTempId) {
+ // 元のtripから削除
+ return { ...t, items: t.items.filter(item => item.fieldId !== fieldId) };
+ }
+ if (t.tempId === toTripTempId) {
+ // 移動先に追加(同じfield+fertilizerがあれば加算)
+ const newItems = [...t.items];
+ for (const moving of movingItems) {
+ const existing = newItems.find(
+ item => item.fieldId === moving.fieldId && item.fertilizerId === moving.fertilizerId
+ );
+ if (existing) {
+ existing.bags += moving.bags;
+ } else {
+ newItems.push({ ...moving });
+ }
+ }
+ return { ...t, items: newItems };
+ }
+ return t;
+ });
+ });
+ };
+
+ // 圃場をtripから未割り当てに戻す
+ const returnFieldToUnassigned = (fieldId: number, tripTempId: string) => {
+ removeFieldFromTrip(fieldId, tripTempId);
+ };
+
+ // tripのグループ小計
+ const getTripGroupFertTotal = useCallback((trip: LocalTrip, fieldIds: number[], fertilizerId: number): number => {
+ return trip.items
+ .filter(item => fieldIds.includes(item.fieldId) && item.fertilizerId === fertilizerId)
+ .reduce((sum, item) => sum + item.bags, 0);
+ }, []);
+
+ // ── 保存 ──────────────────────────────────────────────
+
+ const handleSave = async () => {
+ setSaveError(null);
+ if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
+
+ setSaving(true);
+ const payload = {
+ year,
+ name: name.trim(),
+ groups: groups.map((g, i) => ({
+ name: g.name,
+ order: i,
+ field_ids: g.fieldIds,
+ })),
+ trips: trips.map((t, i) => ({
+ order: i,
+ name: t.name,
+ date: t.date || null,
+ items: t.items.map(item => ({
+ field_id: item.fieldId,
+ fertilizer_id: item.fertilizerId,
+ bags: item.bags.toFixed(4),
+ })),
+ })),
+ };
+
+ try {
+ if (isEdit) {
+ await api.put(`/fertilizer/delivery/${planId}/`, payload);
+ } else {
+ await api.post('/fertilizer/delivery/', 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 (
+
+
+ 読み込み中...
+
+ );
+ }
+
+ // 未割り当てのtripに入っていない圃場×肥料があるか
+ const hasUnassignedBags = (() => {
+ for (const g of groups) {
+ for (const fId of g.fieldIds) {
+ for (const fert of selectedFertilizers) {
+ if (getUnassignedBags(fId, fert.id) > 0) return true;
+ }
+ }
+ }
+ for (const fi of unassignedFields) {
+ for (const fert of selectedFertilizers) {
+ if (getUnassignedBags(fi.id, fert.id) > 0) return true;
+ }
+ }
+ return false;
+ })();
+
+ return (
+
+
+
+ {/* ヘッダー */}
+
+
+
+ {isEdit ? '運搬計画を編集' : '運搬計画を新規作成'}
+
+
+ {saveError && (
+
+ {saveError}
+
+
+ )}
+
+ {/* 基本情報 */}
+
+
+
+
+ setName(e.target.value)}
+ placeholder="例: 2026春 肥料運搬"
+ 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"
+ />
+
+
+
+
+
+
+
+
+ {allEntries.length === 0 ? (
+
+ {year}年の施肥計画がありません。先に施肥計画を作成してください。
+
+ ) : (
+ <>
+ {/* 対象肥料チェックボックス */}
+
+
対象肥料
+
+ {availableFertilizers.map(fert => (
+
+ ))}
+
+
+
+ {/* グループ定義 */}
+
+
グループ定義
+
+ {/* 新規グループ追加 */}
+
+
setNewGroupName(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && addGroup()}
+ placeholder="新規グループ名"
+ className="border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500 w-48"
+ />
+
+
+
+ {/* グループ一覧 */}
+
+ {groups.map((group, idx) => (
+
+
+ {group.isRenamingName !== undefined ? (
+ <>
+
+ setGroups(prev =>
+ prev.map(g =>
+ g.tempId === group.tempId ? { ...g, isRenamingName: e.target.value } : g
+ )
+ )
+ }
+ onKeyDown={e => e.key === 'Enter' && commitRename(group.tempId)}
+ className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500"
+ autoFocus
+ />
+
+ >
+ ) : (
+ <>
+
{group.name}
+
{group.fieldIds.length}圃場
+
+
+
+
+ >
+ )}
+
+
+ {group.fieldIds.length === 0 ? (
+
圃場が割り当てられていません
+ ) : (
+ group.fieldIds.map(fId => {
+ const fi = getFieldInfo(fId);
+ return (
+
+
+ {fi.name}
+
+ {selectedFertilizers.map((fert, i) => {
+ const bags = getPlannedBags(fId, fert.id);
+ if (bags === 0) return null;
+ return (
+
+ {i > 0 && ' / '}
+ {fert.name}: {bags.toFixed(2)}
+
+ );
+ })}
+
+
+ );
+ })
+ )}
+
+
+ ))}
+
+
+ {/* 未割り当て圃場 */}
+ {unassignedFields.length > 0 && (
+
+
未割り当て圃場
+
+ {unassignedFields.map((fi, idx) => (
+
+ {fi.name}
+
+
+ ))}
+
+
+ )}
+
+
+ {/* 運搬回 */}
+
+
+
運搬回
+
+ {hasUnassignedBags && (
+
+ )}
+
+
+
+
+ {/* 未割り当てサマリー */}
+ {hasUnassignedBags && (
+
+
未割り当て(どの回にも入っていない)
+
+ {groups.map(group => {
+ const groupItems: { fId: number; fert: typeof selectedFertilizers[0]; remaining: number }[] = [];
+ for (const fId of group.fieldIds) {
+ for (const fert of selectedFertilizers) {
+ const r = getUnassignedBags(fId, fert.id);
+ if (r > 0) groupItems.push({ fId, fert, remaining: r });
+ }
+ }
+ if (groupItems.length === 0) return null;
+
+ // グループの肥料ごと合計
+ const fertTotals = selectedFertilizers.map(f => ({
+ name: f.name,
+ total: groupItems.filter(i => i.fert.id === f.id).reduce((s, i) => s + i.remaining, 0),
+ })).filter(f => f.total > 0);
+
+ return (
+
+ {group.name}:
+ {fertTotals.map((f, i) => (
+ {i > 0 ? ', ' : ' '}{f.name} {f.total.toFixed(1)}
+ ))}
+
+ );
+ })}
+
+
+ )}
+
+ {trips.map((trip, tripIdx) => {
+ const tripGrouped = getTripGroupedFields(trip);
+ const tripTotal = getTripTotal(trip);
+
+ return (
+
+ {/* Trip ヘッダー */}
+
+ {tripIdx + 1}回目
+ updateTripName(trip.tempId, e.target.value)}
+ placeholder="名前(任意)"
+ className="border border-gray-300 rounded px-2 py-1 text-sm flex-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
+ />
+ updateTripDate(trip.tempId, e.target.value)}
+ className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
+ />
+
+ 積載: {tripTotal.toFixed(1)}袋
+
+ {selectedFertilizers.length > 0 && (
+
+ ({selectedFertilizers.map((f, i) => {
+ const t = getTripFertTotal(trip, f.id);
+ if (t === 0) return null;
+ return {i > 0 ? ' ' : ''}{f.name}:{t.toFixed(1)};
+ })})
+
+ )}
+
+
+
+ {/* Trip 内容 */}
+
+ {trip.items.length === 0 ? (
+
圃場が割り当てられていません
+ ) : (
+
+ {tripGrouped.map(({ groupName, fieldIds }) => {
+ const groupFertTotals = selectedFertilizers.map(f => ({
+ id: f.id,
+ name: f.name,
+ total: getTripGroupFertTotal(trip, fieldIds, f.id),
+ })).filter(f => f.total > 0);
+
+ return (
+
+ {/* グループ小計行 */}
+
+ ★ {groupName}
+
+ {groupFertTotals.map((f, i) => (
+ {i > 0 ? ' ' : ''}{f.name}: {f.total.toFixed(2)}
+ ))}
+
+
+ {/* 圃場行 */}
+ {fieldIds.map(fId => {
+ const fi = getFieldInfo(fId);
+ const otherTrips = trips.filter(t => t.tempId !== trip.tempId);
+ return (
+
+ {fi.name}
+
+ {selectedFertilizers.map((fert, i) => {
+ const item = trip.items.find(
+ it => it.fieldId === fId && it.fertilizerId === fert.id
+ );
+ if (!item) return null;
+ return (
+
+ {i > 0 && ' / '}
+ {fert.name}: {item.bags.toFixed(2)}
+
+ );
+ })}
+
+
+
+ );
+ })}
+
+ );
+ })}
+
+ )}
+
+ {/* このtripに圃場を追加 */}
+ {hasUnassignedBags && (
+
+
+
+ )}
+
+
+ );
+ })}
+
+ >
+ )}
+
+ {/* フッターボタン */}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/distribution/_components/DistributionEditPage.tsx b/frontend/src/app/distribution/_components/DistributionEditPage.tsx
deleted file mode 100644
index 8f3fe91..0000000
--- a/frontend/src/app/distribution/_components/DistributionEditPage.tsx
+++ /dev/null
@@ -1,651 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { useRouter } from 'next/navigation';
-import { Plus, X, ChevronUp, ChevronDown, Pencil, Check } from 'lucide-react';
-import Navbar from '@/components/Navbar';
-import { DistributionPlan, FertilizationPlan } from '@/types';
-import { api } from '@/lib/api';
-
-const CURRENT_YEAR = new Date().getFullYear();
-
-// ローカル管理用のグループ型(ID未採番の新規グループも持てる)
-interface LocalGroup {
- tempId: string;
- name: string;
- order: number;
- fieldIds: number[];
- isRenamingName?: string; // 名前変更中の一時値
-}
-
-interface FieldInfo {
- id: number;
- name: string;
- area_tan: string;
-}
-
-interface Props {
- planId?: number; // 編集時のみ
-}
-
-export default function DistributionEditPage({ planId }: Props) {
- const router = useRouter();
- const isEdit = planId !== undefined;
-
- // 基本情報
- const [name, setName] = useState('');
- const [fertilizationPlanId, setFertilizationPlanId] = useState('');
- const [year] = useState(() => {
- if (typeof window !== 'undefined') {
- return parseInt(localStorage.getItem('distributionYear') || String(CURRENT_YEAR), 10);
- }
- return CURRENT_YEAR;
- });
-
- // 施肥計画一覧(セレクタ用)
- const [fertilizationPlans, setFertilizationPlans] = useState([]);
- // 選択中の施肥計画の詳細(肥料・entries)
- const [fertPlanDetail, setFertPlanDetail] = useState(null);
-
- // ローカルグループ状態
- const [groups, setGroups] = useState([]);
- const [newGroupName, setNewGroupName] = useState('');
-
- // UI状態
- const [saveError, setSaveError] = useState(null);
- const [saving, setSaving] = useState(false);
- const [loading, setLoading] = useState(true);
-
- // ── 初期データ読み込み ──────────────────────────────────
-
- useEffect(() => {
- const init = async () => {
- try {
- // 施肥計画一覧を全年度取得(分配計画のベースになる)
- const res = await api.get('/fertilizer/plans/');
- setFertilizationPlans(res.data);
- } catch (e) {
- console.error(e);
- }
-
- if (isEdit && planId) {
- try {
- // 既存の分配計画を読み込む
- const detailRes = await api.get(`/fertilizer/distribution/${planId}/`);
- const detail: DistributionPlan = detailRes.data;
- setName(detail.name);
- setFertilizationPlanId(detail.fertilization_plan.id);
- setFertPlanDetail(detail.fertilization_plan);
- // グループを LocalGroup 形式に変換
- setGroups(
- detail.groups.map((g, i) => ({
- tempId: String(g.id),
- name: g.name,
- order: g.order ?? i,
- fieldIds: g.fields.map(f => f.id),
- }))
- );
- } catch (e) {
- console.error(e);
- }
- }
- setLoading(false);
- };
- init();
- }, [planId]);
-
- // 施肥計画が変わったら詳細を取得
- useEffect(() => {
- if (!fertilizationPlanId) {
- setFertPlanDetail(null);
- if (!isEdit) setGroups([]);
- return;
- }
- if (isEdit && fertPlanDetail?.id === fertilizationPlanId) return;
-
- const fetchDetail = async () => {
- try {
- const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`);
- const data: FertilizationPlan = res.data;
- // FertilizationPlanForDistributionSerializer と同じ構造に合わせる
- const ferts = Array.from(
- new Map(
- data.entries.map(e => [e.fertilizer, { id: e.fertilizer, name: e.fertilizer_name || '' }])
- ).values()
- ).sort((a, b) => a.name.localeCompare(b.name));
- setFertPlanDetail({
- id: data.id,
- name: data.name,
- year: data.year,
- variety_name: data.variety_name,
- crop_name: data.crop_name,
- fertilizers: ferts,
- entries: data.entries.map(e => ({
- field: e.field,
- fertilizer: e.fertilizer,
- bags: String(e.bags),
- })),
- });
- if (!isEdit) setGroups([]);
- } catch (e) {
- console.error(e);
- }
- };
- fetchDetail();
- }, [fertilizationPlanId]);
-
- // ── 計算ヘルパー ──────────────────────────────────────
-
- // 全圃場一覧(施肥計画のentries に含まれる圃場)
- const allPlanFields: FieldInfo[] = (() => {
- if (!fertPlanDetail) return [];
- const seen = new Map();
- for (const e of fertPlanDetail.entries) {
- if (!seen.has(e.field)) {
- // field名は後述の fertilizationPlans から取る
- seen.set(e.field, { id: e.field, name: String(e.field), area_tan: '0' });
- }
- }
- return Array.from(seen.values());
- })();
-
- // fertilizationPlans から field情報を取得(FertilizationPlanSerializer の entries に field_name が含まれる)
- const fieldInfoMap = (() => {
- const map = new Map();
- if (!fertPlanDetail) return map;
- const plan = fertilizationPlans.find(p => p.id === fertPlanDetail.id);
- if (plan) {
- for (const e of plan.entries) {
- if (e.field && !map.has(e.field)) {
- map.set(e.field, {
- id: e.field,
- name: e.field_name || String(e.field),
- area_tan: e.field_area_tan || '0',
- });
- }
- }
- }
- return map;
- })();
-
- const getFieldInfo = (fieldId: number): FieldInfo =>
- fieldInfoMap.get(fieldId) ?? { id: fieldId, name: `圃場#${fieldId}`, area_tan: '0' };
-
- // 割り当て済みフィールドIDセット
- const assignedFieldIds = new Set(groups.flatMap(g => g.fieldIds));
-
- // 未割り当て圃場
- const unassignedFields = fertPlanDetail
- ? Array.from(
- new Map(
- fertPlanDetail.entries
- .map(e => e.field)
- .filter(id => !assignedFieldIds.has(id))
- .map(id => [id, getFieldInfo(id)])
- ).values()
- )
- : [];
-
- // bags取得
- const getBags = (fieldId: number, fertilizerId: number): number => {
- if (!fertPlanDetail) return 0;
- const entry = fertPlanDetail.entries.find(
- e => e.field === fieldId && e.fertilizer === fertilizerId
- );
- return entry ? parseFloat(entry.bags) : 0;
- };
-
- // グループごとの集計
- const groupSummaries = groups.map(g => {
- const fertTotals = (fertPlanDetail?.fertilizers || []).map(fert => ({
- fertilizerId: fert.id,
- fertilizerName: fert.name,
- total: g.fieldIds.reduce((sum, fId) => sum + getBags(fId, fert.id), 0),
- }));
- const rowTotal = fertTotals.reduce((s, f) => s + f.total, 0);
- return { ...g, fertTotals, rowTotal };
- });
-
- // 未割り当てグループの集計
- const unassignedSummary = {
- fertTotals: (fertPlanDetail?.fertilizers || []).map(fert => ({
- fertilizerId: fert.id,
- fertilizerName: fert.name,
- total: unassignedFields.reduce((sum, f) => sum + getBags(f.id, fert.id), 0),
- })),
- rowTotal: 0 as number,
- };
- unassignedSummary.rowTotal = unassignedSummary.fertTotals.reduce((s, f) => s + f.total, 0);
-
- // 肥料合計行
- const fertColumnTotals = (fertPlanDetail?.fertilizers || []).map(fert => {
- const groupTotal = groupSummaries.reduce(
- (sum, g) => sum + (g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0),
- 0
- );
- const unassignedTotal = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
- return { id: fert.id, total: groupTotal + unassignedTotal };
- });
- const grandTotal = fertColumnTotals.reduce((s, f) => s + f.total, 0);
-
- // ── グループ操作 ──────────────────────────────────────
-
- const addGroup = () => {
- const n = newGroupName.trim();
- if (!n) return;
- if (groups.some(g => g.name === n)) {
- setSaveError(`グループ名「${n}」はすでに存在します`);
- return;
- }
- setSaveError(null);
- setGroups(prev => [
- ...prev,
- { tempId: crypto.randomUUID(), name: n, order: prev.length, fieldIds: [] },
- ]);
- setNewGroupName('');
- };
-
- const removeGroup = (tempId: string) => {
- setGroups(prev => prev.filter(g => g.tempId !== tempId));
- };
-
- const moveGroup = (tempId: string, dir: -1 | 1) => {
- setGroups(prev => {
- const idx = prev.findIndex(g => g.tempId === tempId);
- if (idx < 0 || idx + dir < 0 || idx + dir >= prev.length) return prev;
- const next = [...prev];
- [next[idx], next[idx + dir]] = [next[idx + dir], next[idx]];
- return next.map((g, i) => ({ ...g, order: i }));
- });
- };
-
- const startRename = (tempId: string) => {
- setGroups(prev =>
- prev.map(g => (g.tempId === tempId ? { ...g, isRenamingName: g.name } : g))
- );
- };
-
- const commitRename = (tempId: string) => {
- setGroups(prev =>
- prev.map(g => {
- if (g.tempId !== tempId) return g;
- const newName = (g.isRenamingName || '').trim();
- if (!newName || newName === g.name) return { ...g, isRenamingName: undefined };
- if (prev.some(other => other.tempId !== tempId && other.name === newName)) {
- setSaveError(`グループ名「${newName}」はすでに存在します`);
- return { ...g, isRenamingName: undefined };
- }
- return { ...g, name: newName, isRenamingName: undefined };
- })
- );
- };
-
- const assignFieldToGroup = (fieldId: number, groupTempId: string) => {
- setGroups(prev =>
- prev.map(g => {
- if (g.tempId === groupTempId) {
- return { ...g, fieldIds: [...g.fieldIds, fieldId] };
- }
- return { ...g, fieldIds: g.fieldIds.filter(id => id !== fieldId) };
- })
- );
- };
-
- const removeFieldFromGroup = (fieldId: number, groupTempId: string) => {
- setGroups(prev =>
- prev.map(g =>
- g.tempId === groupTempId ? { ...g, fieldIds: g.fieldIds.filter(id => id !== fieldId) } : g
- )
- );
- };
-
- // ── 保存 ──────────────────────────────────────────────
-
- const handleSave = async () => {
- setSaveError(null);
- if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
- if (!fertilizationPlanId) { setSaveError('施肥計画を選択してください'); return; }
-
- setSaving(true);
- const payload = {
- name: name.trim(),
- fertilization_plan_id: fertilizationPlanId,
- groups: groups.map((g, i) => ({
- name: g.name,
- order: i,
- field_ids: g.fieldIds,
- })),
- };
-
- try {
- if (isEdit) {
- await api.put(`/fertilizer/distribution/${planId}/`, payload);
- } else {
- await api.post('/fertilizer/distribution/', payload);
- }
- setSaving(false);
- router.push('/distribution');
- } catch (e: unknown) {
- setSaving(false);
- const axiosErr = e as { response?: { data?: unknown } };
- const errData = axiosErr?.response?.data;
- setSaveError(errData ? JSON.stringify(errData) : '保存に失敗しました');
- }
- };
-
- // ── レンダリング ──────────────────────────────────────
-
- if (loading) {
- return (
-
-
- 読み込み中...
-
- );
- }
-
- const fertilizers = fertPlanDetail?.fertilizers || [];
-
- return (
-
-
-
- {/* ヘッダー */}
-
-
-
-
-
- {isEdit ? '分配計画を編集' : '分配計画を新規作成'}
-
-
- {saveError && (
-
- {saveError}
-
-
- )}
-
- {/* 基本情報 */}
-
-
-
-
- setName(e.target.value)}
- placeholder="例: 2025年コシヒカリ 分配計画"
- className="flex-1 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
- />
-
-
-
-
-
-
-
-
- {!fertPlanDetail ? (
-
- 施肥計画を選択するとグループ割り当て画面が表示されます
-
- ) : (
- <>
- {/* グループ割り当て */}
-
-
グループ割り当て
-
- {/* 新規グループ追加 */}
-
-
setNewGroupName(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && addGroup()}
- placeholder="新規グループ名"
- className="border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500 w-48"
- />
-
-
-
- {/* グループ一覧 */}
-
- {groups.map((group, idx) => (
-
- {/* グループヘッダー */}
-
- {group.isRenamingName !== undefined ? (
- <>
-
- setGroups(prev =>
- prev.map(g =>
- g.tempId === group.tempId ? { ...g, isRenamingName: e.target.value } : g
- )
- )
- }
- onKeyDown={e => e.key === 'Enter' && commitRename(group.tempId)}
- className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500"
- autoFocus
- />
-
- >
- ) : (
- <>
-
{group.name}
-
-
-
-
- >
- )}
-
- {/* グループ内圃場 */}
-
- {group.fieldIds.length === 0 ? (
-
圃場が割り当てられていません
- ) : (
- group.fieldIds.map(fId => {
- const fi = getFieldInfo(fId);
- const bags = fertilizers.map(fert => getBags(fId, fert.id));
- return (
-
-
- {fi.name}
- {fi.area_tan}反
-
- {fertilizers.map((fert, i) => (
-
- {i > 0 && ' / '}
- {fert.name}: {bags[i].toFixed(2)}袋
-
- ))}
-
-
- );
- })
- )}
-
-
- ))}
-
-
- {/* 未割り当て圃場 */}
- {unassignedFields.length > 0 && (
-
-
未割り当て圃場
-
- {unassignedFields.map((fi, idx) => (
-
- {fi.name}
- {fi.area_tan}反
-
-
- ))}
-
-
- )}
-
-
- {/* 集計プレビュー */}
- {(groups.length > 0 || unassignedFields.length > 0) && fertilizers.length > 0 && (
-
-
集計プレビュー
-
-
-
-
- | グループ |
- {fertilizers.map(fert => (
-
- {fert.name}
- |
- ))}
- 合計(袋) |
-
-
-
- {groupSummaries.map(g => (
-
- | {g.name} |
- {fertilizers.map(fert => {
- const t = g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
- return (
-
- {t > 0 ? t.toFixed(2) : -}
- |
- );
- })}
-
- {g.rowTotal.toFixed(2)}
- |
-
- ))}
- {unassignedSummary.rowTotal > 0 && (
-
- | 未割り当て |
- {fertilizers.map(fert => {
- const t = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
- return (
-
- {t > 0 ? t.toFixed(2) : -}
- |
- );
- })}
-
- {unassignedSummary.rowTotal.toFixed(2)}
- |
-
- )}
-
-
-
- | 合計 |
- {fertColumnTotals.map(f => (
-
- {f.total.toFixed(2)}
- |
- ))}
-
- {grandTotal.toFixed(2)}
- |
-
-
-
-
-
- )}
- >
- )}
-
- {/* フッターボタン */}
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/app/distribution/new/page.tsx b/frontend/src/app/distribution/new/page.tsx
index 96525b8..49f2892 100644
--- a/frontend/src/app/distribution/new/page.tsx
+++ b/frontend/src/app/distribution/new/page.tsx
@@ -1,5 +1,5 @@
-import DistributionEditPage from '../_components/DistributionEditPage';
+import DeliveryEditPage from '../_components/DeliveryEditPage';
-export default function DistributionNewPage() {
- return ;
+export default function DeliveryNewPage() {
+ return ;
}
diff --git a/frontend/src/app/distribution/page.tsx b/frontend/src/app/distribution/page.tsx
index 6696bad..534c4c6 100644
--- a/frontend/src/app/distribution/page.tsx
+++ b/frontend/src/app/distribution/page.tsx
@@ -2,15 +2,15 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
-import { FlaskConical, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
+import { Truck, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
-import { DistributionPlanListItem } from '@/types';
+import { DeliveryPlanListItem } from '@/types';
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_KEY = 'distributionYear';
-export default function DistributionListPage() {
+export default function DeliveryListPage() {
const router = useRouter();
const [year, setYear] = useState(() => {
if (typeof window !== 'undefined') {
@@ -18,7 +18,7 @@ export default function DistributionListPage() {
}
return CURRENT_YEAR;
});
- const [plans, setPlans] = useState([]);
+ const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [deleteError, setDeleteError] = useState(null);
@@ -32,7 +32,7 @@ export default function DistributionListPage() {
const fetchPlans = async () => {
setLoading(true);
try {
- const res = await api.get(`/fertilizer/distribution/?year=${year}`);
+ const res = await api.get(`/fertilizer/delivery/?year=${year}`);
setPlans(res.data);
} catch (e) {
console.error(e);
@@ -44,7 +44,7 @@ export default function DistributionListPage() {
const handleDelete = async (id: number) => {
setDeleteError(null);
try {
- await api.delete(`/fertilizer/distribution/${id}/`);
+ await api.delete(`/fertilizer/delivery/${id}/`);
setPlans(prev => prev.filter(p => p.id !== id));
} catch (e) {
console.error(e);
@@ -54,11 +54,11 @@ export default function DistributionListPage() {
const handlePdf = async (id: number, planName: string) => {
try {
- const res = await api.get(`/fertilizer/distribution/${id}/pdf/`, { responseType: 'blob' });
+ const res = await api.get(`/fertilizer/delivery/${id}/pdf/`, { responseType: 'blob' });
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
- a.download = `distribution_${planName}.pdf`;
+ a.download = `delivery_${planName}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
@@ -72,8 +72,8 @@ export default function DistributionListPage() {
-
-
分配計画
+
+ 運搬計画