Add rice transplant planning feature

This commit is contained in:
akira
2026-04-04 17:26:55 +09:00
parent f236fe2f90
commit 0c57dd7886
15 changed files with 1458 additions and 13 deletions

View File

@@ -107,6 +107,7 @@ ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \
| 気象データ | `document/12_マスタードキュメント_気象データ編.md` | | 気象データ | `document/12_マスタードキュメント_気象データ編.md` |
| 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` | | 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` |
| 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` | | 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` |
| 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` |
| データモデル全体 | `document/03_データ仕様書.md` | | データモデル全体 | `document/03_データ仕様書.md` |
--- ---

View File

@@ -1,6 +1,6 @@
# 現在の作業状況 # 現在の作業状況
> **最終更新**: 2026-03-16 > **最終更新**: 2026-04-04
> **現在のフェーズ**: Phase 1 (MVP) - 全タスク完了、Phase 2 移行準備中 > **現在のフェーズ**: Phase 1 (MVP) - 全タスク完了、Phase 2 移行準備中
## 実装済み機能Phase 1 - MVP ## 実装済み機能Phase 1 - MVP
@@ -34,6 +34,11 @@
- 軽トラ1回分単位、グループ一括割り当て、回間移動 - 軽トラ1回分単位、グループ一括割り当て、回間移動
- マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md` - マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md`
12. **作業記録索引**: `apps/workrecords`、運搬/散布の自動upsert 12. **作業記録索引**: `apps/workrecords`、運搬/散布の自動upsert
13. **田植え計画**MVP実装:
- 年度×品種単位で苗箱枚数・種もみ使用量を計画
- 作物単位の種もみ在庫kg、品種単位の反当苗箱枚数デフォルト
- 作付け計画から候補圃場を自動取得
- マスタードキュメント: `document/16_マスタードキュメント_田植え計画編.md`
## 既知の課題・技術的負債 ## 既知の課題・技術的負債

View File

@@ -0,0 +1,59 @@
# Generated by Django 5.2 on 2026-04-04 00:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fields', '0006_e1c_chusankan_17_fields'),
('plans', '0004_crop_base_temp'),
]
operations = [
migrations.AddField(
model_name='crop',
name='seed_inventory_kg',
field=models.DecimalField(decimal_places=3, default=0, max_digits=10, verbose_name='種もみ在庫(kg)'),
),
migrations.AddField(
model_name='variety',
name='default_seedling_boxes_per_tan',
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数デフォルト'),
),
migrations.CreateModel(
name='RiceTransplantPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='計画名')),
('year', models.IntegerField(verbose_name='年度')),
('default_seed_grams_per_box', models.DecimalField(decimal_places=2, default=0, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)デフォルト')),
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('variety', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rice_transplant_plans', to='plans.variety', verbose_name='品種')),
],
options={
'verbose_name': '田植え計画',
'verbose_name_plural': '田植え計画',
'ordering': ['-year', 'variety'],
},
),
migrations.CreateModel(
name='RiceTransplantEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('seedling_boxes_per_tan', models.DecimalField(decimal_places=2, max_digits=6, verbose_name='反当苗箱枚数')),
('seed_grams_per_box', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rice_transplant_entries', to='fields.field', verbose_name='圃場')),
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='plans.ricetransplantplan', verbose_name='田植え計画')),
],
options={
'verbose_name': '田植え計画エントリ',
'verbose_name_plural': '田植え計画エントリ',
'ordering': ['field__display_order', 'field__id'],
'unique_together': {('plan', 'field')},
},
),
]

View File

@@ -5,6 +5,12 @@ from apps.fields.models import Field
class Crop(models.Model): class Crop(models.Model):
name = models.CharField(max_length=100, unique=True, verbose_name="作物名") name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)") base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)")
seed_inventory_kg = models.DecimalField(
max_digits=10,
decimal_places=3,
default=0,
verbose_name="種もみ在庫(kg)",
)
class Meta: class Meta:
verbose_name = "作物マスタ" verbose_name = "作物マスタ"
@@ -17,6 +23,12 @@ class Crop(models.Model):
class Variety(models.Model): class Variety(models.Model):
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物") crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物")
name = models.CharField(max_length=100, verbose_name="品種名") name = models.CharField(max_length=100, verbose_name="品種名")
default_seedling_boxes_per_tan = models.DecimalField(
max_digits=6,
decimal_places=2,
default=0,
verbose_name="反当苗箱枚数デフォルト",
)
class Meta: class Meta:
verbose_name = "品種マスタ" verbose_name = "品種マスタ"
@@ -42,3 +54,65 @@ class Plan(models.Model):
def __str__(self): def __str__(self):
return f"{self.field.name} - {self.year} - {self.crop.name}" return f"{self.field.name} - {self.year} - {self.crop.name}"
class RiceTransplantPlan(models.Model):
name = models.CharField(max_length=200, verbose_name='計画名')
year = models.IntegerField(verbose_name='年度')
variety = models.ForeignKey(
Variety,
on_delete=models.PROTECT,
related_name='rice_transplant_plans',
verbose_name='品種',
)
default_seed_grams_per_box = models.DecimalField(
max_digits=8,
decimal_places=2,
default=0,
verbose_name='苗箱1枚あたり種もみ(g)デフォルト',
)
notes = models.TextField(blank=True, default='', verbose_name='備考')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '田植え計画'
verbose_name_plural = '田植え計画'
ordering = ['-year', 'variety']
def __str__(self):
return f'{self.year} {self.name}'
class RiceTransplantEntry(models.Model):
plan = models.ForeignKey(
RiceTransplantPlan,
on_delete=models.CASCADE,
related_name='entries',
verbose_name='田植え計画',
)
field = models.ForeignKey(
Field,
on_delete=models.CASCADE,
related_name='rice_transplant_entries',
verbose_name='圃場',
)
seedling_boxes_per_tan = models.DecimalField(
max_digits=6,
decimal_places=2,
verbose_name='反当苗箱枚数',
)
seed_grams_per_box = models.DecimalField(
max_digits=8,
decimal_places=2,
verbose_name='苗箱1枚あたり種もみ(g)',
)
class Meta:
verbose_name = '田植え計画エントリ'
verbose_name_plural = '田植え計画エントリ'
unique_together = [['plan', 'field']]
ordering = ['field__display_order', 'field__id']
def __str__(self):
return f'{self.plan} / {self.field} / {self.seedling_boxes_per_tan}枚/反'

View File

@@ -1,5 +1,9 @@
from decimal import Decimal
from rest_framework import serializers from rest_framework import serializers
from apps.fields.models import Field
from .models import Crop, Variety, Plan from .models import Crop, Variety, Plan
from .models import RiceTransplantEntry, RiceTransplantPlan
class VarietySerializer(serializers.ModelSerializer): class VarietySerializer(serializers.ModelSerializer):
@@ -34,3 +38,154 @@ class PlanSerializer(serializers.ModelSerializer):
setattr(instance, attr, value) setattr(instance, attr, value)
instance.save() instance.save()
return instance return instance
class RiceTransplantEntrySerializer(serializers.ModelSerializer):
field_name = serializers.CharField(source='field.name', read_only=True)
field_area_tan = serializers.DecimalField(
source='field.area_tan',
max_digits=6,
decimal_places=4,
read_only=True,
)
planned_boxes = serializers.SerializerMethodField()
planned_seed_kg = serializers.SerializerMethodField()
class Meta:
model = RiceTransplantEntry
fields = [
'id',
'field',
'field_name',
'field_area_tan',
'seedling_boxes_per_tan',
'seed_grams_per_box',
'planned_boxes',
'planned_seed_kg',
]
def get_planned_boxes(self, obj):
area = Decimal(str(obj.field.area_tan))
return str((area * obj.seedling_boxes_per_tan).quantize(Decimal('0.01')))
def get_planned_seed_kg(self, obj):
area = Decimal(str(obj.field.area_tan))
boxes = area * obj.seedling_boxes_per_tan
seed_kg = (boxes * obj.seed_grams_per_box / Decimal('1000')).quantize(Decimal('0.001'))
return str(seed_kg)
class RiceTransplantPlanSerializer(serializers.ModelSerializer):
variety_name = serializers.CharField(source='variety.name', read_only=True)
crop_name = serializers.CharField(source='variety.crop.name', read_only=True)
entries = RiceTransplantEntrySerializer(many=True, read_only=True)
field_count = serializers.SerializerMethodField()
total_seedling_boxes = serializers.SerializerMethodField()
total_seed_kg = serializers.SerializerMethodField()
crop_seed_inventory_kg = serializers.SerializerMethodField()
remaining_seed_kg = serializers.SerializerMethodField()
class Meta:
model = RiceTransplantPlan
fields = [
'id',
'name',
'year',
'variety',
'variety_name',
'crop_name',
'default_seed_grams_per_box',
'notes',
'entries',
'field_count',
'total_seedling_boxes',
'total_seed_kg',
'crop_seed_inventory_kg',
'remaining_seed_kg',
'created_at',
'updated_at',
]
def get_field_count(self, obj):
return obj.entries.count()
def get_total_seedling_boxes(self, obj):
total = sum(
Decimal(str(entry.field.area_tan)) * entry.seedling_boxes_per_tan
for entry in obj.entries.all()
)
return str(total.quantize(Decimal('0.01')))
def get_total_seed_kg(self, obj):
total = sum(
(
Decimal(str(entry.field.area_tan))
* entry.seedling_boxes_per_tan
* entry.seed_grams_per_box
/ Decimal('1000')
)
for entry in obj.entries.all()
)
return str(total.quantize(Decimal('0.001')))
def get_crop_seed_inventory_kg(self, obj):
return str(obj.variety.crop.seed_inventory_kg)
def get_remaining_seed_kg(self, obj):
total_seed = Decimal(self.get_total_seed_kg(obj))
return str((obj.variety.crop.seed_inventory_kg - total_seed).quantize(Decimal('0.001')))
class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
class Meta:
model = RiceTransplantPlan
fields = [
'id',
'name',
'year',
'variety',
'default_seed_grams_per_box',
'notes',
'entries',
]
def create(self, validated_data):
entries_data = validated_data.pop('entries', [])
plan = RiceTransplantPlan.objects.create(**validated_data)
self._save_entries(plan, entries_data)
return plan
def update(self, instance, validated_data):
entries_data = validated_data.pop('entries', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
if entries_data is not None:
instance.entries.all().delete()
self._save_entries(instance, entries_data)
return instance
def validate(self, attrs):
entries_data = attrs.get('entries')
if entries_data is None:
return attrs
field_ids = [entry.get('field_id') for entry in entries_data if entry.get('field_id') is not None]
existing_ids = set(Field.objects.filter(id__in=field_ids).values_list('id', flat=True))
missing_ids = sorted(set(field_ids) - existing_ids)
if missing_ids:
raise serializers.ValidationError({
'entries': f'存在しない圃場IDが含まれています: {", ".join(str(field_id) for field_id in missing_ids)}'
})
return attrs
def _save_entries(self, plan, entries_data):
for entry in entries_data:
RiceTransplantEntry.objects.create(
plan=plan,
field_id=entry['field_id'],
seedling_boxes_per_tan=entry['seedling_boxes_per_tan'],
seed_grams_per_box=entry['seed_grams_per_box'],
)

View File

@@ -5,6 +5,7 @@ from . import views
router = DefaultRouter() router = DefaultRouter()
router.register(r'crops', views.CropViewSet) router.register(r'crops', views.CropViewSet)
router.register(r'varieties', views.VarietyViewSet) router.register(r'varieties', views.VarietyViewSet)
router.register(r'rice-transplant-plans', views.RiceTransplantPlanViewSet, basename='rice-transplant-plan')
router.register(r'', views.PlanViewSet) router.register(r'', views.PlanViewSet)
urlpatterns = [ urlpatterns = [

View File

@@ -2,8 +2,14 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from django.db.models import Sum from django.db.models import Sum
from .models import Crop, Variety, Plan from .models import Crop, Variety, Plan, RiceTransplantPlan
from .serializers import CropSerializer, VarietySerializer, PlanSerializer from .serializers import (
CropSerializer,
VarietySerializer,
PlanSerializer,
RiceTransplantPlanSerializer,
RiceTransplantPlanWriteSerializer,
)
from apps.fields.models import Field from apps.fields.models import Field
@@ -130,3 +136,49 @@ class PlanViewSet(viewsets.ModelViewSet):
def get_crops_with_varieties(self, request): def get_crops_with_varieties(self, request):
crops = Crop.objects.prefetch_related('varieties').all() crops = Crop.objects.prefetch_related('varieties').all()
return Response(CropSerializer(crops, many=True).data) return Response(CropSerializer(crops, many=True).data)
class RiceTransplantPlanViewSet(viewsets.ModelViewSet):
queryset = RiceTransplantPlan.objects.select_related(
'variety',
'variety__crop',
).prefetch_related('entries', 'entries__field')
def get_queryset(self):
queryset = self.queryset
year = self.request.query_params.get('year')
if year:
queryset = queryset.filter(year=year)
return queryset
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return RiceTransplantPlanWriteSerializer
return RiceTransplantPlanSerializer
@action(detail=False, methods=['get'])
def candidate_fields(self, request):
year = request.query_params.get('year')
variety_id = request.query_params.get('variety_id')
if not year or not variety_id:
return Response(
{'error': 'year と variety_id が必要です'},
status=status.HTTP_400_BAD_REQUEST,
)
field_ids = Plan.objects.filter(
year=year,
variety_id=variety_id,
).values_list('field_id', flat=True)
fields = Field.objects.filter(id__in=field_ids).order_by('display_order', 'id')
data = [
{
'id': field.id,
'name': field.name,
'area_tan': str(field.area_tan),
'area_m2': field.area_m2,
'group_name': field.group_name,
}
for field in fields
]
return Response(data)

View File

@@ -0,0 +1,308 @@
# マスタードキュメント:田植え計画機能
> **作成**: 2026-04-04
> **最終更新**: 2026-04-04
> **対象機能**: 田植え計画(年度・品種を軸に複数回作成できる苗箱・種もみ使用量計画)
> **実装状況**: MVP実装完了
---
## 概要
農業生産者が「年度 × 品種」を軸に、田植え前の播種・育苗準備量を見積もる機能。
各圃場について「反当何枚の苗箱を使うか」「苗箱1枚あたり種もみを何g使うか」を記録し、圃場別・計画全体の苗箱枚数と種もみ使用量を自動集計する。
圃場候補は既存の作付け計画から自動取得し、種もみ在庫は作物単位、反当苗箱枚数の初期値は品種単位で管理する。
同じ年度・同じ品種でも、播種時期や育苗ロットを分けるために複数の田植え計画を作成できる。
### 機能スコープIN / OUT
| IN実装済み | OUT対象外 |
|---|---|
| 田植え計画の作成・編集・削除 | 育苗日程のカレンダー管理 |
| 作付け計画からの候補圃場自動取得 | 実播種実績の記録 |
| 圃場ごとの苗箱枚数/反の個別調整 | 種もみロット管理 |
| 圃場ごとの種もみg/箱の個別調整 | 在庫の自動引当 |
| 苗箱合計・種もみkg合計の自動集計 | PDF出力 |
| 作物ごとの種もみ在庫kg管理 | 品種ごとの播種日管理 |
| 品種ごとの反当苗箱枚数デフォルト管理 | |
---
## 業務ルール
1. 田植え計画は `年度 × 品種` を軸に作成する
2. 対象圃場は、その年度・品種の作付け計画が登録されている圃場から取得する
3. 種もみ在庫は作物単位で管理する
4. 反当苗箱枚数の初期値は品種単位で管理する
5. ただし実際の計画値は圃場単位で上書きできる
6. 種もみg/箱は計画全体のデフォルト値を持ちつつ、圃場単位で上書きできる
7. 在庫不足はエラーで保存停止せず、一覧・編集画面で残在庫見込みとして可視化する
8. 同じ年度・同じ品種で複数の計画を作成してよい
9. 複数回に分ける場合は、`計画名` で「第1回」「第2回」「4/10播種分」などを区別する
---
## 計算式
### 圃場ごとの苗箱合計
`苗箱合計 = 圃場面積(反) × 反当苗箱枚数`
### 圃場ごとの種もみ使用量
`種もみkg = 苗箱合計 × 苗箱1枚あたり種もみ(g) ÷ 1000`
### 計画全体の残在庫見込み
`残在庫見込み = 作物の種もみ在庫(kg) - 計画全体の種もみkg合計`
---
## データモデル
### Crop作物マスタ
既存 `plans.Crop` に以下を追加。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| seed_inventory_kg | decimal(10,3) | default=0 | 作物単位の種もみ在庫(kg) |
### Variety品種マスタ
既存 `plans.Variety` に以下を追加。
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| default_seedling_boxes_per_tan | decimal(6,2) | default=0 | 反当苗箱枚数の初期値 |
### RiceTransplantPlan田植え計画
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| name | varchar(200) | required | 計画名 |
| year | int | required | 年度 |
| variety | FK(plans.Variety) | PROTECT | 品種 |
| default_seed_grams_per_box | decimal(8,2) | default=0 | 苗箱1枚あたり種もみ(g)の初期値 |
| notes | text | blank | 備考 |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |
- `year + variety` の一意制約は持たない
- 同一年度・同一品種で複数レコード作成可能
#### 表示用計算項目APIレスポンスに含まれる
| 項目 | 型 | 説明 |
|---|---|---|
| field_count | int | 対象圃場数 |
| total_seedling_boxes | decimal | 苗箱枚数合計 |
| total_seed_kg | decimal | 種もみ使用量合計(kg) |
| crop_seed_inventory_kg | decimal | 作物在庫(kg) |
| remaining_seed_kg | decimal | 残在庫見込み(kg) |
### RiceTransplantEntry田植え計画エントリ
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| plan | FK(RiceTransplantPlan) | CASCADE | |
| field | FK(fields.Field) | CASCADE | |
| seedling_boxes_per_tan | decimal(6,2) | required | 反当苗箱枚数 |
| seed_grams_per_box | decimal(8,2) | required | 苗箱1枚あたり種もみ(g) |
- `unique_together = ['plan', 'field']`
- 順序: `field__display_order, field__id`
#### 表示用計算項目entryレスポンスに含まれる
| 項目 | 型 | 説明 |
|---|---|---|
| field_name | string | 圃場名 |
| field_area_tan | decimal | 圃場面積(反) |
| planned_boxes | decimal | 圃場ごとの苗箱合計 |
| planned_seed_kg | decimal | 圃場ごとの種もみkg |
---
## API エンドポイント
すべて JWT 認証(`Authorization: Bearer <token>`)が必要。
### 田植え計画
| メソッド | URL | 説明 |
|---|---|---|
| GET | `/api/plans/rice-transplant-plans/?year={year}` | 年度別一覧 |
| POST | `/api/plans/rice-transplant-plans/` | 新規作成 |
| GET | `/api/plans/rice-transplant-plans/{id}/` | 詳細取得 |
| PUT/PATCH | `/api/plans/rice-transplant-plans/{id}/` | 更新 |
| DELETE | `/api/plans/rice-transplant-plans/{id}/` | 削除 |
| GET | `/api/plans/rice-transplant-plans/candidate_fields/?year={year}&variety_id={id}` | 作付け計画から候補圃場取得 |
一覧レスポンス例:
```json
{
"id": 1,
"name": "2026年度 コシヒカリ 田植え計画",
"year": 2026,
"variety": 3,
"variety_name": "コシヒカリ",
"crop_name": "水稲",
"default_seed_grams_per_box": "200.00",
"notes": "",
"field_count": 8,
"total_seedling_boxes": "98.40",
"total_seed_kg": "19.680",
"crop_seed_inventory_kg": "25.000",
"remaining_seed_kg": "5.320",
"entries": [
{
"id": 10,
"field": 5,
"field_name": "田中上",
"field_area_tan": "1.2000",
"seedling_boxes_per_tan": "12.00",
"seed_grams_per_box": "200.00",
"planned_boxes": "14.40",
"planned_seed_kg": "2.880"
}
]
}
```
POST/PUT リクエスト例:
```json
{
"name": "2026年度 コシヒカリ 田植え計画",
"year": 2026,
"variety": 3,
"default_seed_grams_per_box": "200.00",
"notes": "",
"entries": [
{
"field_id": 5,
"seedling_boxes_per_tan": "12.00",
"seed_grams_per_box": "200.00"
},
{
"field_id": 6,
"seedling_boxes_per_tan": "11.50",
"seed_grams_per_box": "190.00"
}
]
}
```
更新時は `entries` を全置換する。
### 作物・品種マスタ更新
田植え計画に必要な既定値は既存 API で更新する。
| メソッド | URL | 更新項目 |
|---|---|---|
| PATCH | `/api/plans/crops/{id}/` | `seed_inventory_kg` |
| PATCH | `/api/plans/varieties/{id}/` | `default_seedling_boxes_per_tan` |
---
## 画面仕様
### 1. 田植え計画一覧 `/rice-transplant`
- 年度切替
- 田植え計画の一覧表示
- 同一年度・同一品種の計画が複数並ぶことを想定する
- 表示列:
- 計画名
- 作物 / 品種
- 圃場数
- 苗箱合計
- 種もみ計画kg
- 残在庫見込みkg
- 行アクション:
- 編集
- 削除
### 2. 田植え計画編集 `/rice-transplant/new`, `/rice-transplant/{id}/edit`
- 基本情報:
- 計画名
- 同一年度・同一品種の複数計画を区別できる名称を付ける
- 例: `2026年度 コシヒカリ 第1回`, `2026年度 コシヒカリ 4/15播種分`
- 年度
- 品種
- 苗箱1枚あたり種もみ(g) デフォルト
- 備考
- 対象圃場:
- 品種選択後に作付け計画から候補圃場を自動取得
- 新規作成時は候補圃場を初期選択
- 圃場の追加・除外が可能
- 初期値:
- `反当苗箱枚数` は品種マスタの `default_seedling_boxes_per_tan`
- `種もみg/箱` は計画ヘッダの `default_seed_grams_per_box`
- 圃場テーブル:
- 圃場
- 面積(反)
- 反当苗箱枚数
- 種もみg/箱
- 苗箱合計
- 種もみkg
- サマリー:
- 対象圃場数
- 苗箱合計
- 種もみ計画kg
- 作物在庫kg
- 残在庫見込みkg
- 補助操作:
- 初期値を一括反映
### 3. 品種管理モーダル `/allocation`
既存の作付け計画画面内の品種管理モーダルを拡張。
- 作物単位:
- 種もみ在庫(kg) を更新可能
- 品種単位:
- 反当苗箱枚数デフォルトを更新可能
---
## バリデーション・運用ルール
1. 計画名は必須
2. 品種は必須
3. 圃場は1件以上必要
4. `seedling_boxes_per_tan``seed_grams_per_box` は 0 以上の数値を想定
5. 在庫不足でも保存は許可し、UIで不足を可視化する
6. 候補圃場の抽出元は既存 `Plan`(作付け計画)であるため、先に作付け計画が必要
---
## 既知の制約
1. 種もみ在庫は作物単位のみで、品種別在庫には未対応
2. 田植え計画の PDF 出力は未実装
3. 在庫管理 `materials` app とは未連携で、引当・使用実績は持たない
4. 実播種や田植え実績との連携は未実装
---
## 関連ファイル
| 種別 | パス |
|---|---|
| モデル | `backend/apps/plans/models.py` |
| シリアライザ | `backend/apps/plans/serializers.py` |
| ViewSet | `backend/apps/plans/views.py` |
| URL | `backend/apps/plans/urls.py` |
| マイグレーション | `backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py` |
| 一覧画面 | `frontend/src/app/rice-transplant/page.tsx` |
| 編集画面 | `frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx` |
| ナビゲーション | `frontend/src/components/Navbar.tsx` |
| 品種管理モーダル | `frontend/src/app/allocation/page.tsx` |

View File

@@ -367,6 +367,34 @@ export default function AllocationPage() {
} }
}; };
const handleUpdateCropSeedInventory = async (cropId: number, seedInventoryKg: string) => {
try {
const crop = crops.find((item) => item.id === cropId);
if (!crop) return;
await api.patch(`/plans/crops/${cropId}/`, {
seed_inventory_kg: seedInventoryKg,
});
await fetchData(true);
} catch (error) {
console.error('Failed to update crop seed inventory:', error);
alert('種もみ在庫の更新に失敗しました');
}
};
const handleUpdateVarietyDefaultBoxes = async (varietyId: number, defaultBoxes: string) => {
try {
const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId);
if (!variety) return;
await api.patch(`/plans/varieties/${varietyId}/`, {
default_seedling_boxes_per_tan: defaultBoxes,
});
await fetchData(true);
} catch (error) {
console.error('Failed to update variety default boxes:', error);
alert('品種デフォルトの更新に失敗しました');
}
};
const toggleFieldSelection = (fieldId: number) => { const toggleFieldSelection = (fieldId: number) => {
setSelectedFields((prev) => { setSelectedFields((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -1029,18 +1057,34 @@ export default function AllocationPage() {
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{managerCropId && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="mb-2 text-xs font-semibold text-amber-800"></p>
<CropSeedInventoryForm
crop={crops.find((crop) => crop.id === managerCropId) || null}
onSave={handleUpdateCropSeedInventory}
/>
</div>
)}
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? ( {managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
<ul className="space-y-2"> <ul className="space-y-2">
{getVarietiesForCrop(managerCropId).map((v) => ( {getVarietiesForCrop(managerCropId).map((v) => (
<li key={v.id} className="flex items-center justify-between p-2 rounded hover:bg-gray-50"> <li key={v.id} className="rounded border border-gray-200 p-3">
<span className="text-sm text-gray-900">{v.name}</span> <div className="mb-2 flex items-center justify-between">
<button <span className="text-sm font-medium text-gray-900">{v.name}</span>
onClick={() => handleDeleteVariety(v.id, v.name)} <button
className="text-red-400 hover:text-red-600 p-1" onClick={() => handleDeleteVariety(v.id, v.name)}
title="削除" className="text-red-400 hover:text-red-600 p-1"
> title="削除"
<Trash2 className="h-4 w-4" /> >
</button> <Trash2 className="h-4 w-4" />
</button>
</div>
<VarietyDefaultBoxesForm
varietyId={v.id}
initialValue={v.default_seedling_boxes_per_tan}
onSave={handleUpdateVarietyDefaultBoxes}
/>
</li> </li>
))} ))}
</ul> </ul>
@@ -1105,3 +1149,90 @@ function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name
</div> </div>
); );
} }
function CropSeedInventoryForm({
crop,
onSave,
}: {
crop: Crop | null;
onSave: (cropId: number, seedInventoryKg: string) => Promise<void>;
}) {
const [value, setValue] = useState(crop?.seed_inventory_kg ?? '0');
const [saving, setSaving] = useState(false);
useEffect(() => {
setValue(crop?.seed_inventory_kg ?? '0');
}, [crop?.id, crop?.seed_inventory_kg]);
const handleSave = async () => {
if (!crop) return;
setSaving(true);
await onSave(crop.id, value);
setSaving(false);
};
return (
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs text-gray-600">(kg)</label>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</div>
<button
onClick={handleSave}
disabled={!crop || saving}
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
</button>
</div>
);
}
function VarietyDefaultBoxesForm({
varietyId,
initialValue,
onSave,
}: {
varietyId: number;
initialValue: string;
onSave: (varietyId: number, defaultBoxes: string) => Promise<void>;
}) {
const [value, setValue] = useState(initialValue);
const [saving, setSaving] = useState(false);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const handleSave = async () => {
setSaving(true);
await onSave(varietyId, value);
setSaving(false);
};
return (
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs text-gray-600"></label>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</div>
<button
onClick={handleSave}
disabled={saving}
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
</button>
</div>
);
}

View File

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

View File

@@ -0,0 +1,447 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ChevronLeft, Save } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Crop, Field, RiceTransplantPlan } from '@/types';
type EntryInput = {
seedling_boxes_per_tan: string;
seed_grams_per_box: string;
};
type EntryMap = Record<number, EntryInput>;
const currentYear = new Date().getFullYear();
export default function RiceTransplantEditPage({ planId }: { planId?: number }) {
const router = useRouter();
const isNew = !planId;
const [name, setName] = useState('');
const [year, setYear] = useState(currentYear);
const [varietyId, setVarietyId] = useState<number | ''>('');
const [defaultSeedGramsPerBox, setDefaultSeedGramsPerBox] = useState('200');
const [notes, setNotes] = useState('');
const [crops, setCrops] = useState<Crop[]>([]);
const [allFields, setAllFields] = useState<Field[]>([]);
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
const [entries, setEntries] = useState<EntryMap>({});
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
const getVariety = (id: number) =>
crops.flatMap((crop) => crop.varieties).find((variety) => variety.id === id);
const getSelectedCrop = () => {
if (!varietyId) return null;
return crops.find((crop) => crop.varieties.some((variety) => variety.id === varietyId)) ?? null;
};
const initializeEntry = (fieldId: number, nextDefaultSeedGramsPerBox = defaultSeedGramsPerBox) => {
const variety = varietyId ? getVariety(varietyId) : null;
const defaultBoxes = variety?.default_seedling_boxes_per_tan ?? '0';
return {
seedling_boxes_per_tan: String(defaultBoxes),
seed_grams_per_box: nextDefaultSeedGramsPerBox,
};
};
useEffect(() => {
const init = async () => {
setError(null);
try {
const [cropsRes, fieldsRes] = await Promise.all([
api.get('/plans/crops/'),
api.get('/fields/?ordering=display_order,id'),
]);
setCrops(cropsRes.data);
setAllFields(fieldsRes.data);
if (!isNew && planId) {
const planRes = await api.get(`/plans/rice-transplant-plans/${planId}/`);
const plan: RiceTransplantPlan = planRes.data;
setName(plan.name);
setYear(plan.year);
setVarietyId(plan.variety);
setDefaultSeedGramsPerBox(plan.default_seed_grams_per_box);
setNotes(plan.notes);
const fieldIds = new Set(plan.entries.map((entry) => entry.field));
const planFields = fieldsRes.data.filter((field: Field) => fieldIds.has(field.id));
setSelectedFields(planFields);
setCandidateFields(planFields);
setEntries(
plan.entries.reduce((acc: EntryMap, entry) => {
acc[entry.field] = {
seedling_boxes_per_tan: String(entry.seedling_boxes_per_tan),
seed_grams_per_box: String(entry.seed_grams_per_box),
};
return acc;
}, {})
);
}
} catch (e) {
console.error(e);
setError('データの読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
init();
}, [isNew, planId]);
useEffect(() => {
const fetchCandidates = async () => {
if (!varietyId || !year || (!isNew && loading)) return;
try {
const res = await api.get(`/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${varietyId}`);
const nextCandidates: Field[] = res.data;
setCandidateFields(nextCandidates);
if (isNew) {
setSelectedFields(nextCandidates);
setEntries((prev) => {
const next = { ...prev };
nextCandidates.forEach((field) => {
if (!next[field.id]) {
next[field.id] = initializeEntry(field.id);
}
});
return next;
});
}
} catch (e) {
console.error(e);
setError('候補圃場の取得に失敗しました。');
}
};
fetchCandidates();
}, [varietyId, year, isNew, loading]);
const updateEntry = (fieldId: number, key: keyof EntryInput, value: string) => {
setEntries((prev) => ({
...prev,
[fieldId]: {
...(prev[fieldId] ?? initializeEntry(fieldId)),
[key]: value,
},
}));
};
const applyDefaultsToSelected = () => {
setEntries((prev) => {
const next = { ...prev };
selectedFields.forEach((field) => {
next[field.id] = initializeEntry(field.id, defaultSeedGramsPerBox);
});
return next;
});
};
const addField = (field: Field) => {
if (selectedFields.some((selected) => selected.id === field.id)) return;
setSelectedFields((prev) => [...prev, field]);
setEntries((prev) => ({
...prev,
[field.id]: prev[field.id] ?? initializeEntry(field.id),
}));
};
const removeField = (fieldId: number) => {
setSelectedFields((prev) => prev.filter((field) => field.id !== fieldId));
};
const fieldRows = useMemo(
() => selectedFields.map((field) => ({ field, entry: entries[field.id] ?? initializeEntry(field.id) })),
[selectedFields, entries, varietyId, defaultSeedGramsPerBox]
);
const calculateBoxes = (field: Field, entry: EntryInput) => {
const areaTan = parseFloat(field.area_tan || '0');
const boxesPerTan = parseFloat(entry.seedling_boxes_per_tan || '0');
return areaTan * boxesPerTan;
};
const calculateSeedKg = (field: Field, entry: EntryInput) => {
const boxes = calculateBoxes(field, entry);
const gramsPerBox = parseFloat(entry.seed_grams_per_box || '0');
return (boxes * gramsPerBox) / 1000;
};
const totalBoxes = fieldRows.reduce((sum, row) => sum + calculateBoxes(row.field, row.entry), 0);
const totalSeedKg = fieldRows.reduce((sum, row) => sum + calculateSeedKg(row.field, row.entry), 0);
const cropSeedInventoryKg = parseFloat(getSelectedCrop()?.seed_inventory_kg ?? '0');
const remainingSeedKg = cropSeedInventoryKg - totalSeedKg;
const handleSave = async () => {
setError(null);
if (!name.trim()) {
setError('計画名を入力してください。');
return;
}
if (!varietyId) {
setError('品種を選択してください。');
return;
}
if (selectedFields.length === 0) {
setError('圃場を1つ以上選択してください。');
return;
}
const payload = {
name,
year,
variety: varietyId,
default_seed_grams_per_box: defaultSeedGramsPerBox,
notes,
entries: selectedFields.map((field) => ({
field_id: field.id,
seedling_boxes_per_tan: entries[field.id]?.seedling_boxes_per_tan ?? initializeEntry(field.id).seedling_boxes_per_tan,
seed_grams_per_box: entries[field.id]?.seed_grams_per_box ?? defaultSeedGramsPerBox,
})),
};
setSaving(true);
try {
if (isNew) {
await api.post('/plans/rice-transplant-plans/', payload);
} else {
await api.put(`/plans/rice-transplant-plans/${planId}/`, payload);
}
router.push('/rice-transplant');
} catch (e) {
console.error(e);
setError('保存に失敗しました。');
} finally {
setSaving(false);
}
};
const unselectedFields = (candidateFields.length > 0 ? candidateFields : allFields).filter(
(field) => !selectedFields.some((selected) => selected.id === field.id)
);
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="mx-auto max-w-6xl px-4 py-8 text-gray-500">...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => router.push('/rice-transplant')} className="text-gray-500 hover:text-gray-700">
<ChevronLeft className="h-5 w-5" />
</button>
<h1 className="text-2xl font-bold text-gray-800">
{isNew ? '田植え計画 新規作成' : '田植え計画 編集'}
</h1>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'}
</button>
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<div className="mb-4 grid gap-4 rounded-lg bg-white p-4 shadow md:grid-cols-2 xl:grid-cols-4">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
placeholder="例: 2026年度 コシヒカリ 第1回"
/>
<p className="mt-1 text-xs text-gray-500">1</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value, 10))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
{years.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<select
value={varietyId}
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value, 10) : '')}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value=""></option>
{crops.map((crop) => (
<optgroup key={crop.id} label={crop.name}>
{crop.varieties.map((variety) => (
<option key={variety.id} value={variety.id}>
{variety.name}
</option>
))}
</optgroup>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">1(g) </label>
<input
value={defaultSeedGramsPerBox}
onChange={(e) => setDefaultSeedGramsPerBox(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</div>
</div>
<div className="mb-4 grid gap-4 md:grid-cols-[2fr,1fr]">
<div className="rounded-lg bg-white p-4 shadow">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800"></h2>
<button
onClick={applyDefaultsToSelected}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
>
</button>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{selectedFields.map((field) => (
<button
key={field.id}
onClick={() => removeField(field.id)}
className="rounded-full border border-green-200 bg-green-50 px-3 py-1 text-xs text-green-800"
>
{field.name} ×
</button>
))}
{selectedFields.length === 0 && <p className="text-sm text-gray-500"></p>}
</div>
{unselectedFields.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-gray-500"></p>
<div className="flex flex-wrap gap-2">
{unselectedFields.map((field) => (
<button
key={field.id}
onClick={() => addField(field)}
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-gray-700 hover:bg-gray-100"
>
{field.name}
</button>
))}
</div>
</div>
)}
</div>
<div className="rounded-lg bg-white p-4 shadow">
<h2 className="mb-3 text-sm font-semibold text-gray-800"></h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between text-gray-600">
<span></span>
<span>{selectedFields.length}</span>
</div>
<div className="flex justify-between text-gray-600">
<span></span>
<span>{totalBoxes.toFixed(2)}</span>
</div>
<div className="flex justify-between text-gray-600">
<span></span>
<span>{totalSeedKg.toFixed(3)}kg</span>
</div>
<div className="flex justify-between text-gray-600">
<span>{getSelectedCrop()?.name ?? '作物'} </span>
<span>{cropSeedInventoryKg.toFixed(3)}kg</span>
</div>
<div className={`flex justify-between font-semibold ${remainingSeedKg < 0 ? 'text-red-600' : 'text-emerald-700'}`}>
<span></span>
<span>{remainingSeedKg.toFixed(3)}kg</span>
</div>
</div>
</div>
</div>
<div className="mb-4 rounded-lg bg-white p-4 shadow">
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="w-full text-sm">
<thead className="border-b bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700">()</th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700">g/</th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700">kg</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{fieldRows.map(({ field, entry }) => (
<tr key={field.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{field.name}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{field.area_tan}</td>
<td className="px-4 py-3">
<input
value={entry.seedling_boxes_per_tan}
onChange={(e) => updateEntry(field.id, 'seedling_boxes_per_tan', e.target.value)}
className="w-24 rounded-md border border-gray-300 px-2 py-1 text-right focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</td>
<td className="px-4 py-3">
<input
value={entry.seed_grams_per_box}
onChange={(e) => updateEntry(field.id, 'seed_grams_per_box', e.target.value)}
className="w-24 rounded-md border border-gray-300 px-2 py-1 text-right focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="decimal"
/>
</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-700">{calculateBoxes(field, entry).toFixed(2)}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-700">{calculateSeedKg(field, entry).toFixed(3)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,159 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Pencil, Plus, Sprout, Trash2 } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { RiceTransplantPlan } from '@/types';
const currentYear = new Date().getFullYear();
export default function RiceTransplantPage() {
const router = useRouter();
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('riceTransplantYear');
if (saved) return parseInt(saved, 10);
}
return currentYear;
});
const [plans, setPlans] = useState<RiceTransplantPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
const fetchPlans = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get(`/plans/rice-transplant-plans/?year=${year}`);
setPlans(res.data);
} catch (e) {
console.error(e);
setError('田植え計画の読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
useEffect(() => {
localStorage.setItem('riceTransplantYear', String(year));
fetchPlans();
}, [year]);
const handleDelete = async (id: number, name: string) => {
setError(null);
if (!confirm(`${name}」を削除しますか?`)) {
return;
}
try {
await api.delete(`/plans/rice-transplant-plans/${id}/`);
await fetchPlans();
} catch (e) {
console.error(e);
setError(`${name}」の削除に失敗しました。`);
}
};
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Sprout className="h-6 w-6 text-emerald-600" />
<h1 className="text-2xl font-bold text-gray-800"></h1>
</div>
<button
onClick={() => router.push('/rice-transplant/new')}
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-white hover:bg-emerald-700"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="mb-6 flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">:</label>
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value, 10))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
{years.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{loading ? (
<p className="text-gray-500">...</p>
) : plans.length === 0 ? (
<div className="rounded-lg bg-white p-12 text-center text-gray-400 shadow">
<Sprout className="mx-auto mb-3 h-12 w-12 opacity-30" />
<p>{year}</p>
</div>
) : (
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="w-full text-sm">
<thead className="border-b bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-left font-medium text-gray-700"> / </th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3 text-right font-medium text-gray-700"></th>
<th className="px-4 py-3" />
</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 font-medium text-gray-900">{plan.name}</td>
<td className="px-4 py-3 text-gray-600">{plan.crop_name} / {plan.variety_name}</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.total_seedling_boxes}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.total_seed_kg}kg</td>
<td className={`px-4 py-3 text-right tabular-nums ${parseFloat(plan.remaining_seed_kg) < 0 ? 'text-red-600' : 'text-emerald-700'}`}>
{plan.remaining_seed_kg}kg
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => router.push(`/rice-transplant/${plan.id}/edit`)}
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(plan.id, plan.name)}
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine, Construction } from 'lucide-react'; import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine, Construction, Tractor } 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('/rice-transplant')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/rice-transplant')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Tractor className="h-4 w-4 mr-2" />
</button>
<button <button
onClick={() => router.push('/fertilizer/spreading')} onClick={() => router.push('/fertilizer/spreading')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${ className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${

View File

@@ -36,11 +36,13 @@ export interface Variety {
id: number; id: number;
crop: number; crop: number;
name: string; name: string;
default_seedling_boxes_per_tan: string;
} }
export interface Crop { export interface Crop {
id: number; id: number;
name: string; name: string;
seed_inventory_kg: string;
varieties: Variety[]; varieties: Variety[];
} }
@@ -165,6 +167,36 @@ export interface FertilizationPlan {
updated_at: string; updated_at: string;
} }
export interface RiceTransplantEntry {
id?: number;
field: number;
field_name?: string;
field_area_tan?: string;
seedling_boxes_per_tan: string;
seed_grams_per_box: string;
planned_boxes: string;
planned_seed_kg: string;
}
export interface RiceTransplantPlan {
id: number;
name: string;
year: number;
variety: number;
variety_name: string;
crop_name: string;
default_seed_grams_per_box: string;
notes: string;
entries: RiceTransplantEntry[];
field_count: number;
total_seedling_boxes: string;
total_seed_kg: string;
crop_seed_inventory_kg: string;
remaining_seed_kg: string;
created_at: string;
updated_at: string;
}
export interface DeliveryGroupField { export interface DeliveryGroupField {
id: number; id: number;
name: string; name: string;