Issue #2 に最新の理解を記録し、その内容で仕様書と実装を修正しました。

document/16_マスタードキュメント_田植え計画編.md は、「行ごとに保持するのは圃場の苗箱数」「列側に反当苗箱枚数を持つ」「種もみg/箱 は全体共通値」という前提に更新しています。コード側は backend/apps/plans/models.py と backend/apps/plans/serializers.py で計画ヘッダに seedling_boxes_per_tan を追加し、backend/apps/plans/migrations/0007_ricetransplantplan_seedling_boxes_per_tan.py も作成しました。画面は frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx を施肥計画寄りに組み直し、列単位のデフォルト反映と四捨五入、行ごとの苗箱数入力に寄せています。frontend/src/types/index.ts も合わせて更新済みです。

確認できたのはバックエンドの構文チェックまでで、python3 -m py_compile backend/apps/plans/models.py backend/apps/plans/serializers.py backend/apps/plans/views.py は通過しています。フロントのビルド確認まではこの環境では回していません。Issue #2 にも今回の反映内容をコメント済みです。
This commit is contained in:
akira
2026-04-05 10:53:24 +09:00
parent 95c90dd699
commit 11b36b28a5
6 changed files with 275 additions and 219 deletions

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('plans', '0006_rename_seedling_boxes_per_tan_to_installed_seedling_boxes'),
]
operations = [
migrations.AddField(
model_name='ricetransplantplan',
name='seedling_boxes_per_tan',
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数'),
),
]

View File

@@ -71,6 +71,12 @@ class RiceTransplantPlan(models.Model):
default=0,
verbose_name='苗箱1枚あたり種もみ(g)デフォルト',
)
seedling_boxes_per_tan = models.DecimalField(
max_digits=6,
decimal_places=2,
default=0,
verbose_name='反当苗箱枚数',
)
notes = models.TextField(blank=True, default='', verbose_name='備考')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

View File

@@ -49,8 +49,8 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer):
read_only=True,
)
planned_boxes = serializers.SerializerMethodField()
planned_seed_kg = serializers.SerializerMethodField()
default_seedling_boxes = serializers.SerializerMethodField()
planned_seed_kg = serializers.SerializerMethodField()
class Meta:
model = RiceTransplantEntry
@@ -60,7 +60,6 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer):
'field_name',
'field_area_tan',
'installed_seedling_boxes',
'seed_grams_per_box',
'default_seedling_boxes',
'planned_boxes',
'planned_seed_kg',
@@ -68,7 +67,7 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer):
def get_default_seedling_boxes(self, obj):
area = Decimal(str(obj.field.area_tan))
default_boxes_per_tan = obj.plan.variety.default_seedling_boxes_per_tan
default_boxes_per_tan = obj.plan.seedling_boxes_per_tan
return str((area * default_boxes_per_tan).quantize(Decimal('0.01')))
def get_planned_boxes(self, obj):
@@ -76,7 +75,7 @@ class RiceTransplantEntrySerializer(serializers.ModelSerializer):
def get_planned_seed_kg(self, obj):
seed_kg = (
obj.installed_seedling_boxes * obj.seed_grams_per_box / Decimal('1000')
obj.installed_seedling_boxes * obj.plan.default_seed_grams_per_box / Decimal('1000')
).quantize(Decimal('0.001'))
return str(seed_kg)
@@ -101,6 +100,7 @@ class RiceTransplantPlanSerializer(serializers.ModelSerializer):
'variety_name',
'crop_name',
'default_seed_grams_per_box',
'seedling_boxes_per_tan',
'notes',
'entries',
'field_count',
@@ -126,7 +126,7 @@ class RiceTransplantPlanSerializer(serializers.ModelSerializer):
total = sum(
(
entry.installed_seedling_boxes
* entry.seed_grams_per_box
* obj.default_seed_grams_per_box
/ Decimal('1000')
)
for entry in obj.entries.all()
@@ -152,6 +152,7 @@ class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
'year',
'variety',
'default_seed_grams_per_box',
'seedling_boxes_per_tan',
'notes',
'entries',
]
@@ -192,5 +193,5 @@ class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
plan=plan,
field_id=entry['field_id'],
installed_seedling_boxes=entry['installed_seedling_boxes'],
seed_grams_per_box=entry['seed_grams_per_box'],
seed_grams_per_box=plan.default_seed_grams_per_box,
)

View File

@@ -10,7 +10,7 @@
## 概要
農業生産者が「年度 × 品種」を軸に、田植え前の播種・育苗準備量を見積もる機能。
各圃場について「実際に設置する苗箱数」と「苗箱1枚あたり種もみを何g使うか」を記録し、圃場別・計画全体の苗箱枚数と種もみ使用量を自動集計する。
各圃場について「実際に使う苗箱数」を記録し、計画全体で必要な種もみ量を自動集計する。
圃場候補は既存の作付け計画から自動取得し、種もみ在庫は作物単位、反当苗箱枚数の初期値は品種単位で管理する。
同じ年度・同じ品種でも、播種時期や育苗ロットを分けるために複数の田植え計画を作成できる。
@@ -21,8 +21,8 @@
|---|---|
| 田植え計画の作成・編集・削除 | 育苗日程のカレンダー管理 |
| 作付け計画からの候補圃場自動取得 | 実播種実績の記録 |
| 圃場ごとの設置苗箱数の個別調整 | 種もみロット管理 |
| 圃場ごとの種もみg/箱の個別調整 | 在庫の自動引当 |
| 圃場ごとの苗箱数の個別調整 | 種もみロット管理 |
| 列単位のデフォルト反映・四捨五入 | 在庫の自動引当 |
| 苗箱合計・種もみkg合計の自動集計 | PDF出力 |
| 作物ごとの種もみ在庫kg管理 | 品種ごとの播種日管理 |
| 品種ごとの反当苗箱枚数デフォルト管理 | |
@@ -35,24 +35,25 @@
2. 対象圃場は、その年度・品種の作付け計画が登録されている圃場から取得する
3. 種もみ在庫は作物単位で管理する
4. 反当苗箱枚数の初期値は品種単位で管理する
5. 画面上では `反当苗箱枚数 × 面積(反)` を設置苗箱枚数のデフォルト候補として表示する
6. 実際に保存するのは圃場ごとの `設置苗箱枚数` であり、手動調整後の値をそのまま保持する
7. 種もみg/箱は計画全体のデフォルト値を持ちつつ、圃場単位で上書きでき
8. 在庫不足はエラーで保存停止せず、一覧・編集画面で残在庫見込みとして可視化する
9. 同じ年度・同じ品種で複数の計画を作成してよい
10. 複数回に分ける場合は、`計画名` で「第1回」「第2回」「4/10播種分」などを区別する
5. 計画ヘッダ側に `反当苗箱枚数` を持ち、施肥計画の `反当袋数` と同じ役割で使う
6. 画面上では `反当苗箱枚数 × 面積(反)` を各圃場のデフォルト苗箱数として表示する
7. 実際に保存するのは圃場ごとの `苗箱数` であり、手動調整後の値をそのまま保持す
8. `種もみg/箱` は計画全体の共通パラメータとして扱い、圃場ごとには持たない
9. 在庫不足はエラーで保存停止せず、一覧・編集画面で残在庫見込みとして可視化する
10. 同じ年度・同じ品種で複数の計画を作成してよい
11. 複数回に分ける場合は、`計画名` で「第1回」「第2回」「4/10播種分」などを区別する
---
## 計算式
### 圃場ごとの設置苗箱枚数デフォルト候補
### 圃場ごとのデフォルト苗箱数
`設置苗箱枚数デフォルト = 圃場面積(反) × 反当苗箱枚数`
`デフォルト苗箱数 = 圃場面積(反) × 反当苗箱枚数`
### 圃場ごとの種もみ使用量
`種もみkg = 設置苗箱枚数 × 苗箱1枚あたり種もみ(g) ÷ 1000`
`種もみkg = 苗箱数合計 × 苗箱1枚あたり種もみ(g) ÷ 1000`
### 計画全体の残在庫見込み
@@ -86,6 +87,7 @@
| name | varchar(200) | required | 計画名 |
| year | int | required | 年度 |
| variety | FK(plans.Variety) | PROTECT | 品種 |
| seedling_boxes_per_tan | decimal(6,2) | default=0 | 計画で使う反当苗箱枚数 |
| default_seed_grams_per_box | decimal(8,2) | default=0 | 苗箱1枚あたり種もみ(g)の初期値 |
| notes | text | blank | 備考 |
| created_at | datetime | auto | |
@@ -99,7 +101,7 @@
| 項目 | 型 | 説明 |
|---|---|---|
| field_count | int | 対象圃場数 |
| total_seedling_boxes | decimal | 設置苗箱数合計 |
| total_seedling_boxes | decimal | 苗箱数合計 |
| total_seed_kg | decimal | 種もみ使用量合計(kg) |
| crop_seed_inventory_kg | decimal | 作物在庫(kg) |
| remaining_seed_kg | decimal | 残在庫見込み(kg) |
@@ -111,8 +113,7 @@
| id | int | PK | |
| plan | FK(RiceTransplantPlan) | CASCADE | |
| field | FK(fields.Field) | CASCADE | |
| installed_seedling_boxes | decimal(8,2) | required | 設置苗箱数 |
| seed_grams_per_box | decimal(8,2) | required | 苗箱1枚あたり種もみ(g) |
| installed_seedling_boxes | decimal(8,2) | required | その圃場の苗箱数 |
- `unique_together = ['plan', 'field']`
- 順序: `field__display_order, field__id`
@@ -124,8 +125,8 @@
| field_name | string | 圃場名 |
| field_area_tan | decimal | 圃場面積(反) |
| default_seedling_boxes | decimal | `反当苗箱枚数 × 面積(反)` で求めたデフォルト候補 |
| planned_boxes | decimal | 圃場ごとの設置苗箱数 |
| planned_seed_kg | decimal | 圃場ごとの種もみkg |
| planned_boxes | decimal | 圃場ごとの苗箱数 |
| planned_seed_kg | decimal | 圃場ごとの必要種もみkg |
---
@@ -154,6 +155,7 @@
"variety": 3,
"variety_name": "コシヒカリ",
"crop_name": "水稲",
"seedling_boxes_per_tan": "12.00",
"default_seed_grams_per_box": "200.00",
"notes": "",
"field_count": 8,
@@ -168,7 +170,6 @@
"field_name": "田中上",
"field_area_tan": "1.2000",
"installed_seedling_boxes": "14.40",
"seed_grams_per_box": "200.00",
"default_seedling_boxes": "14.40",
"planned_boxes": "14.40",
"planned_seed_kg": "2.880"
@@ -184,18 +185,17 @@ POST/PUT リクエスト例:
"name": "2026年度 コシヒカリ 田植え計画",
"year": 2026,
"variety": 3,
"seedling_boxes_per_tan": "12.00",
"default_seed_grams_per_box": "200.00",
"notes": "",
"entries": [
{
"field_id": 5,
"installed_seedling_boxes": "14.40",
"seed_grams_per_box": "200.00"
"installed_seedling_boxes": "14.40"
},
{
"field_id": 6,
"installed_seedling_boxes": "13.80",
"seed_grams_per_box": "190.00"
"installed_seedling_boxes": "13.80"
}
]
}
@@ -247,26 +247,25 @@ POST/PUT リクエスト例:
- 新規作成時は候補圃場を初期選択
- 圃場の追加・除外が可能
- 初期値:
- `反当苗箱枚数` は品種マスタの `default_seedling_boxes_per_tan`
- `設置苗箱枚数デフォルト``反当苗箱枚数 × 面積(反)` で求める
- `種もみg/箱` は計画ヘッダの `default_seed_grams_per_box`
- `反当苗箱枚数` は品種マスタの `default_seedling_boxes_per_tan` を初期値として表示
- `反当苗箱枚数 × 面積(反)`各圃場のデフォルト苗箱数を求める
- `種もみg/箱` は計画全体の共通値
- 圃場テーブル:
- 圃場
- 面積(反)
- 設置苗箱枚数デフォルト(ラベル表示)
- 設置苗箱枚数(入力欄)
- 種もみg/箱
- デフォルト反映ボタン
- 苗箱合計
- 種もみkg
- 苗箱数入力欄
- 左側にデフォルト苗箱数ラベルを表示
- 必要種もみkg
- 列操作:
- `反当苗箱枚数` の入力欄
- デフォルトを列単位で一括反映するボタン
- 列単位の四捨五入ボタン
- サマリー:
- 対象圃場数
- 苗箱合計
- 種もみ計画kg
- 作物在庫kg
- 残在庫見込みkg
- 補助操作:
- 初期値を一括反映
### 3. 品種管理モーダル `/allocation`
@@ -284,7 +283,7 @@ POST/PUT リクエスト例:
1. 計画名は必須
2. 品種は必須
3. 圃場は1件以上必要
4. `installed_seedling_boxes``seed_grams_per_box` は 0 以上の数値を想定
4. `installed_seedling_boxes``seedling_boxes_per_tan` は 0 以上の数値を想定
5. 在庫不足でも保存は許可し、UIで不足を可視化する
6. 候補圃場の抽出元は既存 `Plan`(作付け計画)であるため、先に作付け計画が必要
@@ -307,7 +306,7 @@ POST/PUT リクエスト例:
| シリアライザ | `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` |
| マイグレーション | `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` |
| 一覧画面 | `frontend/src/app/rice-transplant/page.tsx` |
| 編集画面 | `frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx` |
| ナビゲーション | `frontend/src/components/Navbar.tsx` |

View File

@@ -8,12 +8,7 @@ import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Crop, Field, RiceTransplantPlan } from '@/types';
type EntryInput = {
installed_seedling_boxes: string;
seed_grams_per_box: string;
};
type EntryMap = Record<number, EntryInput>;
type BoxMap = Record<number, string>;
const currentYear = new Date().getFullYear();
@@ -24,6 +19,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
const [name, setName] = useState('');
const [year, setYear] = useState(currentYear);
const [varietyId, setVarietyId] = useState<number | ''>('');
const [seedlingBoxesPerTan, setSeedlingBoxesPerTan] = useState('');
const [defaultSeedGramsPerBox, setDefaultSeedGramsPerBox] = useState('200');
const [notes, setNotes] = useState('');
@@ -31,7 +27,10 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
const [allFields, setAllFields] = useState<Field[]>([]);
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
const [entries, setEntries] = useState<EntryMap>({});
const [calcBoxes, setCalcBoxes] = useState<BoxMap>({});
const [adjustedBoxes, setAdjustedBoxes] = useState<BoxMap>({});
const [applyToEmptyOnly, setApplyToEmptyOnly] = useState(true);
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
@@ -47,19 +46,10 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
return crops.find((crop) => crop.varieties.some((variety) => variety.id === varietyId)) ?? null;
};
const calculateDefaultBoxes = (fieldId: number) => {
const field = allFields.find((item) => item.id === fieldId);
const variety = varietyId ? getVariety(varietyId) : null;
const areaTan = parseFloat(field?.area_tan ?? '0');
const defaultBoxesPerTan = parseFloat(variety?.default_seedling_boxes_per_tan ?? '0');
return (areaTan * defaultBoxesPerTan).toFixed(2);
};
const initializeEntry = (fieldId: number, nextDefaultSeedGramsPerBox = defaultSeedGramsPerBox) => {
return {
installed_seedling_boxes: calculateDefaultBoxes(fieldId),
seed_grams_per_box: nextDefaultSeedGramsPerBox,
};
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);
};
useEffect(() => {
@@ -79,6 +69,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
setName(plan.name);
setYear(plan.year);
setVarietyId(plan.variety);
setSeedlingBoxesPerTan(plan.seedling_boxes_per_tan);
setDefaultSeedGramsPerBox(plan.default_seed_grams_per_box);
setNotes(plan.notes);
@@ -86,15 +77,15 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
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] = {
installed_seedling_boxes: String(entry.installed_seedling_boxes),
seed_grams_per_box: String(entry.seed_grams_per_box),
};
return acc;
}, {})
);
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);
});
setAdjustedBoxes(nextAdjusted);
setCalcBoxes(nextCalc);
}
} catch (e) {
console.error(e);
@@ -115,15 +106,6 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
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);
@@ -133,62 +115,84 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
fetchCandidates();
}, [varietyId, year, isNew, loading]);
const updateEntry = (fieldId: number, key: keyof EntryInput, value: string) => {
setEntries((prev) => ({
useEffect(() => {
if (!varietyId) return;
const variety = getVariety(varietyId);
if (!variety) return;
if (isNew || seedlingBoxesPerTan === '') {
setSeedlingBoxesPerTan(variety.default_seedling_boxes_per_tan);
}
}, [varietyId, crops, isNew]);
useEffect(() => {
const nextCalc: BoxMap = {};
selectedFields.forEach((field) => {
nextCalc[field.id] = calculateDefaultBoxes(field, seedlingBoxesPerTan);
});
setCalcBoxes(nextCalc);
}, [selectedFields, seedlingBoxesPerTan]);
const addField = (field: Field) => {
if (selectedFields.some((selected) => selected.id === field.id)) return;
setSelectedFields((prev) => [...prev, field]);
};
const removeField = (fieldId: number) => {
setSelectedFields((prev) => prev.filter((field) => field.id !== fieldId));
setCalcBoxes((prev) => {
const next = { ...prev };
delete next[fieldId];
return next;
});
setAdjustedBoxes((prev) => {
const next = { ...prev };
delete next[fieldId];
return next;
});
};
const updateBoxCount = (fieldId: number, value: string) => {
setAdjustedBoxes((prev) => ({
...prev,
[fieldId]: {
...(prev[fieldId] ?? initializeEntry(fieldId)),
[key]: value,
},
[fieldId]: value,
}));
};
const applyDefaultsToSelected = () => {
setEntries((prev) => {
const applyColumnDefaults = () => {
setAdjustedBoxes((prev) => {
const next = { ...prev };
selectedFields.forEach((field) => {
next[field.id] = initializeEntry(field.id, defaultSeedGramsPerBox);
const currentValue = prev[field.id] ?? '';
if (applyToEmptyOnly && currentValue !== '') return;
next[field.id] = calcBoxes[field.id] ?? '';
});
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 roundColumn = () => {
setAdjustedBoxes((prev) => {
const next = { ...prev };
selectedFields.forEach((field) => {
const raw = prev[field.id] !== undefined && prev[field.id] !== '' ? prev[field.id] : calcBoxes[field.id];
if (!raw) return;
const value = parseFloat(raw);
if (isNaN(value)) return;
next[field.id] = String(Math.round(value));
});
return next;
});
};
const removeField = (fieldId: number) => {
setSelectedFields((prev) => prev.filter((field) => field.id !== fieldId));
const effectiveBoxes = (fieldId: number) => {
const raw = adjustedBoxes[fieldId] !== undefined && adjustedBoxes[fieldId] !== '' ? adjustedBoxes[fieldId] : calcBoxes[fieldId];
const value = parseFloat(raw ?? '0');
return isNaN(value) ? 0 : value;
};
const fieldRows = useMemo(
() =>
selectedFields.map((field) => ({
field,
entry: entries[field.id] ?? initializeEntry(field.id),
defaultBoxes: calculateDefaultBoxes(field.id),
})),
[selectedFields, entries, varietyId, defaultSeedGramsPerBox, allFields]
);
const calculateBoxes = (_field: Field, entry: EntryInput) => {
const installedBoxes = parseFloat(entry.installed_seedling_boxes || '0');
return installedBoxes;
};
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 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;
@@ -207,17 +211,19 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
return;
}
const entries = selectedFields.map((field) => ({
field_id: field.id,
installed_seedling_boxes: effectiveBoxes(field.id).toFixed(2),
}));
const payload = {
name,
year,
variety: varietyId,
default_seed_grams_per_box: defaultSeedGramsPerBox,
seedling_boxes_per_tan: seedlingBoxesPerTan || '0',
default_seed_grams_per_box: defaultSeedGramsPerBox || '0',
notes,
entries: selectedFields.map((field) => ({
field_id: field.id,
installed_seedling_boxes: entries[field.id]?.installed_seedling_boxes ?? initializeEntry(field.id).installed_seedling_boxes,
seed_grams_per_box: entries[field.id]?.seed_grams_per_box ?? defaultSeedGramsPerBox,
})),
entries,
};
setSaving(true);
@@ -240,6 +246,16 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
(field) => !selectedFields.some((selected) => selected.id === field.id)
);
const fieldRows = useMemo(
() =>
selectedFields.map((field) => ({
field,
defaultBoxes: calcBoxes[field.id] ?? '',
boxCount: adjustedBoxes[field.id] !== undefined && adjustedBoxes[field.id] !== '' ? adjustedBoxes[field.id] : calcBoxes[field.id] ?? '',
})),
[selectedFields, calcBoxes, adjustedBoxes]
);
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
@@ -278,8 +294,8 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
</div>
)}
<div className="mb-4 grid gap-4 rounded-lg bg-white p-4 shadow md:grid-cols-2 xl:grid-cols-4">
<div>
<div className="mb-4 grid gap-4 rounded-lg bg-white p-4 shadow md:grid-cols-2 xl:grid-cols-5">
<div className="xl:col-span-2">
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={name}
@@ -323,55 +339,101 @@ 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)}
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"
className="w-full 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>
</div>
<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">
{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="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">
<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>
)}
<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="rounded-lg bg-white p-4 shadow">
@@ -386,7 +448,7 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
<span>{totalBoxes.toFixed(2)}</span>
</div>
<div className="flex justify-between text-gray-600">
<span></span>
<span></span>
<span>{totalSeedKg.toFixed(3)}kg</span>
</div>
<div className="flex justify-between text-gray-600">
@@ -401,66 +463,38 @@ export default function RiceTransplantEditPage({ planId }: { planId?: number })
</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"></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"></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, defaultBoxes }) => (
<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-2">
<span className="text-xs tabular-nums text-gray-500">{defaultBoxes}</span>
<button
type="button"
onClick={() => updateEntry(field.id, 'installed_seedling_boxes', defaultBoxes)}
className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-100"
>
</button>
</div>
</td>
<td className="px-4 py-3">
<input
value={entry.installed_seedling_boxes}
onChange={(e) => updateEntry(field.id, 'installed_seedling_boxes', 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>
))}
{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>
);
})}
</tbody>
</table>
</div>

View File

@@ -173,7 +173,6 @@ export interface RiceTransplantEntry {
field_name?: string;
field_area_tan?: string;
installed_seedling_boxes: string;
seed_grams_per_box: string;
default_seedling_boxes: string;
planned_boxes: string;
planned_seed_kg: string;
@@ -187,6 +186,7 @@ export interface RiceTransplantPlan {
variety_name: string;
crop_name: string;
default_seed_grams_per_box: string;
seedling_boxes_per_tan: string;
notes: string;
entries: RiceTransplantEntry[];
field_count: number;