品種ごとの種子在庫前提まで実装を進めました。
主な変更は、seed 資材種別の追加と Variety.seed_material の導入です。backend/apps/materials/models.py、backend/apps/plans/models.py、backend/apps/plans/serializers.py で、田植え計画が作物在庫ではなく品種に紐づく種子資材の現在庫を参照するように切り替えました。マイグレーションは backend/apps/materials/migrations/0005_material_seed_type.py と backend/apps/plans/migrations/0008_variety_seed_material.py を追加しています。 画面側は、frontend/src/app/materials/page.tsx と frontend/src/app/materials/masters/page.tsx に「種子」タブを追加し、frontend/src/app/allocation/page.tsx の品種管理モーダルで品種ごとに種子在庫資材を設定できるようにしました。田植え計画画面 frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx も、苗箱数 列中心に整理し、種もみkg 列を削除、反当苗箱枚数 の列反映と ≈ / ↩ の四捨五入トグルを施肥計画寄りの操作感に寄せています。仕様書 document/16_マスタードキュメント_田植え計画編.md も更新済みです。 確認できたのは python3 -m py_compile backend/apps/materials/models.py backend/apps/materials/serializers.py backend/apps/plans/models.py backend/apps/plans/serializers.py backend/apps/plans/views.py までです。frontend/node_modules が無いためフロントのビルド確認はまだできていません。Issue #2 にも反映内容をコメント済みです。必要なら次にコミットします。
This commit is contained in:
26
backend/apps/materials/migrations/0005_material_seed_type.py
Normal file
26
backend/apps/materials/migrations/0005_material_seed_type.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('materials', '0004_fix_spreading_item_on_delete'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='material',
|
||||
name='material_type',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('fertilizer', '肥料'),
|
||||
('pesticide', '農薬'),
|
||||
('seed', '種子'),
|
||||
('seedling', '種苗'),
|
||||
('other', 'その他'),
|
||||
],
|
||||
max_length=20,
|
||||
verbose_name='資材種別',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -10,6 +10,7 @@ class Material(models.Model):
|
||||
class MaterialType(models.TextChoices):
|
||||
FERTILIZER = 'fertilizer', '肥料'
|
||||
PESTICIDE = 'pesticide', '農薬'
|
||||
SEED = 'seed', '種子'
|
||||
SEEDLING = 'seedling', '種苗'
|
||||
OTHER = 'other', 'その他'
|
||||
|
||||
|
||||
@@ -112,11 +112,15 @@ class MaterialWriteSerializer(serializers.ModelSerializer):
|
||||
{'fertilizer_profile': '農薬には肥料詳細を設定できません。'}
|
||||
)
|
||||
if (
|
||||
material_type in {Material.MaterialType.SEEDLING, Material.MaterialType.OTHER}
|
||||
material_type in {
|
||||
Material.MaterialType.SEED,
|
||||
Material.MaterialType.SEEDLING,
|
||||
Material.MaterialType.OTHER,
|
||||
}
|
||||
and (fertilizer_profile or pesticide_profile)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
'種苗・その他には詳細プロファイルを設定できません。'
|
||||
'種子・種苗・その他には詳細プロファイルを設定できません。'
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
26
backend/apps/plans/migrations/0008_variety_seed_material.py
Normal file
26
backend/apps/plans/migrations/0008_variety_seed_material.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('materials', '0005_material_seed_type'),
|
||||
('plans', '0007_ricetransplantplan_seedling_boxes_per_tan'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='variety',
|
||||
name='seed_material',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
limit_choices_to={'material_type': 'seed'},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='varieties',
|
||||
to='materials.material',
|
||||
verbose_name='種子在庫資材',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -29,6 +29,15 @@ class Variety(models.Model):
|
||||
default=0,
|
||||
verbose_name="反当苗箱枚数デフォルト",
|
||||
)
|
||||
seed_material = models.ForeignKey(
|
||||
'materials.Material',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='varieties',
|
||||
verbose_name='種子在庫資材',
|
||||
blank=True,
|
||||
null=True,
|
||||
limit_choices_to={'material_type': 'seed'},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "品種マスタ"
|
||||
|
||||
@@ -2,14 +2,24 @@ from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.fields.models import Field
|
||||
from apps.materials.models import StockTransaction
|
||||
from .models import Crop, Variety, Plan
|
||||
from .models import RiceTransplantEntry, RiceTransplantPlan
|
||||
|
||||
|
||||
class VarietySerializer(serializers.ModelSerializer):
|
||||
seed_material_name = serializers.CharField(source='seed_material.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Variety
|
||||
fields = '__all__'
|
||||
fields = [
|
||||
'id',
|
||||
'crop',
|
||||
'name',
|
||||
'default_seedling_boxes_per_tan',
|
||||
'seed_material',
|
||||
'seed_material_name',
|
||||
]
|
||||
|
||||
|
||||
class CropSerializer(serializers.ModelSerializer):
|
||||
@@ -50,7 +60,6 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer):
|
||||
)
|
||||
planned_boxes = serializers.SerializerMethodField()
|
||||
default_seedling_boxes = serializers.SerializerMethodField()
|
||||
planned_seed_kg = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RiceTransplantEntry
|
||||
@@ -62,7 +71,6 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer):
|
||||
'installed_seedling_boxes',
|
||||
'default_seedling_boxes',
|
||||
'planned_boxes',
|
||||
'planned_seed_kg',
|
||||
]
|
||||
|
||||
def get_default_seedling_boxes(self, obj):
|
||||
@@ -73,21 +81,15 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer):
|
||||
def get_planned_boxes(self, obj):
|
||||
return str(obj.installed_seedling_boxes.quantize(Decimal('0.01')))
|
||||
|
||||
def get_planned_seed_kg(self, obj):
|
||||
seed_kg = (
|
||||
obj.installed_seedling_boxes * obj.plan.default_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)
|
||||
seed_material_name = serializers.CharField(source='variety.seed_material.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()
|
||||
variety_seed_inventory_kg = serializers.SerializerMethodField()
|
||||
remaining_seed_kg = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
@@ -102,11 +104,12 @@ class RiceTransplantPlanSerializer(serializers.ModelSerializer):
|
||||
'default_seed_grams_per_box',
|
||||
'seedling_boxes_per_tan',
|
||||
'notes',
|
||||
'seed_material_name',
|
||||
'entries',
|
||||
'field_count',
|
||||
'total_seedling_boxes',
|
||||
'total_seed_kg',
|
||||
'crop_seed_inventory_kg',
|
||||
'variety_seed_inventory_kg',
|
||||
'remaining_seed_kg',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
@@ -117,28 +120,58 @@ class RiceTransplantPlanSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_total_seedling_boxes(self, obj):
|
||||
total = sum(
|
||||
entry.installed_seedling_boxes
|
||||
for entry in obj.entries.all()
|
||||
(
|
||||
entry.installed_seedling_boxes
|
||||
for entry in obj.entries.all()
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return str(total.quantize(Decimal('0.01')))
|
||||
|
||||
def get_total_seed_kg(self, obj):
|
||||
total = sum(
|
||||
(
|
||||
entry.installed_seedling_boxes
|
||||
* obj.default_seed_grams_per_box
|
||||
/ Decimal('1000')
|
||||
)
|
||||
for entry in obj.entries.all()
|
||||
(
|
||||
entry.installed_seedling_boxes
|
||||
* obj.default_seed_grams_per_box
|
||||
/ Decimal('1000')
|
||||
)
|
||||
for entry in obj.entries.all()
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
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_variety_seed_inventory_kg(self, obj):
|
||||
return str(self._get_seed_inventory_kg(obj).quantize(Decimal('0.001')))
|
||||
|
||||
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')))
|
||||
return str((self._get_seed_inventory_kg(obj) - total_seed).quantize(Decimal('0.001')))
|
||||
|
||||
def _get_seed_inventory_kg(self, obj):
|
||||
material = obj.variety.seed_material
|
||||
if material is None:
|
||||
return Decimal('0')
|
||||
|
||||
transactions = list(material.stock_transactions.all())
|
||||
increase = sum(
|
||||
(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type in StockTransaction.INCREASE_TYPES
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
decrease = sum(
|
||||
(
|
||||
txn.quantity
|
||||
for txn in transactions
|
||||
if txn.transaction_type in StockTransaction.DECREASE_TYPES
|
||||
),
|
||||
Decimal('0'),
|
||||
)
|
||||
return increase - decrease
|
||||
|
||||
|
||||
class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -19,7 +19,7 @@ class CropViewSet(viewsets.ModelViewSet):
|
||||
|
||||
|
||||
class VarietyViewSet(viewsets.ModelViewSet):
|
||||
queryset = Variety.objects.all()
|
||||
queryset = Variety.objects.select_related('seed_material', 'crop').all()
|
||||
serializer_class = VarietySerializer
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ class PlanViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def get_crops_with_varieties(self, request):
|
||||
crops = Crop.objects.prefetch_related('varieties').all()
|
||||
crops = Crop.objects.prefetch_related('varieties__seed_material').all()
|
||||
return Response(CropSerializer(crops, many=True).data)
|
||||
|
||||
|
||||
@@ -142,7 +142,12 @@ class RiceTransplantPlanViewSet(viewsets.ModelViewSet):
|
||||
queryset = RiceTransplantPlan.objects.select_related(
|
||||
'variety',
|
||||
'variety__crop',
|
||||
).prefetch_related('entries', 'entries__field')
|
||||
'variety__seed_material',
|
||||
).prefetch_related(
|
||||
'variety__seed_material__stock_transactions',
|
||||
'entries',
|
||||
'entries__field',
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
農業生産者が「年度 × 品種」を軸に、田植え前の播種・育苗準備量を見積もる機能。
|
||||
各圃場について「実際に使う苗箱数」を記録し、計画全体で必要な種もみ量を自動集計する。
|
||||
|
||||
圃場候補は既存の作付け計画から自動取得し、種もみ在庫は作物単位、反当苗箱枚数の初期値は品種単位で管理する。
|
||||
圃場候補は既存の作付け計画から自動取得し、種もみ在庫は品種単位、反当苗箱枚数の初期値も品種単位で管理する。
|
||||
同じ年度・同じ品種でも、播種時期や育苗ロットを分けるために複数の田植え計画を作成できる。
|
||||
|
||||
### 機能スコープ(IN / OUT)
|
||||
@@ -24,7 +24,7 @@
|
||||
| 圃場ごとの苗箱数の個別調整 | 種もみロット管理 |
|
||||
| 列単位のデフォルト反映・四捨五入 | 在庫の自動引当 |
|
||||
| 苗箱合計・種もみkg合計の自動集計 | PDF出力 |
|
||||
| 作物ごとの種もみ在庫kg管理 | 品種ごとの播種日管理 |
|
||||
| 品種ごとの種もみ在庫参照 | 品種ごとの播種日管理 |
|
||||
| 品種ごとの反当苗箱枚数デフォルト管理 | |
|
||||
|
||||
---
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
1. 田植え計画は `年度 × 品種` を軸に作成する
|
||||
2. 対象圃場は、その年度・品種の作付け計画が登録されている圃場から取得する
|
||||
3. 種もみ在庫は作物単位で管理する
|
||||
3. 種もみ在庫は品種単位で管理する
|
||||
4. 反当苗箱枚数の初期値は品種単位で管理する
|
||||
5. 計画ヘッダ側に `反当苗箱枚数` を持ち、施肥計画の `反当袋数` と同じ役割で使う
|
||||
6. 画面上では `反当苗箱枚数 × 面積(反)` を各圃場のデフォルト苗箱数として表示する
|
||||
@@ -57,27 +57,20 @@
|
||||
|
||||
### 計画全体の残在庫見込み
|
||||
|
||||
`残在庫見込み = 作物の種もみ在庫(kg) - 計画全体の種もみkg合計`
|
||||
`残在庫見込み = 品種の種もみ在庫(kg) - 計画全体の種もみkg合計`
|
||||
|
||||
---
|
||||
|
||||
## データモデル
|
||||
|
||||
### Crop(作物マスタ)
|
||||
|
||||
既存 `plans.Crop` に以下を追加。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| seed_inventory_kg | decimal(10,3) | default=0 | 作物単位の種もみ在庫(kg) |
|
||||
|
||||
### Variety(品種マスタ)
|
||||
|
||||
既存 `plans.Variety` に以下を追加。
|
||||
既存 `plans.Variety` に以下を追加・参照する。
|
||||
|
||||
| フィールド | 型 | 制約 | 説明 |
|
||||
|---|---|---|---|
|
||||
| default_seedling_boxes_per_tan | decimal(6,2) | default=0 | 反当苗箱枚数の初期値 |
|
||||
| seed_material | FK(materials.Material) 相当 | nullable | その品種に対応する種子在庫 |
|
||||
|
||||
### RiceTransplantPlan(田植え計画)
|
||||
|
||||
@@ -103,7 +96,7 @@
|
||||
| field_count | int | 対象圃場数 |
|
||||
| total_seedling_boxes | decimal | 苗箱数合計 |
|
||||
| total_seed_kg | decimal | 種もみ使用量合計(kg) |
|
||||
| crop_seed_inventory_kg | decimal | 作物在庫(kg) |
|
||||
| variety_seed_inventory_kg | decimal | 品種在庫(kg) |
|
||||
| remaining_seed_kg | decimal | 残在庫見込み(kg) |
|
||||
|
||||
### RiceTransplantEntry(田植え計画エントリ)
|
||||
@@ -126,7 +119,6 @@
|
||||
| field_area_tan | decimal | 圃場面積(反) |
|
||||
| default_seedling_boxes | decimal | `反当苗箱枚数 × 面積(反)` で求めたデフォルト候補 |
|
||||
| planned_boxes | decimal | 圃場ごとの苗箱数 |
|
||||
| planned_seed_kg | decimal | 圃場ごとの必要種もみkg |
|
||||
|
||||
---
|
||||
|
||||
@@ -157,11 +149,12 @@
|
||||
"crop_name": "水稲",
|
||||
"seedling_boxes_per_tan": "12.00",
|
||||
"default_seed_grams_per_box": "200.00",
|
||||
"seed_material_name": "にこまる 種もみ",
|
||||
"notes": "",
|
||||
"field_count": 8,
|
||||
"total_seedling_boxes": "98.40",
|
||||
"total_seed_kg": "19.680",
|
||||
"crop_seed_inventory_kg": "25.000",
|
||||
"variety_seed_inventory_kg": "25.000",
|
||||
"remaining_seed_kg": "5.320",
|
||||
"entries": [
|
||||
{
|
||||
@@ -171,8 +164,7 @@
|
||||
"field_area_tan": "1.2000",
|
||||
"installed_seedling_boxes": "14.40",
|
||||
"default_seedling_boxes": "14.40",
|
||||
"planned_boxes": "14.40",
|
||||
"planned_seed_kg": "2.880"
|
||||
"planned_boxes": "14.40"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -203,14 +195,15 @@ POST/PUT リクエスト例:
|
||||
|
||||
更新時は `entries` を全置換する。
|
||||
|
||||
### 作物・品種マスタ更新
|
||||
### 品種マスタ更新 / 在庫管理
|
||||
|
||||
田植え計画に必要な既定値は既存 API で更新する。
|
||||
|
||||
| メソッド | URL | 更新項目 |
|
||||
|---|---|---|
|
||||
| PATCH | `/api/plans/crops/{id}/` | `seed_inventory_kg` |
|
||||
| PATCH | `/api/plans/varieties/{id}/` | `default_seedling_boxes_per_tan` |
|
||||
| PATCH | `/api/plans/varieties/{id}/` | `seed_material` または同等の種子在庫参照 |
|
||||
| CRUD | `/api/materials/materials/?material_type=seed` | 品種別の種子在庫マスタ |
|
||||
|
||||
---
|
||||
|
||||
@@ -255,26 +248,26 @@ POST/PUT リクエスト例:
|
||||
- 面積(反)
|
||||
- 苗箱数入力欄
|
||||
- 左側にデフォルト苗箱数ラベルを表示
|
||||
- 必要種もみkg
|
||||
- 小数は 1 桁表示を基本とする
|
||||
- 列操作:
|
||||
- `反当苗箱枚数` の入力欄
|
||||
- デフォルトを列単位で一括反映するボタン
|
||||
- 列単位の四捨五入ボタン
|
||||
- 施肥計画の四捨五入ボタンと同じ配置・2ステート動作
|
||||
- サマリー:
|
||||
- 対象圃場数
|
||||
- 苗箱合計
|
||||
- 種もみ計画kg
|
||||
- 作物在庫kg
|
||||
- 品種在庫kg
|
||||
- 残在庫見込みkg
|
||||
|
||||
### 3. 品種管理モーダル `/allocation`
|
||||
|
||||
既存の作付け計画画面内の品種管理モーダルを拡張。
|
||||
|
||||
- 作物単位:
|
||||
- 種もみ在庫(kg) を更新可能
|
||||
- 品種単位:
|
||||
- 反当苗箱枚数デフォルトを更新可能
|
||||
- 対応する種子在庫を設定可能
|
||||
|
||||
---
|
||||
|
||||
@@ -291,10 +284,8 @@ POST/PUT リクエスト例:
|
||||
|
||||
## 既知の制約
|
||||
|
||||
1. 種もみ在庫は作物単位のみで、品種別在庫には未対応
|
||||
2. 田植え計画の PDF 出力は未実装
|
||||
3. 在庫管理 `materials` app とは未連携で、引当・使用実績は持たない
|
||||
4. 実播種や田植え実績との連携は未実装
|
||||
1. 田植え計画の PDF 出力は未実装
|
||||
2. 実播種や田植え実績との連携は未実装
|
||||
|
||||
---
|
||||
|
||||
@@ -303,11 +294,15 @@ POST/PUT リクエスト例:
|
||||
| 種別 | パス |
|
||||
|---|---|
|
||||
| モデル | `backend/apps/plans/models.py` |
|
||||
| モデル | `backend/apps/materials/models.py` |
|
||||
| シリアライザ | `backend/apps/plans/serializers.py` |
|
||||
| シリアライザ | `backend/apps/materials/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`, `backend/apps/plans/migrations/0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes.py`, `backend/apps/plans/migrations/0007_ricetransplantplan_seedling_boxes_per_tan.py` |
|
||||
| マイグレーション | `backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py`, `backend/apps/plans/migrations/0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes.py`, `backend/apps/plans/migrations/0007_ricetransplantplan_seedling_boxes_per_tan.py`, `backend/apps/plans/migrations/0008_variety_seed_material.py`, `backend/apps/materials/migrations/0005_material_seed_type.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` |
|
||||
| 在庫画面 | `frontend/src/app/materials/page.tsx` |
|
||||
| 資材マスタ | `frontend/src/app/materials/masters/page.tsx` |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Field, Crop, Plan } from '@/types';
|
||||
import { Field, Crop, Material, Plan } from '@/types';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react';
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function AllocationPage() {
|
||||
const [fields, setFields] = useState<Field[]>([]);
|
||||
const [crops, setCrops] = useState<Crop[]>([]);
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [seedMaterials, setSeedMaterials] = useState<Material[]>([]);
|
||||
const [year, setYear] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('allocationYear');
|
||||
@@ -60,14 +61,16 @@ export default function AllocationPage() {
|
||||
const fetchData = async (background = false) => {
|
||||
if (!background) setLoading(true);
|
||||
try {
|
||||
const [fieldsRes, cropsRes, plansRes] = await Promise.all([
|
||||
const [fieldsRes, cropsRes, plansRes, seedMaterialsRes] = await Promise.all([
|
||||
api.get('/fields/?ordering=group_name,display_order,id'),
|
||||
api.get('/plans/crops/'),
|
||||
api.get(`/plans/?year=${year}`),
|
||||
api.get('/materials/materials/?material_type=seed'),
|
||||
]);
|
||||
setFields(fieldsRes.data);
|
||||
setCrops(cropsRes.data);
|
||||
setPlans(plansRes.data);
|
||||
setSeedMaterials(seedMaterialsRes.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
@@ -367,20 +370,6 @@ 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);
|
||||
@@ -395,6 +384,20 @@ export default function AllocationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateVarietySeedMaterial = async (varietyId: number, seedMaterialId: string) => {
|
||||
try {
|
||||
const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId);
|
||||
if (!variety) return;
|
||||
await api.patch(`/plans/varieties/${varietyId}/`, {
|
||||
seed_material: seedMaterialId ? parseInt(seedMaterialId, 10) : null,
|
||||
});
|
||||
await fetchData(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to update variety seed material:', error);
|
||||
alert('種子在庫の紐付け更新に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFieldSelection = (fieldId: number) => {
|
||||
setSelectedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1057,15 +1060,6 @@ export default function AllocationPage() {
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<ul className="space-y-2">
|
||||
{getVarietiesForCrop(managerCropId).map((v) => (
|
||||
@@ -1085,6 +1079,15 @@ export default function AllocationPage() {
|
||||
initialValue={v.default_seedling_boxes_per_tan}
|
||||
onSave={handleUpdateVarietyDefaultBoxes}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<VarietySeedMaterialForm
|
||||
varietyId={v.id}
|
||||
initialValue={v.seed_material ? String(v.seed_material) : ''}
|
||||
initialLabel={v.seed_material_name}
|
||||
materials={seedMaterials}
|
||||
onSave={handleUpdateVarietySeedMaterial}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1150,49 +1153,6 @@ function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1236,3 +1196,60 @@ function VarietyDefaultBoxesForm({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VarietySeedMaterialForm({
|
||||
varietyId,
|
||||
initialValue,
|
||||
initialLabel,
|
||||
materials,
|
||||
onSave,
|
||||
}: {
|
||||
varietyId: number;
|
||||
initialValue: string;
|
||||
initialLabel: string | null;
|
||||
materials: Material[];
|
||||
onSave: (varietyId: number, seedMaterialId: 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>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">未設定</option>
|
||||
{materials.map((material) => (
|
||||
<option key={material.id} value={material.id}>
|
||||
{material.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
現在: {initialLabel || '未設定'}
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Check, X } from 'lucide-react';
|
||||
|
||||
import { Material } from '@/types';
|
||||
|
||||
export type MaterialTab = 'fertilizer' | 'pesticide' | 'misc';
|
||||
export type MaterialTab = 'fertilizer' | 'pesticide' | 'seed' | 'misc';
|
||||
|
||||
export interface MaterialFormState {
|
||||
name: string;
|
||||
@@ -244,14 +244,20 @@ export default function MaterialForm({
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<select
|
||||
className={inputClassName}
|
||||
value={form.material_type}
|
||||
onChange={(e) => onBaseFieldChange('material_type', e.target.value)}
|
||||
>
|
||||
<option value="other">その他</option>
|
||||
<option value="seedling">種苗</option>
|
||||
</select>
|
||||
{tab === 'seed' ? (
|
||||
<div className="rounded-md border border-green-200 bg-green-100 px-2 py-1 text-sm text-green-800">
|
||||
種子
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
className={inputClassName}
|
||||
value={form.material_type}
|
||||
onChange={(e) => onBaseFieldChange('material_type', e.target.value)}
|
||||
>
|
||||
<option value="other">その他</option>
|
||||
<option value="seedling">種苗</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
|
||||
@@ -15,15 +15,23 @@ import { Material } from '@/types';
|
||||
const tabs: { key: MaterialTab; label: string }[] = [
|
||||
{ key: 'fertilizer', label: '肥料' },
|
||||
{ key: 'pesticide', label: '農薬' },
|
||||
{ key: 'seed', label: '種子' },
|
||||
{ key: 'misc', label: 'その他' },
|
||||
];
|
||||
|
||||
const emptyForm = (tab: MaterialTab): MaterialFormState => ({
|
||||
name: '',
|
||||
material_type:
|
||||
tab === 'fertilizer' ? 'fertilizer' : tab === 'pesticide' ? 'pesticide' : 'other',
|
||||
tab === 'fertilizer'
|
||||
? 'fertilizer'
|
||||
: tab === 'pesticide'
|
||||
? 'pesticide'
|
||||
: tab === 'seed'
|
||||
? 'seed'
|
||||
: 'other',
|
||||
maker: '',
|
||||
stock_unit: tab === 'fertilizer' ? 'bag' : tab === 'pesticide' ? 'bottle' : 'piece',
|
||||
stock_unit:
|
||||
tab === 'fertilizer' ? 'bag' : tab === 'pesticide' ? 'bottle' : tab === 'seed' ? 'kg' : 'piece',
|
||||
is_active: true,
|
||||
notes: '',
|
||||
fertilizer_profile: {
|
||||
@@ -334,6 +342,21 @@ export default function MaterialMastersPage() {
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
)}
|
||||
{tab === 'seed' && (
|
||||
<SeedTable
|
||||
materials={visibleMaterials}
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
saving={saving}
|
||||
onEdit={startEdit}
|
||||
onDelete={handleDelete}
|
||||
onBaseFieldChange={handleBaseFieldChange}
|
||||
onFertilizerFieldChange={handleFertilizerFieldChange}
|
||||
onPesticideFieldChange={handlePesticideFieldChange}
|
||||
onSave={handleSave}
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
)}
|
||||
{tab === 'misc' && (
|
||||
<MiscTable
|
||||
materials={visibleMaterials}
|
||||
@@ -560,6 +583,57 @@ function MiscTable(props: TableProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function SeedTable(props: TableProps) {
|
||||
return (
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="border-b border-gray-200">
|
||||
<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-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-left font-medium text-gray-700">備考</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-700">使用中</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{props.editingId === 'new' && <MaterialForm tab="seed" {...props} />}
|
||||
{props.materials.map((material) =>
|
||||
props.editingId === material.id ? (
|
||||
<MaterialForm key={material.id} tab="seed" {...props} />
|
||||
) : (
|
||||
<tr key={material.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{material.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.material_type_display}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.maker || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{material.stock_unit_display}</td>
|
||||
<td className="max-w-xs px-4 py-3 text-gray-600">{material.notes || '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-600">
|
||||
{material.is_active ? '○' : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RowActions
|
||||
disabled={props.editingId !== null}
|
||||
onEdit={() => props.onEdit(material)}
|
||||
onDelete={() => props.onDelete(material)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{props.materials.length === 0 && props.editingId === null && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
該当する資材が登録されていません
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActions({
|
||||
disabled,
|
||||
onEdit,
|
||||
|
||||
@@ -10,12 +10,13 @@ import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Material, StockSummary, StockTransaction } from '@/types';
|
||||
|
||||
type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'misc';
|
||||
type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'seed' | 'misc';
|
||||
|
||||
const tabs: { key: FilterTab; label: string }[] = [
|
||||
{ key: 'all', label: '全て' },
|
||||
{ key: 'fertilizer', label: '肥料' },
|
||||
{ key: 'pesticide', label: '農薬' },
|
||||
{ key: 'seed', label: '種子' },
|
||||
{ key: 'misc', label: 'その他' },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ChevronLeft, Save } from 'lucide-react';
|
||||
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { Crop, Field, RiceTransplantPlan } from '@/types';
|
||||
import { Crop, Field, RiceTransplantPlan, StockSummary } from '@/types';
|
||||
|
||||
type BoxMap = Record<number, string>;
|
||||
|
||||
@@ -27,10 +27,11 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
const [allFields, setAllFields] = useState<Field[]>([]);
|
||||
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
|
||||
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
|
||||
const [seedStocks, setSeedStocks] = useState<StockSummary[]>([]);
|
||||
|
||||
const [calcBoxes, setCalcBoxes] = useState<BoxMap>({});
|
||||
const [adjustedBoxes, setAdjustedBoxes] = useState<BoxMap>({});
|
||||
const [applyToEmptyOnly, setApplyToEmptyOnly] = useState(true);
|
||||
const [boxesRounded, setBoxesRounded] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -41,27 +42,24 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
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 calculateDefaultBoxes = (field: Field, perTan: string) => {
|
||||
const areaTan = parseFloat(field.area_tan || '0');
|
||||
const boxesPerTan = parseFloat(perTan || '0');
|
||||
return isNaN(areaTan * boxesPerTan) ? '' : (areaTan * boxesPerTan).toFixed(2);
|
||||
return Number.isNaN(areaTan * boxesPerTan) ? '' : (areaTan * boxesPerTan).toFixed(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [cropsRes, fieldsRes] = await Promise.all([
|
||||
const [cropsRes, fieldsRes, seedStockRes] = await Promise.all([
|
||||
api.get('/plans/crops/'),
|
||||
api.get('/fields/?ordering=display_order,id'),
|
||||
api.get('/materials/stock-summary/?material_type=seed'),
|
||||
]);
|
||||
setCrops(cropsRes.data);
|
||||
setAllFields(fieldsRes.data);
|
||||
setSeedStocks(seedStockRes.data);
|
||||
|
||||
if (!isNew && planId) {
|
||||
const planRes = await api.get(`/plans/rice-transplant-plans/${planId}/`);
|
||||
@@ -81,8 +79,8 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
const nextAdjusted: BoxMap = {};
|
||||
const nextCalc: BoxMap = {};
|
||||
plan.entries.forEach((entry) => {
|
||||
nextAdjusted[entry.field] = String(entry.installed_seedling_boxes);
|
||||
nextCalc[entry.field] = String(entry.default_seedling_boxes);
|
||||
nextAdjusted[entry.field] = Number(entry.installed_seedling_boxes).toFixed(1);
|
||||
nextCalc[entry.field] = Number(entry.default_seedling_boxes).toFixed(1);
|
||||
});
|
||||
setAdjustedBoxes(nextAdjusted);
|
||||
setCalcBoxes(nextCalc);
|
||||
@@ -101,7 +99,9 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
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 res = await api.get(
|
||||
`/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${varietyId}`
|
||||
);
|
||||
const nextCandidates: Field[] = res.data;
|
||||
setCandidateFields(nextCandidates);
|
||||
if (isNew) {
|
||||
@@ -122,7 +122,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
if (isNew || seedlingBoxesPerTan === '') {
|
||||
setSeedlingBoxesPerTan(variety.default_seedling_boxes_per_tan);
|
||||
}
|
||||
}, [varietyId, crops, isNew]);
|
||||
}, [varietyId, crops, isNew, seedlingBoxesPerTan]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextCalc: BoxMap = {};
|
||||
@@ -130,6 +130,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
nextCalc[field.id] = calculateDefaultBoxes(field, seedlingBoxesPerTan);
|
||||
});
|
||||
setCalcBoxes(nextCalc);
|
||||
setBoxesRounded(false);
|
||||
}, [selectedFields, seedlingBoxesPerTan]);
|
||||
|
||||
const addField = (field: Field) => {
|
||||
@@ -162,39 +163,59 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
setAdjustedBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
selectedFields.forEach((field) => {
|
||||
const currentValue = prev[field.id] ?? '';
|
||||
if (applyToEmptyOnly && currentValue !== '') return;
|
||||
next[field.id] = calcBoxes[field.id] ?? '';
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setBoxesRounded(false);
|
||||
};
|
||||
|
||||
const roundColumn = () => {
|
||||
const toggleRoundColumn = () => {
|
||||
if (boxesRounded) {
|
||||
setAdjustedBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
selectedFields.forEach((field) => {
|
||||
delete next[field.id];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setBoxesRounded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustedBoxes((prev) => {
|
||||
const next = { ...prev };
|
||||
selectedFields.forEach((field) => {
|
||||
const raw = prev[field.id] !== undefined && prev[field.id] !== '' ? prev[field.id] : calcBoxes[field.id];
|
||||
const raw = calcBoxes[field.id] ?? prev[field.id];
|
||||
if (!raw) return;
|
||||
const value = parseFloat(raw);
|
||||
if (isNaN(value)) return;
|
||||
if (Number.isNaN(value)) return;
|
||||
next[field.id] = String(Math.round(value));
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setBoxesRounded(true);
|
||||
};
|
||||
|
||||
const effectiveBoxes = (fieldId: number) => {
|
||||
const raw = adjustedBoxes[fieldId] !== undefined && adjustedBoxes[fieldId] !== '' ? adjustedBoxes[fieldId] : calcBoxes[fieldId];
|
||||
const raw =
|
||||
adjustedBoxes[fieldId] !== undefined && adjustedBoxes[fieldId] !== ''
|
||||
? adjustedBoxes[fieldId]
|
||||
: calcBoxes[fieldId];
|
||||
const value = parseFloat(raw ?? '0');
|
||||
return isNaN(value) ? 0 : value;
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
};
|
||||
|
||||
const selectedVariety = varietyId ? getVariety(varietyId) : null;
|
||||
const seedStock = selectedVariety?.seed_material
|
||||
? seedStocks.find((item) => item.material_id === selectedVariety.seed_material) ?? null
|
||||
: null;
|
||||
|
||||
const totalBoxes = selectedFields.reduce((sum, field) => sum + effectiveBoxes(field.id), 0);
|
||||
const seedGrams = parseFloat(defaultSeedGramsPerBox || '0');
|
||||
const totalSeedKg = seedGrams > 0 ? (totalBoxes * seedGrams) / 1000 : 0;
|
||||
const cropSeedInventoryKg = parseFloat(getSelectedCrop()?.seed_inventory_kg ?? '0');
|
||||
const remainingSeedKg = cropSeedInventoryKg - totalSeedKg;
|
||||
const seedInventoryKg = parseFloat(seedStock?.current_stock ?? '0');
|
||||
const remainingSeedKg = seedInventoryKg - totalSeedKg;
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
@@ -251,7 +272,10 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
selectedFields.map((field) => ({
|
||||
field,
|
||||
defaultBoxes: calcBoxes[field.id] ?? '',
|
||||
boxCount: adjustedBoxes[field.id] !== undefined && adjustedBoxes[field.id] !== '' ? adjustedBoxes[field.id] : calcBoxes[field.id] ?? '',
|
||||
boxCount:
|
||||
adjustedBoxes[field.id] !== undefined && adjustedBoxes[field.id] !== ''
|
||||
? adjustedBoxes[field.id]
|
||||
: calcBoxes[field.id] ?? '',
|
||||
})),
|
||||
[selectedFields, calcBoxes, adjustedBoxes]
|
||||
);
|
||||
@@ -271,7 +295,10 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
<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">
|
||||
<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">
|
||||
@@ -301,9 +328,11 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
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回"
|
||||
placeholder="例: 2026年度 にこまる 第1回"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">同じ年度・同じ品種でも、第1回や播種日ごとに複数計画を作れます。</p>
|
||||
<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>
|
||||
@@ -339,7 +368,9 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">苗箱1枚あたり種もみ(g)</label>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">
|
||||
苗箱1枚あたり種もみ(g)
|
||||
</label>
|
||||
<input
|
||||
value={defaultSeedGramsPerBox}
|
||||
onChange={(e) => setDefaultSeedGramsPerBox(e.target.value)}
|
||||
@@ -352,17 +383,6 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
<div className="mb-4 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>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyToEmptyOnly}
|
||||
onChange={(e) => setApplyToEmptyOnly(e.target.checked)}
|
||||
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
未入力圃場のみ
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
@@ -375,7 +395,9 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
{field.name} ×
|
||||
</button>
|
||||
))}
|
||||
{selectedFields.length === 0 && <p className="text-sm text-gray-500">圃場が選択されていません。</p>}
|
||||
{selectedFields.length === 0 && (
|
||||
<p className="text-sm text-gray-500">圃場が選択されていません。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unselectedFields.length > 0 && (
|
||||
@@ -396,35 +418,6 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-lg bg-white p-4 shadow">
|
||||
<div className="mb-3 flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">反当苗箱枚数</label>
|
||||
<input
|
||||
value={seedlingBoxesPerTan}
|
||||
onChange={(e) => setSeedlingBoxesPerTan(e.target.value)}
|
||||
className="w-28 rounded-lg border border-gray-300 px-3 py-2 text-sm text-right focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyColumnDefaults}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
デフォルトを反映
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={roundColumn}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
四捨五入
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">各圃場のデフォルト苗箱数は `反当苗箱枚数 × 面積(反)` で計算されます。</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-4 md:grid-cols-[2fr,1fr]">
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">備考</label>
|
||||
@@ -445,17 +438,21 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>苗箱合計</span>
|
||||
<span>{totalBoxes.toFixed(2)}枚</span>
|
||||
<span>{totalBoxes.toFixed(1)}枚</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>
|
||||
<span>{seedStock?.name || selectedVariety?.seed_material_name || '種子在庫未設定'}</span>
|
||||
<span>{seedInventoryKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
<div className={`flex justify-between font-semibold ${remainingSeedKg < 0 ? 'text-red-600' : 'text-emerald-700'}`}>
|
||||
<div
|
||||
className={`flex justify-between font-semibold ${
|
||||
remainingSeedKg < 0 ? 'text-red-600' : 'text-emerald-700'
|
||||
}`}
|
||||
>
|
||||
<span>残在庫見込み</span>
|
||||
<span>{remainingSeedKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
@@ -469,32 +466,74 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
|
||||
<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">種もみkg</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-700">
|
||||
<div>苗箱数</div>
|
||||
<div className="mt-1 space-y-0.5 text-[11px] font-normal leading-4">
|
||||
<div className="text-gray-500">反当 {seedlingBoxesPerTan || '0'}枚</div>
|
||||
<div className="text-gray-500">合計 {totalBoxes.toFixed(1)}枚</div>
|
||||
</div>
|
||||
<span className="mt-1 flex items-center justify-center gap-1.5 text-xs font-normal text-gray-400">
|
||||
(枚)
|
||||
<button
|
||||
onClick={toggleRoundColumn}
|
||||
className={`inline-flex h-5 w-5 items-center justify-center rounded font-bold leading-none ${
|
||||
boxesRounded
|
||||
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
|
||||
: 'bg-blue-100 text-blue-500 hover:bg-blue-200'
|
||||
}`}
|
||||
title={boxesRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
|
||||
>
|
||||
{boxesRounded ? '↩' : '≈'}
|
||||
</button>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border-t border-gray-200 px-4 py-2 text-left text-xs font-medium text-gray-500">
|
||||
反当苗箱枚数
|
||||
</th>
|
||||
<th className="border-t border-gray-200 px-4 py-2" />
|
||||
<th className="border-t border-gray-200 px-4 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<input
|
||||
value={seedlingBoxesPerTan}
|
||||
onChange={(e) => setSeedlingBoxesPerTan(e.target.value)}
|
||||
className="w-24 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:outline-none focus:ring-1 focus:ring-green-400"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyColumnDefaults}
|
||||
className="rounded border border-blue-300 px-3 py-1 text-xs text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
反映
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{fieldRows.map(({ field, defaultBoxes, boxCount }) => {
|
||||
const seedKg = (effectiveBoxes(field.id) * seedGrams) / 1000;
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<span className="text-xs tabular-nums text-gray-500">既定 {defaultBoxes || '0.00'}枚</span>
|
||||
<input
|
||||
value={boxCount}
|
||||
onChange={(e) => updateBoxCount(field.id, 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"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-700">{seedKg.toFixed(3)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{fieldRows.map(({ field, defaultBoxes, boxCount }) => (
|
||||
<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">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<span className="text-xs tabular-nums text-gray-500">
|
||||
既定 {defaultBoxes || '0.0'}枚
|
||||
</span>
|
||||
<input
|
||||
value={boxCount}
|
||||
onChange={(e) => updateBoxCount(field.id, 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"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -37,12 +37,13 @@ export interface Variety {
|
||||
crop: number;
|
||||
name: string;
|
||||
default_seedling_boxes_per_tan: string;
|
||||
seed_material: number | null;
|
||||
seed_material_name: string | null;
|
||||
}
|
||||
|
||||
export interface Crop {
|
||||
id: number;
|
||||
name: string;
|
||||
seed_inventory_kg: string;
|
||||
varieties: Variety[];
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ export interface PesticideProfile {
|
||||
export interface Material {
|
||||
id: number;
|
||||
name: string;
|
||||
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
|
||||
material_type: 'fertilizer' | 'pesticide' | 'seed' | 'seedling' | 'other';
|
||||
material_type_display: string;
|
||||
maker: string;
|
||||
stock_unit: 'bag' | 'bottle' | 'kg' | 'liter' | 'piece';
|
||||
@@ -123,7 +124,7 @@ export interface StockTransaction {
|
||||
export interface StockSummary {
|
||||
material_id: number;
|
||||
name: string;
|
||||
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
|
||||
material_type: 'fertilizer' | 'pesticide' | 'seed' | 'seedling' | 'other';
|
||||
material_type_display: string;
|
||||
maker: string;
|
||||
stock_unit: string;
|
||||
@@ -175,7 +176,6 @@ export interface RiceTransplantEntry {
|
||||
installed_seedling_boxes: string;
|
||||
default_seedling_boxes: string;
|
||||
planned_boxes: string;
|
||||
planned_seed_kg: string;
|
||||
}
|
||||
|
||||
export interface RiceTransplantPlan {
|
||||
@@ -188,11 +188,12 @@ export interface RiceTransplantPlan {
|
||||
default_seed_grams_per_box: string;
|
||||
seedling_boxes_per_tan: string;
|
||||
notes: string;
|
||||
seed_material_name: string | null;
|
||||
entries: RiceTransplantEntry[];
|
||||
field_count: number;
|
||||
total_seedling_boxes: string;
|
||||
total_seed_kg: string;
|
||||
crop_seed_inventory_kg: string;
|
||||
variety_seed_inventory_kg: string;
|
||||
remaining_seed_kg: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
Reference in New Issue
Block a user