Compare commits

..

10 Commits

Author SHA1 Message Date
Akira
e3c21d6e81 ConfirmSpreadingModal の改善点:
groupedEntries(肥料別リスト表示)→ layout(圃場×肥料のマトリクス表)に変更 
施肥計画編集画面と同じ「圃場名 / 面積(反) / 肥料列... / 合計」のテーブル構造に統一 
各セルに計画値ラベル + 実績入力欄を縦並び 
列合計(肥料別)・行合計(圃場別)・総合計を追加 
計画情報サマリーカード(年度・品種・圃場数・肥料数)を追加 
操作ガイド(sky色バナー)を追加 
モーダル幅を max-w-4xl → max-w-[95vw] に拡大(マトリクス表に合わせて) 
ドキュメント更新:

document/13_マスタードキュメント_施肥計画編.md — 在庫引当・散布確定・確定取消 API を追記 
改善案/在庫管理機能実装案.md — 微修正 
2026-03-15 13:48:48 +09:00
Akira
72b4d670fe 完璧に動作しています。
テスト	結果
確定取消 API	 is_confirmed: false, confirmed_at: null
USE トランザクション削除	 current_stock が 27.5→32 に復帰
引当再作成	 reserved_stock = 5.000 に復帰
追加した変更:

stock_service.py:81-93 — unconfirm_spreading(): USE削除→確定フラグリセット→引当再作成
fertilizer/views.py — unconfirm アクション(POST /api/fertilizer/plans/{id}/unconfirm/)
fertilizer/page.tsx — 一覧に「確定取消」ボタン(確定済み計画のみ表示)
FertilizerEditPage.tsx — 編集画面ヘッダーに「確定取消」ボタン + 在庫情報再取得
2026-03-15 13:28:02 +09:00
Akira
42b11a5df8 在庫管理 Phase 1.5(引当・散布確定)の設計を追記し、CODEX指示書を更新
- 在庫管理機能実装案.md: セクション23(引当・散布確定ワークフロー)を追加
- CODEX.md: Phase 1完了を受け、Phase 1.5実装指示に全面書き換え

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:29:05 +09:00
Akira
497bc87c24 在庫管理機能 Phase 1 実装(apps/materials + フロントエンド)
Backend:
- apps/materials 新規作成(Material, FertilizerProfile, PesticideProfile, StockTransaction)
- 資材マスタ CRUD API(/api/materials/materials/)
- 入出庫履歴 API(/api/materials/stock-transactions/)
- 在庫集計 API(/api/materials/stock-summary/)
- 既存 Fertilizer に material OneToOneField 追加(0005マイグレーション、データ移行込み)

Frontend:
- /materials: 在庫一覧画面(タブフィルタ、履歴展開、入出庫モーダル)
- /materials/masters: 資材マスタ管理(肥料/農薬/その他タブ、インライン編集)
- Navbar に「在庫管理」メニュー追加
- Material/StockTransaction/StockSummary 型定義追加

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:42:47 +09:00
Akira
67d4197b7f 在庫管理機能実装案をレビュー反映し、CODEX実装指示書を追加
- StockTransaction から冗長フィールド除外(unit, reference_type/id, created_by, inventory_count)
- フロントエンド画面構成を変更(入出庫登録をモーダル化、マスタ管理をタブ統合)
- レビュー記録セクション22を追加
- CODEX.md: Phase 1 実装指示書を作成(モデル・API・画面の詳細仕様)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:03:09 +09:00
Akira
1b619c44a0 在庫管理機能実装案.md に ## 21. 補足: 外部サブスクLLM活用案 を追記しました。
内容は、恵菜システム自身は LLM API を呼ばず、調査用プロンプトを提供する 調査手順を表示する LLM回答を貼り付けてフォームへ反映する という実装案です。肥料用・農薬用のプロンプト例、画面イメージ、貼り付け結果の取り込み方式、メリットと注意点まで入れています。

今回は文書更新のみで、コード変更やテストはしていません。必要なら次に、この 貼り付け結果をフォームへ反映 するための入力フォーマット仕様や、画面ワイヤー案まで具体化できます。
2026-03-13 18:29:03 +09:00
Akira
f1512febde 在庫管理機能実装案.md に、電気炉さい ミネラルホウ素 リン酸グアノ も追記しました。
追加した内容は、各資材についての袋表示・保証票ベースの情報、実装上の扱い、alias の例です。あわせて最後のまとめも更新して、5件を次の3パターンに整理しました。

現場名と保証票名が違う: 仁井田米有機, 土佐勤農党, 電気炉さい
現場名と表示名がほぼ一致する: リン酸グアノ
公式マスタよりローカル資材管理向き: ミネラルホウ素
今回は文書更新のみで、コード変更やテストはしていません。必要なら次に、この5件を前提にした MaterialAlias の Django モデル案まで具体化します。
2026-03-13 13:50:24 +09:00
Akira
776a269d6d 在庫管理機能実装案.md に ## 18. 補足: 肥料公式データ同期・あいまい検索案 を追記しました。
内容は、普通肥料を主対象に公式データをローカルDB化し、検索時はローカル検索、24時間超過時だけ裏で差分同期する という方針です。FertilizerOfficialMaster のテーブル案、既存 Fertilizer との紐づけ、検索API、同期ジョブ、特殊肥料は手入力併用にする考え方まで入れています。

今回は文書更新のみで、コード変更やテスト実行はしていません。必要なら次は、この18章をもとに models.py レベルの実装草案まで起こせます。
2026-03-13 13:26:18 +09:00
Akira
1425094107 在庫管理機能実装案.md に、## 17. 補足: 農薬公式データ同期・あいまい検索案 を追記しました。
今回追加したのは、ローカルDBで即検索しつつ、24時間以上経過時だけ裏で差分同期する 方式の具体化です。PesticideOfficialMaster と OfficialDataSyncStatus のテーブル案、検索API、同期ジョブ、差分更新ルール、フロントの再読込挙動、失敗時フォールバックまで入れてあります。

文書更新のみで、コード変更やテスト実行はしていません。必要なら次に、この章をそのまま実装に落として、Django モデル案と API 仕様書を作れます。
2026-03-13 13:22:05 +09:00
Akira
f74dc4c4b7 在庫管理機能実装案 2026-03-13 13:13:40 +09:00
39 changed files with 188806 additions and 39 deletions

View File

@@ -56,7 +56,15 @@
"Bash(BASE=\"http://localhost/api/w/admins\")",
"Bash(__NEW_LINE_ac825b6748572380__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\")",
"Bash(__NEW_LINE_becbcae8f0f5a9e3__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\" -o /tmp/vars.json)",
"Bash(git add:*)"
"Bash(git add:*)",
"Bash(xargs cat:*)",
"Bash(xargs grep:*)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_dd08c8854e486d12__ echo \"=== Fertilization Plans \\(check is_confirmed/confirmed_at\\) ===\" curl -s http://localhost:8000/api/fertilizer/plans/?year=2026 -H \"Authorization: Bearer $TOKEN\")",
"Bash(python -m json.tool)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_0b7fd53b80bd968a__ echo \"=== Stock summary \\(should show reserved\\) ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
"Read(//c/Users/akira/Develop/keinasystem_t02/**)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_74a785697e4cd919__ echo \"=== After confirm: stock summary ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
"Bash(git diff:*)"
],
"additionalDirectories": [
"C:\\Users\\akira\\AppData\\Local\\Temp"

2
.gitignore vendored
View File

@@ -13,3 +13,5 @@ out/
db.sqlite3
postgres_data/
nul
*.tsbuildinfo

830
CODEX.md Normal file
View File

@@ -0,0 +1,830 @@
# CODEX 実装指示書: 施肥計画連携・引当機能Phase 1.5
> 作成日: 2026-03-14
> 対象: `keinasystem_t02`
> 設計案: `改善案/在庫管理機能実装案.md`セクション23が対象
> 前提: Phase 1セクション1〜16は実装済み。`apps/materials` が稼働中。
---
## 0. 実装の前提と絶対ルール
### 現在のプロジェクト構造Phase 1 実装済み)
```
keinasystem_t02/
├── backend/
│ ├── keinasystem/
│ │ ├── settings.py # apps.materials 登録済み
│ │ └── urls.py # /api/materials/ 登録済み
│ └── apps/
│ ├── fields/ # 圃場管理Field モデル)
│ ├── plans/ # 作付け計画Crop, Variety モデル)
│ ├── fertilizer/ # 施肥計画Fertilizer, FertilizationPlan, FertilizationEntry 等)
│ │ └── models.py # Fertilizer.material = OneToOneField(Material) 追加済み
│ └── materials/ # 在庫管理Material, FertilizerProfile, PesticideProfile, StockTransaction
│ └── models.py # Phase 1 で作成済み
└── frontend/
└── src/
├── types/index.ts # Material, StockTransaction, StockSummary 定義済み
├── lib/api.ts # axios インスタンス(変更不要)
├── components/
│ └── Navbar.tsx # 在庫管理メニュー追加済み
└── app/
├── fertilizer/ # 施肥計画(既存)← 今回変更対象
│ ├── page.tsx
│ ├── [id]/edit/page.tsx
│ └── _components/FertilizerEditPage.tsx
└── materials/ # 在庫管理Phase 1 で作成済み)← 今回変更対象
├── page.tsx
└── _components/StockOverview.tsx
```
### 技術スタック
- Backend: Django 5.2 + Django REST Framework + PostgreSQL 16
- Frontend: Next.js 14 (App Router) + TypeScript strict + Tailwind CSS
- 認証: SimpleJWTヘッダー `Authorization: Bearer <token>`
- Docker: `docker compose exec backend python manage.py ...`
### 絶対ルール
1. **既存の施肥計画 CRUD作成・編集・削除・PDFを壊さない**
2. **`FertilizationEntry → Fertilizer` の FK は変更しない**
3. **`Fertilizer` モデルは改名・削除しない**
4. **フロントエンドでは `alert()` / `confirm()` を使わない**(インラインバナーで表示)
5. **TypeScript strict mode に従う**
6. **Next.js 14 では `params` は通常のオブジェクト**`use(params)` は使わない)
7. **マイグレーションは段階的に。1つのマイグレーションで複数の大きな変更をしない**
---
## 1. 実装スコープPhase 1.5
### やること
1. `StockTransaction``reserve` タイプ追加
2. `StockTransaction``fertilization_plan` FK 追加(マイグレーション)
3. `FertilizationPlan``is_confirmed` / `confirmed_at` 追加(マイグレーション)
4. 在庫集計 API に `reserved_stock` / `available_stock` 追加
5. 施肥計画の保存時に引当reserveを自動作成
6. 施肥計画の削除時に引当を自動解除
7. 散布確定 API`confirm_spreading`
8. 肥料在庫一覧 API施肥計画画面用
9. フロントエンド: 在庫一覧に引当表示追加
10. フロントエンド: 施肥計画編集に在庫参照追加
11. フロントエンド: 散布確定画面
12. フロントエンド: 施肥計画一覧に確定状態表示追加
### やらないこと
- 公式データ同期FAMIC、農水省
- 別名辞書MaterialAlias
- LLM 調査支援
- 農薬散布計画の在庫連携
---
## 2. バックエンド: モデル変更
### 2.1 StockTransaction の変更 (`backend/apps/materials/models.py`)
**現在のコード**(変更が必要な箇所のみ抜粋):
```python
class StockTransaction(models.Model):
class TransactionType(models.TextChoices):
PURCHASE = 'purchase', '入庫'
USE = 'use', '使用'
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
DISCARD = 'discard', '廃棄'
INCREASE_TYPES = {
TransactionType.PURCHASE,
TransactionType.ADJUSTMENT_PLUS,
}
DECREASE_TYPES = {
TransactionType.USE,
TransactionType.ADJUSTMENT_MINUS,
TransactionType.DISCARD,
}
```
**変更後**:
```python
class StockTransaction(models.Model):
class TransactionType(models.TextChoices):
PURCHASE = 'purchase', '入庫'
USE = 'use', '使用'
RESERVE = 'reserve', '引当' # ← 追加
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
DISCARD = 'discard', '廃棄'
INCREASE_TYPES = {
TransactionType.PURCHASE,
TransactionType.ADJUSTMENT_PLUS,
}
DECREASE_TYPES = {
TransactionType.USE,
TransactionType.RESERVE, # ← 追加
TransactionType.ADJUSTMENT_MINUS,
TransactionType.DISCARD,
}
```
**フィールド追加**(既存フィールドの後に追加):
```python
fertilization_plan = models.ForeignKey(
'fertilizer.FertilizationPlan',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='stock_reservations',
verbose_name='施肥計画',
)
```
### 2.2 FertilizationPlan の変更 (`backend/apps/fertilizer/models.py`)
**フィールド追加**(既存フィールドの後に追加):
```python
is_confirmed = models.BooleanField(
default=False, verbose_name='散布確定済み'
)
confirmed_at = models.DateTimeField(
null=True, blank=True, verbose_name='散布確定日時'
)
```
### 2.3 マイグレーション
#### マイグレーション1: `backend/apps/materials/migrations/0002_stocktransaction_fertilization_plan.py`
```python
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('materials', '0001_initial'),
('fertilizer', '0005_fertilizer_material'),
]
operations = [
migrations.AddField(
model_name='stocktransaction',
name='fertilization_plan',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='stock_reservations',
to='fertilizer.fertilizationplan',
verbose_name='施肥計画',
),
),
]
```
注意: `TransactionType` の choices 変更はマイグレーション不要Django は choices をDBレベルで強制しないため
#### マイグレーション2: `backend/apps/fertilizer/migrations/0006_fertilizationplan_confirmation.py`
```python
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0005_fertilizer_material'),
]
operations = [
migrations.AddField(
model_name='fertilizationplan',
name='is_confirmed',
field=models.BooleanField(default=False, verbose_name='散布確定済み'),
),
migrations.AddField(
model_name='fertilizationplan',
name='confirmed_at',
field=models.DateTimeField(
blank=True, null=True, verbose_name='散布確定日時'
),
),
]
```
---
## 3. バックエンド: 引当ロジック
### 3.1 引当の作成・解除ヘルパー関数
`backend/apps/materials/stock_service.py` を新規作成:
```python
from django.db import transaction
from .models import StockTransaction
@transaction.atomic
def create_reserves_for_plan(plan):
"""施肥計画の全エントリについて引当トランザクションを作成する。
既存の引当は全削除してから再作成する(差分更新ではなく全置換)。
"""
# 既存の引当を全削除
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type='reserve',
).delete()
# plan が確定済みなら引当を作らないuse が既にある)
if plan.is_confirmed:
return
for entry in plan.entries.select_related('fertilizer__material'):
material = getattr(entry.fertilizer, 'material', None)
if material is None:
# Fertilizer.material が未連携の場合はスキップ
continue
StockTransaction.objects.create(
material=material,
transaction_type='reserve',
quantity=entry.bags,
occurred_on=plan.updated_at.date() if plan.updated_at else plan.created_at.date(),
note=f'施肥計画「{plan.name}」からの引当',
fertilization_plan=plan,
)
@transaction.atomic
def delete_reserves_for_plan(plan):
"""施肥計画に紐づく全引当トランザクションを削除する。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type='reserve',
).delete()
@transaction.atomic
def confirm_spreading(plan, actual_entries):
"""散布確定: 引当を削除し、実績数量で use トランザクションを作成する。
actual_entries: list of dict
[{"field_id": int, "fertilizer_id": int, "actual_bags": Decimal}, ...]
actual_bags=0 の行は引当解除のみuse を作成しない)
"""
from apps.fertilizer.models import Fertilizer
from django.utils import timezone
# 既存の引当を全削除
delete_reserves_for_plan(plan)
# 実績 > 0 の行について use トランザクションを作成
today = timezone.now().date()
for entry_data in actual_entries:
actual_bags = entry_data['actual_bags']
if actual_bags <= 0:
continue
try:
fertilizer = Fertilizer.objects.select_related('material').get(
id=entry_data['fertilizer_id']
)
except Fertilizer.DoesNotExist:
continue
material = getattr(fertilizer, 'material', None)
if material is None:
continue
StockTransaction.objects.create(
material=material,
transaction_type='use',
quantity=actual_bags,
occurred_on=today,
note=f'施肥計画「{plan.name}」散布確定',
fertilization_plan=plan,
)
# 計画を確定済みに更新
plan.is_confirmed = True
plan.confirmed_at = timezone.now()
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
```
### 3.2 施肥計画 ViewSet の変更 (`backend/apps/fertilizer/views.py`)
既存の `FertilizationPlanViewSet` に以下の変更を加える。
#### 保存時の引当自動作成
`perform_create``perform_update` をオーバーライドして、保存後に引当を作成する:
```python
from apps.materials.stock_service import (
create_reserves_for_plan,
delete_reserves_for_plan,
confirm_spreading as confirm_spreading_service,
)
class FertilizationPlanViewSet(viewsets.ModelViewSet):
# ... 既存コード ...
def perform_create(self, serializer):
instance = serializer.save()
create_reserves_for_plan(instance)
def perform_update(self, serializer):
instance = serializer.save()
create_reserves_for_plan(instance)
def perform_destroy(self, instance):
delete_reserves_for_plan(instance)
instance.delete()
```
#### 散布確定アクション
```python
from rest_framework.decorators import action
from decimal import Decimal
class FertilizationPlanViewSet(viewsets.ModelViewSet):
# ... 既存コード ...
@action(detail=True, methods=['post'], url_path='confirm_spreading')
def confirm_spreading(self, request, pk=None):
plan = self.get_object()
if plan.is_confirmed:
return Response(
{'detail': 'この計画は既に散布確定済みです。'},
status=status.HTTP_400_BAD_REQUEST,
)
entries_data = request.data.get('entries', [])
if not entries_data:
return Response(
{'detail': '実績データが空です。'},
status=status.HTTP_400_BAD_REQUEST,
)
actual_entries = []
for entry in entries_data:
actual_entries.append({
'field_id': entry['field_id'],
'fertilizer_id': entry['fertilizer_id'],
'actual_bags': Decimal(str(entry.get('actual_bags', 0))),
})
confirm_spreading_service(plan, actual_entries)
serializer = self.get_serializer(plan)
return Response(serializer.data)
```
### 3.3 施肥計画 Serializer の変更 (`backend/apps/fertilizer/serializers.py`)
`FertilizationPlanSerializer`(読み取り用)に `is_confirmed` / `confirmed_at` を追加:
```python
class FertilizationPlanSerializer(serializers.ModelSerializer):
# ... 既存フィールド ...
is_confirmed = serializers.BooleanField(read_only=True)
confirmed_at = serializers.DateTimeField(read_only=True)
class Meta:
model = FertilizationPlan
fields = [
# ... 既存フィールド ...,
'is_confirmed', 'confirmed_at',
]
```
---
## 4. バックエンド: 在庫集計 API の変更
### 4.1 StockSummarySerializer の変更 (`backend/apps/materials/serializers.py`)
```python
class StockSummarySerializer(serializers.Serializer):
material_id = serializers.IntegerField()
name = serializers.CharField()
material_type = serializers.CharField()
material_type_display = serializers.CharField()
maker = serializers.CharField()
stock_unit = serializers.CharField()
stock_unit_display = serializers.CharField()
is_active = serializers.BooleanField()
current_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
reserved_stock = serializers.DecimalField(max_digits=10, decimal_places=3) # ← 追加
available_stock = serializers.DecimalField(max_digits=10, decimal_places=3) # ← 追加
last_transaction_date = serializers.DateField(allow_null=True)
```
### 4.2 StockSummaryView の変更 (`backend/apps/materials/views.py`)
在庫集計のループ内で `reserved_stock``available_stock` を計算する:
```python
for material in queryset:
transactions = list(material.stock_transactions.all())
increase = sum(
txn.quantity for txn in transactions
if txn.transaction_type in StockTransaction.INCREASE_TYPES
)
decrease = sum(
txn.quantity for txn in transactions
if txn.transaction_type in StockTransaction.DECREASE_TYPES
)
reserved = sum(
txn.quantity for txn in transactions
if txn.transaction_type == 'reserve'
)
last_date = max((txn.occurred_on for txn in transactions), default=None)
current = increase - decrease # 引当込みの在庫(引当分は既に引かれている)
results.append({
'material_id': material.id,
'name': material.name,
'material_type': material.material_type,
'material_type_display': material.get_material_type_display(),
'maker': material.maker,
'stock_unit': material.stock_unit,
'stock_unit_display': material.get_stock_unit_display(),
'is_active': material.is_active,
'current_stock': current + reserved, # 引当を戻した「物理的な在庫」
'reserved_stock': reserved, # 引当中の数量
'available_stock': current, # 利用可能在庫(引当済み分を除く)
'last_transaction_date': last_date,
})
```
**在庫計算の定義**:
- `current_stock`: 物理的に倉庫にある数量(入庫 - 使用 - 廃棄 ± 調整)
- `reserved_stock`: そのうち施肥計画で引き当てられている数量
- `available_stock`: 新しい計画に使える数量(= current_stock - reserved_stock
### 4.3 肥料在庫 API施肥計画画面用
`backend/apps/materials/views.py` に追加:
```python
class FertilizerStockView(generics.ListAPIView):
"""施肥計画画面用: 肥料の在庫情報を返す"""
permission_classes = [IsAuthenticated]
serializer_class = StockSummarySerializer
def get_queryset(self):
return None
def list(self, request, *args, **kwargs):
queryset = Material.objects.filter(
material_type='fertilizer',
is_active=True,
).prefetch_related('stock_transactions')
results = []
for material in queryset:
transactions = list(material.stock_transactions.all())
increase = sum(
txn.quantity for txn in transactions
if txn.transaction_type in StockTransaction.INCREASE_TYPES
)
decrease = sum(
txn.quantity for txn in transactions
if txn.transaction_type in StockTransaction.DECREASE_TYPES
)
reserved = sum(
txn.quantity for txn in transactions
if txn.transaction_type == 'reserve'
)
current = increase - decrease
results.append({
'material_id': material.id,
'name': material.name,
'material_type': material.material_type,
'material_type_display': material.get_material_type_display(),
'maker': material.maker,
'stock_unit': material.stock_unit,
'stock_unit_display': material.get_stock_unit_display(),
'is_active': material.is_active,
'current_stock': current + reserved,
'reserved_stock': reserved,
'available_stock': current,
'last_transaction_date': max(
(t.occurred_on for t in transactions), default=None
),
})
serializer = StockSummarySerializer(results, many=True)
return Response(serializer.data)
```
`backend/apps/materials/urls.py` に追加:
```python
urlpatterns = [
path('', include(router.urls)),
path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'),
path('fertilizer-stock/', views.FertilizerStockView.as_view(), name='fertilizer-stock'), # ← 追加
]
```
---
## 5. フロントエンド: 型定義の変更
### 5.1 StockTransaction 型に `reserve` 追加 (`frontend/src/types/index.ts`)
**変更前**:
```typescript
transaction_type: 'purchase' | 'use' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
```
**変更後**:
```typescript
transaction_type: 'purchase' | 'use' | 'reserve' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
```
### 5.2 StockSummary 型に引当フィールド追加
**変更前**:
```typescript
export interface StockSummary {
material_id: number;
name: string;
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
material_type_display: string;
maker: string;
stock_unit: string;
stock_unit_display: string;
is_active: boolean;
current_stock: string;
last_transaction_date: string | null;
}
```
**変更後**:
```typescript
export interface StockSummary {
material_id: number;
name: string;
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
material_type_display: string;
maker: string;
stock_unit: string;
stock_unit_display: string;
is_active: boolean;
current_stock: string;
reserved_stock: string; // ← 追加
available_stock: string; // ← 追加
last_transaction_date: string | null;
}
```
### 5.3 FertilizationPlan 型に確定フィールド追加
既存の `FertilizationPlan` インターフェースに追加:
```typescript
export interface FertilizationPlan {
// ... 既存フィールド ...
is_confirmed: boolean; // ← 追加
confirmed_at: string | null; // ← 追加
}
```
---
## 6. フロントエンド: 画面変更
### 6.1 在庫一覧の引当表示 (`frontend/src/app/materials/_components/StockOverview.tsx`)
現在庫の表示を変更:
**変更前**:
```
現在庫: 18
```
**変更後**:
```
在庫 18袋引当 12袋/ 利用可能 6袋
```
引当が0の場合は引当表示を省略する。
### 6.2 施肥計画編集画面の在庫参照 (`frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx`)
施肥計画の編集画面(マトリクス表)で、肥料列ヘッダーに在庫情報を表示する。
**追加表示**(肥料名の下に小さく):
```
仁井田米有機
在庫 18袋 / 計画計 24袋
```
計画合計が在庫を超える場合は赤文字で「不足 6袋」を表示する。
**データ取得**: ページ読み込み時に `GET /api/materials/fertilizer-stock/` を呼び、
`Fertilizer.material` の OneToOne 経由で material_id と紐づける。
紐づけロジック:
1. `GET /api/fertilizer/fertilizers/` で肥料一覧を取得(既存)
2. `GET /api/materials/materials/?material_type=fertilizer` で Material 一覧を取得
3. `Fertilizer.name``Material.name` を突き合わせる(同名で作成されているため一致する)
または、Fertilizer の serializer に `material_id` を追加して直接紐づける(推奨)。
**Fertilizer serializer への追加**`backend/apps/fertilizer/serializers.py`:
```python
class FertilizerSerializer(serializers.ModelSerializer):
material_id = serializers.IntegerField(source='material.id', read_only=True, default=None)
class Meta:
model = Fertilizer
fields = [
# ... 既存フィールド ...,
'material_id',
]
```
### 6.3 施肥計画一覧の確定状態表示 (`frontend/src/app/fertilizer/page.tsx`)
各計画行に確定状態を表示:
- 未確定: 通常表示 + 「散布確定」ボタン
- 確定済み: 背景色変更(例: 薄い青)+ 「確定済み ✓」バッジ + 確定日時
### 6.4 散布確定画面
**実装方法**: モーダルまたは専用ページ。施肥計画一覧の「散布確定」ボタンから起動。
**画面構成**:
```
┌─ 散布確定: 「計画名」──────────────────────────────┐
│ │
│ 肥料: 仁井田米有機 │
│ ┌─────────────┬──────┬──────────┐ │
│ │ 圃場 │ 計画 │ 実績 │ │
│ ├─────────────┼──────┼──────────┤ │
│ │ 上の田 │ 3袋 │ [ 3 ] │ │
│ │ 下の田 │ 4袋 │ [ 3.5 ] │ │
│ │ 山の畑 │ 2袋 │ [ 0 ] │ │
│ └─────────────┴──────┴──────────┘ │
│ │
│ 肥料: 土佐勤農党 │
│ ┌─────────────┬──────┬──────────┐ │
│ │ 圃場 │ 計画 │ 実績 │ │
│ ├─────────────┼──────┼──────────┤ │
│ │ ... │ ... │ [ ... ] │ │
│ └─────────────┴──────┴──────────┘ │
│ │
│ [キャンセル] [一括確定] │
└─────────────────────────────────────────────────────┘
```
**動作**:
1. 施肥計画のエントリを肥料ごとにグループ化して表示
2. 「実績」列は計画値がプリセットされた数値入力欄
3. 修正が必要な行だけ数値を変更する
4. 実績を0にした行は「未散布」として引当解除される
5. 「一括確定」で `POST /api/fertilizer/plans/{id}/confirm_spreading/` を呼ぶ
**API リクエスト**:
```json
{
"entries": [
{"field_id": 1, "fertilizer_id": 3, "actual_bags": 3.0},
{"field_id": 2, "fertilizer_id": 3, "actual_bags": 3.5},
{"field_id": 3, "fertilizer_id": 3, "actual_bags": 0}
]
}
```
---
## 7. API エンドポイント一覧Phase 1.5 で追加・変更)
### 新規
| メソッド | パス | 認証 | 説明 |
|----------|------|------|------|
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | JWT | 散布確定reserve→use変換 |
| GET | `/api/materials/fertilizer-stock/` | JWT | 肥料在庫一覧(施肥計画画面用) |
### 変更
| メソッド | パス | 変更内容 |
|----------|------|----------|
| POST/PUT | `/api/fertilizer/plans/` | 保存後に reserve 自動作成 |
| DELETE | `/api/fertilizer/plans/{id}/` | 削除前に reserve 自動削除 |
| GET | `/api/fertilizer/plans/` | レスポンスに `is_confirmed`, `confirmed_at` 追加 |
| GET | `/api/fertilizer/fertilizers/` | レスポンスに `material_id` 追加 |
| GET | `/api/materials/stock-summary/` | レスポンスに `reserved_stock`, `available_stock` 追加 |
---
## 8. 実装順序(厳守)
### Step 1: バックエンド — モデル・マイグレーション
1. `apps/materials/models.py``reserve` タイプ追加、`DECREASE_TYPES` 更新、`fertilization_plan` FK 追加
2. `apps/fertilizer/models.py``is_confirmed`, `confirmed_at` 追加
3. `apps/materials/migrations/0002_stocktransaction_fertilization_plan.py` 作成
4. `apps/fertilizer/migrations/0006_fertilizationplan_confirmation.py` 作成
### Step 2: バックエンド — ロジック・API
5. `apps/materials/stock_service.py` 作成(引当作成・解除・散布確定ヘルパー)
6. `apps/fertilizer/views.py``FertilizationPlanViewSet``perform_create`, `perform_update`, `perform_destroy` オーバーライド追加
7. `apps/fertilizer/views.py``confirm_spreading` アクション追加
8. `apps/fertilizer/serializers.py``is_confirmed`, `confirmed_at` 追加
9. `apps/fertilizer/serializers.py``FertilizerSerializer``material_id` 追加
10. `apps/materials/serializers.py``StockSummarySerializer``reserved_stock`, `available_stock` 追加
11. `apps/materials/views.py``StockSummaryView` で引当集計を追加
12. `apps/materials/views.py``FertilizerStockView` 追加
13. `apps/materials/urls.py``fertilizer-stock/` パス追加
### Step 3: フロントエンド
14. `types/index.ts``reserve` タイプ追加、`StockSummary` に引当フィールド追加、`FertilizationPlan` に確定フィールド追加
15. `app/materials/_components/StockOverview.tsx` に引当表示追加
16. `app/materials/page.tsx``StockTransactionForm``reserve` オプション追加(手動引当は不要なら省略可)
17. `app/fertilizer/_components/FertilizerEditPage.tsx` に在庫参照表示追加
18. `app/fertilizer/page.tsx` に確定状態表示・散布確定ボタン追加
19. `app/fertilizer/_components/ConfirmSpreadingModal.tsx` 新規作成(散布確定モーダル)
---
## 9. テスト確認項目
### バックエンド
- [ ] マイグレーション適用成功materials 0002, fertilizer 0006
- [ ] 施肥計画を保存すると、各エントリに対応する reserve トランザクションが作成される
- [ ] 施肥計画を更新すると、古い reserve が削除され新しい reserve が作成される
- [ ] 施肥計画を削除すると、reserve が全て削除される
- [ ] `GET /api/materials/stock-summary/``reserved_stock``available_stock` が返る
- [ ] 入庫10 → 引当3 → `current_stock=10`, `reserved_stock=3`, `available_stock=7`
- [ ] `POST /api/fertilizer/plans/{id}/confirm_spreading/` で reserve が use に変換される
- [ ] 確定済み計画に再度 confirm_spreading すると 400 エラー
- [ ] actual_bags=0 の行は reserve 削除のみuse は作成しない)
- [ ] `Fertilizer.material` が null の Fertilizer は引当をスキップする
- [ ] 既存の施肥計画 CRUD作成・編集・削除・PDFが壊れていない
### フロントエンド
- [ ] 在庫一覧に引当数量と利用可能在庫が表示される
- [ ] 施肥計画編集画面に肥料ごとの在庫情報が表示される
- [ ] 施肥計画一覧に確定状態(未確定/確定済み)が表示される
- [ ] 散布確定モーダルが開き、計画値がプリセットされる
- [ ] 実績を修正して一括確定できる
- [ ] 確定後、計画が「確定済み」表示に変わる
- [ ] 確定済みの計画には「散布確定」ボタンが表示されない
---
## 10. 既存コードへの変更一覧(影響範囲)
| ファイル | 変更内容 |
|----------|----------|
| `backend/apps/materials/models.py` | `StockTransaction``reserve` タイプ・`fertilization_plan` FK 追加 |
| `backend/apps/materials/serializers.py` | `StockSummarySerializer``reserved_stock``available_stock` 追加 |
| `backend/apps/materials/views.py` | `StockSummaryView` 集計変更、`FertilizerStockView` 追加 |
| `backend/apps/materials/urls.py` | `fertilizer-stock/` パス追加 |
| `backend/apps/materials/stock_service.py` | **新規作成** — 引当ロジック |
| `backend/apps/materials/migrations/0002_...py` | **新規作成** — fertilization_plan FK |
| `backend/apps/fertilizer/models.py` | `FertilizationPlan``is_confirmed``confirmed_at` 追加 |
| `backend/apps/fertilizer/views.py` | `perform_create/update/destroy` オーバーライド、`confirm_spreading` アクション追加 |
| `backend/apps/fertilizer/serializers.py` | `is_confirmed``confirmed_at``material_id` 追加 |
| `backend/apps/fertilizer/migrations/0006_...py` | **新規作成** — is_confirmed, confirmed_at |
| `frontend/src/types/index.ts` | `reserve` タイプ追加、引当フィールド追加、確定フィールド追加 |
| `frontend/src/app/materials/_components/StockOverview.tsx` | 引当表示追加 |
| `frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx` | 在庫参照表示追加 |
| `frontend/src/app/fertilizer/page.tsx` | 確定状態表示・散布確定ボタン追加 |
| `frontend/src/app/fertilizer/_components/ConfirmSpreadingModal.tsx` | **新規作成** — 散布確定モーダル |
---
## 11. 参照すべき既存コード(実装パターンの手本)
| 目的 | 参照先 |
|------|--------|
| 施肥計画 ViewSetperform_create の追加先) | `backend/apps/fertilizer/views.py` |
| 施肥計画 Serializerフィールド追加先 | `backend/apps/fertilizer/serializers.py` |
| 施肥計画の @action パターンPDF アクション) | `backend/apps/fertilizer/views.py``pdf` アクション |
| 在庫集計ロジック | `backend/apps/materials/views.py``StockSummaryView` |
| 施肥計画編集画面(マトリクス表) | `frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx` |
| 施肥計画一覧画面 | `frontend/src/app/fertilizer/page.tsx` |
| モーダルパターン | `frontend/src/app/materials/_components/StockTransactionForm.tsx` |
| 在庫一覧コンポーネント | `frontend/src/app/materials/_components/StockOverview.tsx` |

BIN
all_fertilizer.zip Normal file

Binary file not shown.

52135
all_fertilizer/1.全件.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
import django.db.models.deletion
from django.db import migrations, models
def create_materials_for_existing_fertilizers(apps, schema_editor):
Fertilizer = apps.get_model('fertilizer', 'Fertilizer')
Material = apps.get_model('materials', 'Material')
FertilizerProfile = apps.get_model('materials', 'FertilizerProfile')
for fertilizer in Fertilizer.objects.all():
material = Material.objects.create(
name=fertilizer.name,
material_type='fertilizer',
maker=fertilizer.maker or '',
stock_unit='bag',
is_active=True,
notes=fertilizer.notes or '',
)
FertilizerProfile.objects.create(
material=material,
capacity_kg=fertilizer.capacity_kg,
nitrogen_pct=fertilizer.nitrogen_pct,
phosphorus_pct=fertilizer.phosphorus_pct,
potassium_pct=fertilizer.potassium_pct,
)
fertilizer.material = material
fertilizer.save(update_fields=['material'])
def reverse_migration(apps, schema_editor):
Fertilizer = apps.get_model('fertilizer', 'Fertilizer')
Fertilizer.objects.all().update(material=None)
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0004_fertilizationplan_calc_settings'),
('materials', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='fertilizer',
name='material',
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='legacy_fertilizer',
to='materials.material',
verbose_name='資材マスタ',
),
),
migrations.RunPython(create_materials_for_existing_fertilizers, reverse_migration),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0005_fertilizer_material'),
]
operations = [
migrations.AddField(
model_name='fertilizationplan',
name='is_confirmed',
field=models.BooleanField(default=False, verbose_name='散布確定済み'),
),
migrations.AddField(
model_name='fertilizationplan',
name='confirmed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='散布確定日時'),
),
]

View File

@@ -17,6 +17,14 @@ class Fertilizer(models.Model):
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)'
)
notes = models.TextField(blank=True, null=True, verbose_name='備考')
material = models.OneToOneField(
'materials.Material',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='legacy_fertilizer',
verbose_name='資材マスタ',
)
class Meta:
verbose_name = '肥料マスタ'
@@ -35,6 +43,8 @@ class FertilizationPlan(models.Model):
related_name='fertilization_plans', verbose_name='品種'
)
calc_settings = models.JSONField(default=list, blank=True, verbose_name='計算設定')
is_confirmed = models.BooleanField(default=False, verbose_name='散布確定済み')
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='散布確定日時')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

View File

@@ -3,9 +3,25 @@ from .models import Fertilizer, FertilizationPlan, FertilizationEntry, Distribut
class FertilizerSerializer(serializers.ModelSerializer):
material_id = serializers.SerializerMethodField()
class Meta:
model = Fertilizer
fields = '__all__'
fields = [
'id',
'name',
'maker',
'capacity_kg',
'nitrogen_pct',
'phosphorus_pct',
'potassium_pct',
'notes',
'material',
'material_id',
]
def get_material_id(self, obj):
return obj.material_id
class FertilizationEntrySerializer(serializers.ModelSerializer):
@@ -26,12 +42,15 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
entries = FertilizationEntrySerializer(many=True, read_only=True)
field_count = serializers.SerializerMethodField()
fertilizer_count = serializers.SerializerMethodField()
is_confirmed = serializers.BooleanField(read_only=True)
confirmed_at = serializers.DateTimeField(read_only=True)
class Meta:
model = FertilizationPlan
fields = [
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
'calc_settings', 'entries', 'field_count', 'fertilizer_count', 'created_at', 'updated_at'
'calc_settings', 'entries', 'field_count', 'fertilizer_count',
'is_confirmed', 'confirmed_at', 'created_at', 'updated_at'
]
def get_variety_name(self, obj):

View File

@@ -4,12 +4,19 @@ from django.http import HttpResponse
from django.template.loader import render_to_string
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from weasyprint import HTML
from apps.fields.models import Field
from apps.materials.stock_service import (
confirm_spreading as confirm_spreading_service,
create_reserves_for_plan,
delete_reserves_for_plan,
unconfirm_spreading,
)
from apps.plans.models import Plan, Variety
from .models import Fertilizer, FertilizationPlan, DistributionPlan
from .serializers import (
@@ -33,7 +40,7 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
def get_queryset(self):
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
'entries', 'entries__field', 'entries__fertilizer'
'entries', 'entries__field', 'entries__fertilizer', 'entries__fertilizer__material'
)
year = self.request.query_params.get('year')
if year:
@@ -45,6 +52,20 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
return FertilizationPlanWriteSerializer
return FertilizationPlanSerializer
def perform_create(self, serializer):
instance = serializer.save()
create_reserves_for_plan(instance)
def perform_update(self, serializer):
if serializer.instance.is_confirmed:
raise ValidationError({'detail': '確定済みの施肥計画は編集できません。'})
instance = serializer.save()
create_reserves_for_plan(instance)
def perform_destroy(self, instance):
delete_reserves_for_plan(instance)
instance.delete()
@action(detail=True, methods=['get'])
def pdf(self, request, pk=None):
plan = self.get_object()
@@ -99,6 +120,67 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
return response
@action(detail=True, methods=['post'], url_path='confirm_spreading')
def confirm_spreading(self, request, pk=None):
plan = self.get_object()
if plan.is_confirmed:
return Response(
{'detail': 'この計画は既に散布確定済みです。'},
status=status.HTTP_400_BAD_REQUEST,
)
entries_data = request.data.get('entries', [])
if not entries_data:
return Response(
{'detail': '実績データが空です。'},
status=status.HTTP_400_BAD_REQUEST,
)
actual_entries = []
for entry in entries_data:
field_id = entry.get('field_id')
fertilizer_id = entry.get('fertilizer_id')
if not field_id or not fertilizer_id:
return Response(
{'detail': 'field_id と fertilizer_id が必要です。'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
actual_bags = Decimal(str(entry.get('actual_bags', 0)))
except InvalidOperation:
return Response(
{'detail': 'actual_bags は数値で指定してください。'},
status=status.HTTP_400_BAD_REQUEST,
)
actual_entries.append(
{
'field_id': field_id,
'fertilizer_id': fertilizer_id,
'actual_bags': actual_bags,
}
)
confirm_spreading_service(plan, actual_entries)
plan.refresh_from_db()
serializer = self.get_serializer(plan)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='unconfirm')
def unconfirm(self, request, pk=None):
plan = self.get_object()
if not plan.is_confirmed:
return Response(
{'detail': 'この計画はまだ確定されていません。'},
status=status.HTTP_400_BAD_REQUEST,
)
unconfirm_spreading(plan)
plan.refresh_from_db()
serializer = self.get_serializer(plan)
return Response(serializer.data)
class CandidateFieldsView(APIView):
"""作付け計画から圃場候補を返す"""

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,28 @@
from django.contrib import admin
from .models import FertilizerProfile, Material, PesticideProfile, StockTransaction
class FertilizerProfileInline(admin.StackedInline):
model = FertilizerProfile
extra = 0
class PesticideProfileInline(admin.StackedInline):
model = PesticideProfile
extra = 0
@admin.register(Material)
class MaterialAdmin(admin.ModelAdmin):
list_display = ['name', 'material_type', 'maker', 'stock_unit', 'is_active']
list_filter = ['material_type', 'is_active']
search_fields = ['name', 'maker']
inlines = [FertilizerProfileInline, PesticideProfileInline]
@admin.register(StockTransaction)
class StockTransactionAdmin(admin.ModelAdmin):
list_display = ['material', 'transaction_type', 'quantity', 'occurred_on']
list_filter = ['transaction_type', 'occurred_on']
search_fields = ['material__name']

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class MaterialsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.materials'
verbose_name = '資材管理'

View File

@@ -0,0 +1,87 @@
import decimal
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='Material',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='資材名')),
('material_type', models.CharField(choices=[('fertilizer', '肥料'), ('pesticide', '農薬'), ('seedling', '種苗'), ('other', 'その他')], max_length=20, verbose_name='資材種別')),
('maker', models.CharField(blank=True, default='', max_length=100, verbose_name='メーカー')),
('stock_unit', models.CharField(choices=[('bag', ''), ('bottle', ''), ('kg', 'kg'), ('liter', 'L'), ('piece', '')], default='bag', max_length=20, verbose_name='在庫単位')),
('is_active', models.BooleanField(default=True, 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)),
],
options={
'verbose_name': '資材',
'verbose_name_plural': '資材',
'ordering': ['material_type', 'name'],
},
),
migrations.CreateModel(
name='FertilizerProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('capacity_kg', models.DecimalField(blank=True, decimal_places=3, max_digits=8, null=True, verbose_name='1袋重量(kg)')),
('nitrogen_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='窒素(%)')),
('phosphorus_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='リン酸(%)')),
('potassium_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='カリ(%)')),
('material', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='fertilizer_profile', to='materials.material')),
],
options={
'verbose_name': '肥料詳細',
'verbose_name_plural': '肥料詳細',
},
),
migrations.CreateModel(
name='PesticideProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('registration_no', models.CharField(blank=True, default='', max_length=100, verbose_name='農薬登録番号')),
('formulation', models.CharField(blank=True, default='', max_length=100, verbose_name='剤型')),
('usage_unit', models.CharField(blank=True, default='', max_length=50, verbose_name='使用単位')),
('dilution_ratio', models.CharField(blank=True, default='', max_length=100, verbose_name='希釈倍率')),
('active_ingredient', models.CharField(blank=True, default='', max_length=200, verbose_name='有効成分')),
('category', models.CharField(blank=True, default='', max_length=100, verbose_name='分類')),
('material', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='pesticide_profile', to='materials.material')),
],
options={
'verbose_name': '農薬詳細',
'verbose_name_plural': '農薬詳細',
},
),
migrations.CreateModel(
name='StockTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('transaction_type', models.CharField(choices=[('purchase', '入庫'), ('use', '使用'), ('adjustment_plus', '棚卸増'), ('adjustment_minus', '棚卸減'), ('discard', '廃棄')], max_length=30, verbose_name='取引種別')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, validators=[django.core.validators.MinValueValidator(decimal.Decimal('0.001'))], verbose_name='数量')),
('occurred_on', models.DateField(verbose_name='発生日')),
('note', models.TextField(blank=True, default='', verbose_name='備考')),
('created_at', models.DateTimeField(auto_now_add=True)),
('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stock_transactions', to='materials.material', verbose_name='資材')),
],
options={
'verbose_name': '入出庫履歴',
'verbose_name_plural': '入出庫履歴',
'ordering': ['-occurred_on', '-created_at', '-id'],
},
),
migrations.AddConstraint(
model_name='material',
constraint=models.UniqueConstraint(fields=('material_type', 'name'), name='uniq_material_type_name'),
),
]

View File

@@ -0,0 +1,25 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('materials', '0001_initial'),
('fertilizer', '0005_fertilizer_material'),
]
operations = [
migrations.AddField(
model_name='stocktransaction',
name='fertilization_plan',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='stock_reservations',
to='fertilizer.fertilizationplan',
verbose_name='施肥計画',
),
),
]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,220 @@
from decimal import Decimal
from django.core.validators import MinValueValidator
from django.db import models
class Material(models.Model):
"""共通資材マスタ"""
class MaterialType(models.TextChoices):
FERTILIZER = 'fertilizer', '肥料'
PESTICIDE = 'pesticide', '農薬'
SEEDLING = 'seedling', '種苗'
OTHER = 'other', 'その他'
class StockUnit(models.TextChoices):
BAG = 'bag', ''
BOTTLE = 'bottle', ''
KG = 'kg', 'kg'
LITER = 'liter', 'L'
PIECE = 'piece', ''
name = models.CharField(max_length=100, verbose_name='資材名')
material_type = models.CharField(
max_length=20,
choices=MaterialType.choices,
verbose_name='資材種別',
)
maker = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='メーカー',
)
stock_unit = models.CharField(
max_length=20,
choices=StockUnit.choices,
default=StockUnit.BAG,
verbose_name='在庫単位',
)
is_active = models.BooleanField(default=True, 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)
class Meta:
ordering = ['material_type', 'name']
constraints = [
models.UniqueConstraint(
fields=['material_type', 'name'],
name='uniq_material_type_name',
),
]
verbose_name = '資材'
verbose_name_plural = '資材'
def __str__(self):
return f'{self.get_material_type_display()}: {self.name}'
class FertilizerProfile(models.Model):
"""肥料専用属性"""
material = models.OneToOneField(
Material,
on_delete=models.CASCADE,
related_name='fertilizer_profile',
)
capacity_kg = models.DecimalField(
max_digits=8,
decimal_places=3,
blank=True,
null=True,
verbose_name='1袋重量(kg)',
)
nitrogen_pct = models.DecimalField(
max_digits=5,
decimal_places=2,
blank=True,
null=True,
verbose_name='窒素(%)',
)
phosphorus_pct = models.DecimalField(
max_digits=5,
decimal_places=2,
blank=True,
null=True,
verbose_name='リン酸(%)',
)
potassium_pct = models.DecimalField(
max_digits=5,
decimal_places=2,
blank=True,
null=True,
verbose_name='カリ(%)',
)
class Meta:
verbose_name = '肥料詳細'
verbose_name_plural = '肥料詳細'
def __str__(self):
return f'肥料詳細: {self.material.name}'
class PesticideProfile(models.Model):
"""農薬専用属性"""
material = models.OneToOneField(
Material,
on_delete=models.CASCADE,
related_name='pesticide_profile',
)
registration_no = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='農薬登録番号',
)
formulation = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='剤型',
)
usage_unit = models.CharField(
max_length=50,
blank=True,
default='',
verbose_name='使用単位',
)
dilution_ratio = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='希釈倍率',
)
active_ingredient = models.CharField(
max_length=200,
blank=True,
default='',
verbose_name='有効成分',
)
category = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='分類',
)
class Meta:
verbose_name = '農薬詳細'
verbose_name_plural = '農薬詳細'
def __str__(self):
return f'農薬詳細: {self.material.name}'
class StockTransaction(models.Model):
"""入出庫履歴"""
class TransactionType(models.TextChoices):
PURCHASE = 'purchase', '入庫'
USE = 'use', '使用'
RESERVE = 'reserve', '引当'
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
DISCARD = 'discard', '廃棄'
INCREASE_TYPES = {
TransactionType.PURCHASE,
TransactionType.ADJUSTMENT_PLUS,
}
DECREASE_TYPES = {
TransactionType.USE,
TransactionType.RESERVE,
TransactionType.ADJUSTMENT_MINUS,
TransactionType.DISCARD,
}
material = models.ForeignKey(
Material,
on_delete=models.PROTECT,
related_name='stock_transactions',
verbose_name='資材',
)
transaction_type = models.CharField(
max_length=30,
choices=TransactionType.choices,
verbose_name='取引種別',
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
validators=[MinValueValidator(Decimal('0.001'))],
verbose_name='数量',
)
occurred_on = models.DateField(verbose_name='発生日')
note = models.TextField(blank=True, default='', verbose_name='備考')
fertilization_plan = models.ForeignKey(
'fertilizer.FertilizationPlan',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='stock_reservations',
verbose_name='施肥計画',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-occurred_on', '-created_at', '-id']
verbose_name = '入出庫履歴'
verbose_name_plural = '入出庫履歴'
def __str__(self):
return (
f'{self.material.name} '
f'{self.get_transaction_type_display()} '
f'{self.quantity}'
)

View File

@@ -0,0 +1,215 @@
from decimal import Decimal
from django.db import transaction
from rest_framework import serializers
from .models import (
FertilizerProfile,
Material,
PesticideProfile,
StockTransaction,
)
class FertilizerProfileSerializer(serializers.ModelSerializer):
class Meta:
model = FertilizerProfile
fields = ['capacity_kg', 'nitrogen_pct', 'phosphorus_pct', 'potassium_pct']
class PesticideProfileSerializer(serializers.ModelSerializer):
class Meta:
model = PesticideProfile
fields = [
'registration_no',
'formulation',
'usage_unit',
'dilution_ratio',
'active_ingredient',
'category',
]
class MaterialReadSerializer(serializers.ModelSerializer):
material_type_display = serializers.CharField(
source='get_material_type_display',
read_only=True,
)
stock_unit_display = serializers.CharField(
source='get_stock_unit_display',
read_only=True,
)
fertilizer_profile = FertilizerProfileSerializer(read_only=True)
pesticide_profile = PesticideProfileSerializer(read_only=True)
current_stock = serializers.SerializerMethodField()
class Meta:
model = Material
fields = [
'id',
'name',
'material_type',
'material_type_display',
'maker',
'stock_unit',
'stock_unit_display',
'is_active',
'notes',
'fertilizer_profile',
'pesticide_profile',
'current_stock',
'created_at',
'updated_at',
]
def get_current_stock(self, obj):
transactions = list(obj.stock_transactions.all())
increase = sum(
transaction.quantity
for transaction in transactions
if transaction.transaction_type in StockTransaction.INCREASE_TYPES
)
decrease = sum(
transaction.quantity
for transaction in transactions
if transaction.transaction_type in StockTransaction.DECREASE_TYPES
)
return increase - decrease
class MaterialWriteSerializer(serializers.ModelSerializer):
fertilizer_profile = FertilizerProfileSerializer(required=False, allow_null=True)
pesticide_profile = PesticideProfileSerializer(required=False, allow_null=True)
class Meta:
model = Material
fields = [
'id',
'name',
'material_type',
'maker',
'stock_unit',
'is_active',
'notes',
'fertilizer_profile',
'pesticide_profile',
]
def validate(self, attrs):
material_type = attrs.get('material_type')
if self.instance is not None and material_type is None:
material_type = self.instance.material_type
fertilizer_profile = attrs.get('fertilizer_profile')
pesticide_profile = attrs.get('pesticide_profile')
if material_type == Material.MaterialType.FERTILIZER and pesticide_profile:
raise serializers.ValidationError(
{'pesticide_profile': '肥料には農薬詳細を設定できません。'}
)
if material_type == Material.MaterialType.PESTICIDE and fertilizer_profile:
raise serializers.ValidationError(
{'fertilizer_profile': '農薬には肥料詳細を設定できません。'}
)
if (
material_type in {Material.MaterialType.SEEDLING, Material.MaterialType.OTHER}
and (fertilizer_profile or pesticide_profile)
):
raise serializers.ValidationError(
'種苗・その他には詳細プロファイルを設定できません。'
)
return attrs
@transaction.atomic
def create(self, validated_data):
fertilizer_profile_data = validated_data.pop('fertilizer_profile', None)
pesticide_profile_data = validated_data.pop('pesticide_profile', None)
material = Material.objects.create(**validated_data)
self._save_profiles(material, fertilizer_profile_data, pesticide_profile_data)
return material
@transaction.atomic
def update(self, instance, validated_data):
fertilizer_profile_data = validated_data.pop('fertilizer_profile', None)
pesticide_profile_data = validated_data.pop('pesticide_profile', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
self._save_profiles(instance, fertilizer_profile_data, pesticide_profile_data)
return instance
def to_representation(self, instance):
return MaterialReadSerializer(instance, context=self.context).data
def _save_profiles(self, material, fertilizer_profile_data, pesticide_profile_data):
if material.material_type == Material.MaterialType.FERTILIZER:
if fertilizer_profile_data is not None:
profile, _ = FertilizerProfile.objects.get_or_create(material=material)
for attr, value in fertilizer_profile_data.items():
setattr(profile, attr, value)
profile.save()
PesticideProfile.objects.filter(material=material).delete()
return
if material.material_type == Material.MaterialType.PESTICIDE:
if pesticide_profile_data is not None:
profile, _ = PesticideProfile.objects.get_or_create(material=material)
for attr, value in pesticide_profile_data.items():
setattr(profile, attr, value)
profile.save()
FertilizerProfile.objects.filter(material=material).delete()
return
FertilizerProfile.objects.filter(material=material).delete()
PesticideProfile.objects.filter(material=material).delete()
class StockTransactionSerializer(serializers.ModelSerializer):
material_name = serializers.CharField(source='material.name', read_only=True)
material_type = serializers.CharField(source='material.material_type', read_only=True)
stock_unit = serializers.CharField(source='material.stock_unit', read_only=True)
stock_unit_display = serializers.CharField(
source='material.get_stock_unit_display',
read_only=True,
)
transaction_type_display = serializers.CharField(
source='get_transaction_type_display',
read_only=True,
)
class Meta:
model = StockTransaction
fields = [
'id',
'material',
'material_name',
'material_type',
'transaction_type',
'transaction_type_display',
'quantity',
'stock_unit',
'stock_unit_display',
'occurred_on',
'note',
'fertilization_plan',
'created_at',
]
read_only_fields = ['created_at']
class StockSummarySerializer(serializers.Serializer):
material_id = serializers.IntegerField()
name = serializers.CharField()
material_type = serializers.CharField()
material_type_display = serializers.CharField()
maker = serializers.CharField()
stock_unit = serializers.CharField()
stock_unit_display = serializers.CharField()
is_active = serializers.BooleanField()
current_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
reserved_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
available_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
last_transaction_date = serializers.DateField(allow_null=True)

View File

@@ -0,0 +1,100 @@
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.utils import timezone
from .models import StockTransaction
@transaction.atomic
def create_reserves_for_plan(plan):
"""施肥計画の引当を全置換で作り直す。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type=StockTransaction.TransactionType.RESERVE,
).delete()
if plan.is_confirmed:
return
occurred_on = (
plan.updated_at.date() if getattr(plan, 'updated_at', None) else timezone.localdate()
)
for entry in plan.entries.select_related('fertilizer__material'):
material = getattr(entry.fertilizer, 'material', None)
if material is None:
continue
StockTransaction.objects.create(
material=material,
transaction_type=StockTransaction.TransactionType.RESERVE,
quantity=entry.bags,
occurred_on=occurred_on,
note=f'施肥計画「{plan.name}」からの引当',
fertilization_plan=plan,
)
@transaction.atomic
def delete_reserves_for_plan(plan):
"""施肥計画に紐づく引当のみ削除する。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type=StockTransaction.TransactionType.RESERVE,
).delete()
@transaction.atomic
def confirm_spreading(plan, actual_entries):
"""引当を使用実績へ変換して施肥計画を確定済みにする。"""
from apps.fertilizer.models import Fertilizer
delete_reserves_for_plan(plan)
for entry_data in actual_entries:
actual_bags = _to_decimal(entry_data.get('actual_bags'))
if actual_bags <= 0:
continue
fertilizer = (
Fertilizer.objects.select_related('material')
.filter(id=entry_data['fertilizer_id'])
.first()
)
if fertilizer is None or fertilizer.material is None:
continue
StockTransaction.objects.create(
material=fertilizer.material,
transaction_type=StockTransaction.TransactionType.USE,
quantity=actual_bags,
occurred_on=timezone.localdate(),
note=f'施肥計画「{plan.name}」散布確定',
fertilization_plan=plan,
)
plan.is_confirmed = True
plan.confirmed_at = timezone.now()
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
@transaction.atomic
def unconfirm_spreading(plan):
"""散布確定を取り消し、USE トランザクションを削除して引当を再作成する。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type=StockTransaction.TransactionType.USE,
).delete()
plan.is_confirmed = False
plan.confirmed_at = None
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
create_reserves_for_plan(plan)
def _to_decimal(value):
try:
return Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return Decimal('0')

View File

@@ -0,0 +1,18 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'materials', views.MaterialViewSet, basename='material')
router.register(
r'stock-transactions',
views.StockTransactionViewSet,
basename='stock-transaction',
)
urlpatterns = [
path('', include(router.urls)),
path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'),
path('fertilizer-stock/', views.FertilizerStockView.as_view(), name='fertilizer-stock'),
]

View File

@@ -0,0 +1,164 @@
from decimal import Decimal
from rest_framework import generics, status, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import Material, StockTransaction
from .serializers import (
MaterialReadSerializer,
MaterialWriteSerializer,
StockSummarySerializer,
StockTransactionSerializer,
)
class MaterialViewSet(viewsets.ModelViewSet):
"""資材マスタ CRUD"""
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = Material.objects.select_related(
'fertilizer_profile',
'pesticide_profile',
).prefetch_related('stock_transactions')
material_type = self.request.query_params.get('material_type')
if material_type:
queryset = queryset.filter(material_type=material_type)
active = self.request.query_params.get('active')
if active is not None:
queryset = queryset.filter(is_active=active.lower() == 'true')
return queryset
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return MaterialWriteSerializer
return MaterialReadSerializer
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.stock_transactions.exists():
return Response(
{'detail': 'この資材には入出庫履歴があるため削除できません。無効化してください。'},
status=status.HTTP_400_BAD_REQUEST,
)
return super().destroy(request, *args, **kwargs)
class StockTransactionViewSet(viewsets.ModelViewSet):
"""入出庫履歴 CRUD"""
serializer_class = StockTransactionSerializer
permission_classes = [IsAuthenticated]
http_method_names = ['get', 'post', 'delete', 'head', 'options']
def get_queryset(self):
queryset = StockTransaction.objects.select_related('material')
material_id = self.request.query_params.get('material_id')
if material_id:
queryset = queryset.filter(material_id=material_id)
material_type = self.request.query_params.get('material_type')
if material_type:
queryset = queryset.filter(material__material_type=material_type)
date_from = self.request.query_params.get('date_from')
if date_from:
queryset = queryset.filter(occurred_on__gte=date_from)
date_to = self.request.query_params.get('date_to')
if date_to:
queryset = queryset.filter(occurred_on__lte=date_to)
return queryset
class StockSummaryView(generics.ListAPIView):
"""在庫集計一覧"""
serializer_class = StockSummarySerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Material.objects.none()
def list(self, request, *args, **kwargs):
queryset = Material.objects.prefetch_related('stock_transactions').order_by(
'material_type',
'name',
)
material_type = request.query_params.get('material_type')
if material_type:
queryset = queryset.filter(material_type=material_type)
active = request.query_params.get('active')
if active is not None:
queryset = queryset.filter(is_active=active.lower() == 'true')
results = []
for material in queryset:
results.append(_build_stock_summary(material))
serializer = self.get_serializer(results, many=True)
return Response(serializer.data)
class FertilizerStockView(generics.ListAPIView):
"""施肥計画画面用: 肥料の在庫情報を返す"""
permission_classes = [IsAuthenticated]
serializer_class = StockSummarySerializer
def get_queryset(self):
return Material.objects.none()
def list(self, request, *args, **kwargs):
queryset = Material.objects.filter(
material_type=Material.MaterialType.FERTILIZER,
is_active=True,
).prefetch_related('stock_transactions').order_by('name')
results = [_build_stock_summary(material) for material in queryset]
serializer = self.get_serializer(results, many=True)
return Response(serializer.data)
def _build_stock_summary(material):
transactions = list(material.stock_transactions.all())
increase = sum(
txn.quantity
for txn in transactions
if txn.transaction_type in StockTransaction.INCREASE_TYPES
)
decrease = sum(
txn.quantity
for txn in transactions
if txn.transaction_type in StockTransaction.DECREASE_TYPES
)
reserved = sum(
txn.quantity
for txn in transactions
if txn.transaction_type == StockTransaction.TransactionType.RESERVE
)
available = increase - decrease if transactions else Decimal('0')
last_date = max((txn.occurred_on for txn in transactions), default=None)
return {
'material_id': material.id,
'name': material.name,
'material_type': material.material_type,
'material_type_display': material.get_material_type_display(),
'maker': material.maker,
'stock_unit': material.stock_unit,
'stock_unit_display': material.get_stock_unit_display(),
'is_active': material.is_active,
'current_stock': available + reserved,
'reserved_stock': reserved,
'available_stock': available,
'last_transaction_date': last_date,
}

View File

@@ -44,6 +44,7 @@ INSTALLED_APPS = [
'apps.mail',
'apps.weather',
'apps.fertilizer',
'apps.materials',
]
MIDDLEWARE = [

View File

@@ -58,4 +58,5 @@ urlpatterns = [
path('api/mail/', include('apps.mail.urls')),
path('api/weather/', include('apps.weather.urls')),
path('api/fertilizer/', include('apps.fertilizer.urls')),
path('api/materials/', include('apps.materials.urls')),
]

BIN
designated_mix_national.zip Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,16 @@
# マスタードキュメント:施肥計画機能
> **作成**: 2026-03-01
> **最終更新**: 2026-03-01
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理)
> **実装状況**: 実装完了・本番稼働中(最終 commit deb03ef
> **最終更新**: 2026-03-15
> **対象機能**: 施肥計画(年度×品種単位のマトリクス管理・在庫引当・散布確定
> **実装状況**: 実装完了・本番稼働中
---
## 概要
農業生産者が「年度 × 品種」単位で施肥計画を立てる機能。
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力する
複数圃場 × 複数肥料 × 袋数をマトリクス形式で管理し、PDF出力に加えて在庫引当と散布確定まで一連で扱う
### 機能スコープIN / OUT
@@ -18,9 +18,11 @@
|---|---|
| 肥料マスタ管理 | 肥料購入管理 |
| 施肥計画の作成・編集・削除 | 圃場への配置計画(置き場所割り当て) |
| 3方式の自動計算 | 施肥作業の実績記録 |
| 3方式の自動計算 | 個別作業日報の詳細管理 |
| 作付け計画からの圃場自動取得 | |
| PDF出力圃場×肥料マトリクス表 | |
| 在庫引当・引当解除 | |
| 散布確定(計画値確認 + 実績入力) | |
---
@@ -47,6 +49,8 @@
| name | varchar(200) | required | 計画名(ユーザーが自由入力) |
| year | int | required | 年度 |
| variety | FK(plans.Variety) | PROTECT | 品種≠NULL |
| is_confirmed | bool | default=False | 散布確定済みフラグ |
| confirmed_at | datetime | nullable | 散布確定日時 |
| created_at | datetime | auto | |
| updated_at | datetime | auto | |
@@ -102,6 +106,8 @@
| GET | `/api/fertilizer/plans/{id}/` | 詳細取得entries 含む) |
| PUT | `/api/fertilizer/plans/{id}/` | 更新entries 全置換) |
| DELETE | `/api/fertilizer/plans/{id}/` | 削除 |
| POST | `/api/fertilizer/plans/{id}/confirm_spreading/` | 散布確定(引当 → 使用へ変換) |
| POST | `/api/fertilizer/plans/{id}/unconfirm/` | 散布確定取消(使用 → 引当に戻す) |
| GET | `/api/fertilizer/plans/{id}/pdf/` | PDF出力application/pdf |
一覧レスポンス例FertilizationPlan:
@@ -113,6 +119,8 @@
"variety": 3,
"variety_name": "コシヒカリ",
"crop_name": "米",
"is_confirmed": false,
"confirmed_at": null,
"field_count": 12,
"fertilizer_count": 2,
"entries": [
@@ -146,6 +154,19 @@ POST/PUT リクエスト例:
PUT 時は entries が全置換削除→再作成。entries を省略した場合は既存を維持。
散布確定 API リクエスト例:
```json
{
"entries": [
{"field_id": 5, "fertilizer_id": 1, "actual_bags": 2.4},
{"field_id": 6, "fertilizer_id": 1, "actual_bags": 0}
]
}
```
- `actual_bags > 0`: 対応する引当を使用実績へ変換
- `actual_bags = 0`: 未散布として引当解除
### 圃場候補取得
```
@@ -269,8 +290,8 @@ GET /api/plans/crops/
### 施肥計画一覧(`/fertilizer`
- 年度セレクタlocalStorage `fertilizerYear` で保持)
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数
- 操作ボタン: PDF出力・編集・削除
- 計画カード一覧: 計画名・作物/品種・圃場数・肥料数・散布確定状態
- 操作ボタン: PDF出力・編集・削除・散布確定
- ヘッダー: 「肥料マスタ」「新規作成」ボタン
### 肥料マスタ(`/fertilizer/masters`
@@ -295,6 +316,12 @@ GET /api/plans/crops/
6. **手動調整**: マトリクス表のセルを直接編集
7. **保存**: 「保存」ボタンで entries を一括送信
#### 在庫連携・確定状態
- 肥料列ヘッダーに在庫 / 利用可能在庫 / 計画計 / 不足数を表示
- 散布確定済みの計画は情報バナーを表示し、編集操作をロック
- 「確定取消」で使用実績を引当に戻し、再編集できる
#### マトリクスの表示仕様
- 自動計算直後: セルに計算値(小数)がそのまま表示される(編集可)
@@ -302,6 +329,15 @@ GET /api/plans/crops/
- `↩` ボタン押下: 整数値を破棄し、元の計算値に戻る(参照グレー表示も消える)
- 編集中に計算を再実行すると、その肥料列の `adjusted``roundedColumns` がリセットされる
### 散布確定モーダル(`/fertilizer` 一覧から起動)
- 全画面遷移ではなくモーダル表示
- 施肥計画編集と同じ視線移動になるよう、`圃場 = 行``肥料 = 列` のマトリクス表を採用
- 画面上部に計画名・年度・作物/品種・対象圃場数・肥料数を表示
- 各セルは「薄いグレーの計画値」+「実績入力欄」の2段表示
- 行末に圃場ごとの実績合計、表フッターに肥料別合計と総合計を表示
- `0` を入力したセルは未散布として扱い、対応する引当を解除する
#### State 構成
```typescript

View File

@@ -0,0 +1,308 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Loader2, X } from 'lucide-react';
import { api } from '@/lib/api';
import { FertilizationPlan } from '@/types';
interface ConfirmSpreadingModalProps {
plan: FertilizationPlan | null;
isOpen: boolean;
onClose: () => void;
onConfirmed: () => Promise<void> | void;
}
type ActualMap = Record<string, string>;
const entryKey = (fieldId: number, fertilizerId: number) => `${fieldId}-${fertilizerId}`;
type EntryMatrix = Record<number, Record<number, string>>;
export default function ConfirmSpreadingModal({
plan,
isOpen,
onClose,
onConfirmed,
}: ConfirmSpreadingModalProps) {
const [actuals, setActuals] = useState<ActualMap>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen || !plan) {
return;
}
const nextActuals: ActualMap = {};
plan.entries.forEach((entry) => {
nextActuals[entryKey(entry.field, entry.fertilizer)] = String(entry.bags);
});
setActuals(nextActuals);
setError(null);
}, [isOpen, plan]);
const layout = useMemo(() => {
if (!plan) {
return {
fields: [] as { id: number; name: string; areaTan: string | undefined }[],
fertilizers: [] as { id: number; name: string }[],
planned: {} as EntryMatrix,
};
}
const fieldMap = new Map<number, { id: number; name: string; areaTan: string | undefined }>();
const fertilizerMap = new Map<number, { id: number; name: string }>();
const planned: EntryMatrix = {};
plan.entries.forEach((entry) => {
if (!fieldMap.has(entry.field)) {
fieldMap.set(entry.field, {
id: entry.field,
name: entry.field_name ?? `圃場ID:${entry.field}`,
areaTan: entry.field_area_tan,
});
}
if (!fertilizerMap.has(entry.fertilizer)) {
fertilizerMap.set(entry.fertilizer, {
id: entry.fertilizer,
name: entry.fertilizer_name ?? `肥料ID:${entry.fertilizer}`,
});
}
if (!planned[entry.field]) {
planned[entry.field] = {};
}
planned[entry.field][entry.fertilizer] = String(entry.bags);
});
return {
fields: Array.from(fieldMap.values()),
fertilizers: Array.from(fertilizerMap.values()),
planned,
};
}, [plan]);
if (!isOpen || !plan) {
return null;
}
const handleConfirm = async () => {
setSaving(true);
setError(null);
try {
await api.post(`/fertilizer/plans/${plan.id}/confirm_spreading/`, {
entries: plan.entries.map((entry) => ({
field_id: entry.field,
fertilizer_id: entry.fertilizer,
actual_bags: Number(actuals[entryKey(entry.field, entry.fertilizer)] || 0),
})),
});
await onConfirmed();
onClose();
} catch (e: unknown) {
console.error(e);
const detail =
typeof e === 'object' &&
e !== null &&
'response' in e &&
typeof e.response === 'object' &&
e.response !== null &&
'data' in e.response
? JSON.stringify(e.response.data)
: '散布確定に失敗しました。';
setError(detail);
} finally {
setSaving(false);
}
};
const numericValue = (value: string | undefined) => {
const parsed = parseFloat(value ?? '0');
return isNaN(parsed) ? 0 : parsed;
};
const actualTotalByField = (fieldId: number) =>
layout.fertilizers.reduce(
(sum, fertilizer) => sum + numericValue(actuals[entryKey(fieldId, fertilizer.id)]),
0
);
const actualTotalByFertilizer = (fertilizerId: number) =>
layout.fields.reduce(
(sum, field) => sum + numericValue(actuals[entryKey(field.id, fertilizerId)]),
0
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 px-4">
<div className="max-h-[92vh] w-full max-w-[95vw] overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">
: {plan.name}
</h2>
<p className="text-sm text-gray-500">
</p>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[calc(92vh-144px)] overflow-y-auto bg-gray-50 px-6 py-5">
{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 rounded-lg bg-white p-4 shadow">
<div className="grid gap-3 text-sm text-gray-700 sm:grid-cols-4">
<div>
<div className="text-xs text-gray-500"></div>
<div className="font-medium">{plan.year}</div>
</div>
<div>
<div className="text-xs text-gray-500"> / </div>
<div className="font-medium">
{plan.crop_name} / {plan.variety_name}
</div>
</div>
<div>
<div className="text-xs text-gray-500"></div>
<div className="font-medium">{plan.field_count}</div>
</div>
<div>
<div className="text-xs text-gray-500"></div>
<div className="font-medium">{plan.fertilizer_count}</div>
</div>
</div>
</div>
<div className="mb-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-xs text-sky-800">
0
</div>
<div className="overflow-x-auto rounded-lg bg-white shadow">
<table className="min-w-full text-sm border-collapse">
<thead className="bg-gray-50">
<tr>
<th className="border border-gray-200 px-4 py-3 text-left font-medium text-gray-700 whitespace-nowrap">
</th>
<th className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700 whitespace-nowrap">
()
</th>
{layout.fertilizers.map((fertilizer) => (
<th
key={fertilizer.id}
className="border border-gray-200 px-3 py-2 text-center font-medium text-gray-700 whitespace-nowrap"
>
<div>{fertilizer.name}</div>
<div className="mt-0.5 text-[11px] font-normal text-gray-400">
/
</div>
</th>
))}
<th className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700 whitespace-nowrap">
</th>
</tr>
</thead>
<tbody>
{layout.fields.map((field) => (
<tr key={field.id} className="hover:bg-gray-50">
<td className="border border-gray-200 px-4 py-2 whitespace-nowrap text-gray-800">
{field.name}
</td>
<td className="border border-gray-200 px-3 py-2 text-right text-gray-600 whitespace-nowrap">
{field.areaTan ?? '-'}
</td>
{layout.fertilizers.map((fertilizer) => {
const key = entryKey(field.id, fertilizer.id);
const planned = layout.planned[field.id]?.[fertilizer.id];
const hasEntry = planned !== undefined;
return (
<td key={key} className="border border-gray-200 px-2 py-2">
{hasEntry ? (
<div className="flex flex-col items-end gap-1">
<span className="text-[11px] text-gray-400">
{planned}
</span>
<input
type="number"
min="0"
step="0.1"
value={actuals[key] ?? ''}
onChange={(e) =>
setActuals((prev) => ({
...prev,
[key]: e.target.value,
}))
}
className="w-20 rounded-md border border-gray-300 px-2 py-1 text-right text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
/>
</div>
) : (
<div className="text-center text-gray-300">-</div>
)}
</td>
);
})}
<td className="border border-gray-200 px-3 py-2 text-right font-medium text-gray-700">
{actualTotalByField(field.id).toFixed(2)}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50 font-semibold">
<tr>
<td className="border border-gray-200 px-4 py-2"></td>
<td className="border border-gray-200 px-3 py-2 text-right text-gray-500">
{layout.fields
.reduce((sum, field) => sum + (parseFloat(field.areaTan ?? '0') || 0), 0)
.toFixed(2)}
</td>
{layout.fertilizers.map((fertilizer) => (
<td
key={fertilizer.id}
className="border border-gray-200 px-3 py-2 text-right text-gray-700"
>
{actualTotalByFertilizer(fertilizer.id).toFixed(2)}
</td>
))}
<td className="border border-gray-200 px-3 py-2 text-right text-green-700">
{layout.fields
.reduce((sum, field) => sum + actualTotalByField(field.id), 0)
.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
<button
onClick={onClose}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-100"
>
</button>
<button
onClick={handleConfirm}
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
</button>
</div>
</div>
</div>
);
}

View File

@@ -2,10 +2,10 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ChevronLeft, Plus, X, Calculator, Save, FileDown } from 'lucide-react';
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Undo2 } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Fertilizer, FertilizationPlan, Crop, Field } from '@/types';
import { Crop, FertilizationPlan, Fertilizer, Field, StockSummary } from '@/types';
type CalcMethod = 'per_tan' | 'even' | 'nitrogen';
@@ -63,6 +63,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
const [adjusted, setAdjusted] = useState<Matrix>({});
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
const [stockByMaterialId, setStockByMaterialId] = useState<Record<number, StockSummary>>({});
const [initialPlanTotals, setInitialPlanTotals] = useState<Record<number, number>>({});
const [isConfirmed, setIsConfirmed] = useState(false);
const [confirmedAt, setConfirmedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
@@ -71,15 +75,26 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 初期データ取得
useEffect(() => {
const init = async () => {
setSaveError(null);
try {
const [cropsRes, fertsRes, fieldsRes] = await Promise.all([
const [cropsRes, fertsRes, fieldsRes, stockRes] = await Promise.all([
api.get('/plans/crops/'),
api.get('/fertilizer/fertilizers/'),
api.get('/fields/?ordering=display_order,id'),
api.get('/materials/fertilizer-stock/'),
]);
setCrops(cropsRes.data);
setAllFertilizers(fertsRes.data);
setAllFields(fieldsRes.data);
setStockByMaterialId(
stockRes.data.reduce(
(acc: Record<number, StockSummary>, summary: StockSummary) => {
acc[summary.material_id] = summary;
return acc;
},
{}
)
);
if (!isNew && planId) {
const planRes = await api.get(`/fertilizer/plans/${planId}/`);
@@ -87,6 +102,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
setName(plan.name);
setYear(plan.year);
setVarietyId(plan.variety);
setIsConfirmed(plan.is_confirmed);
setConfirmedAt(plan.confirmed_at);
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
@@ -110,6 +127,12 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
newAdjusted[e.field][e.fertilizer] = String(e.bags);
});
setAdjusted(newAdjusted);
setInitialPlanTotals(
plan.entries.reduce((acc: Record<number, number>, entry) => {
acc[entry.fertilizer] = (acc[entry.fertilizer] ?? 0) + Number(entry.bags);
return acc;
}, {})
);
// 保存済み calc_settings でページ開時に自動計算してラベル用 calcMatrix を生成
const validSettings = plan.calc_settings?.filter((s) => s.param) ?? [];
@@ -142,7 +165,9 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: unknown } };
console.error('初期データ取得エラー:', err);
alert(`データの読み込みに失敗しました (${err.response?.status ?? 'network error'})\n${JSON.stringify(err.response?.data ?? '')}`);
setSaveError(
`データの読み込みに失敗しました (${err.response?.status ?? 'network error'}): ${JSON.stringify(err.response?.data ?? '')}`
);
} finally {
setLoading(false);
}
@@ -170,6 +195,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 肥料追加・削除
const addFertilizer = (fert: Fertilizer) => {
if (isConfirmed) return;
if (planFertilizers.find((f) => f.id === fert.id)) return;
setPlanFertilizers((prev) => [...prev, fert]);
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
@@ -177,6 +203,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
};
const removeFertilizer = (id: number) => {
if (isConfirmed) return;
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
const dropCol = (m: Matrix): Matrix => {
@@ -195,12 +222,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 圃場追加・削除
const addField = (field: Field) => {
if (isConfirmed) return;
if (selectedFields.find((f) => f.id === field.id)) return;
setSelectedFields((prev) => [...prev, field]);
setShowFieldPicker(false);
};
const removeField = (id: number) => {
if (isConfirmed) return;
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; });
@@ -210,8 +239,16 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 自動計算
const runCalc = async (setting: CalcSetting) => {
if (!setting.param) return alert('パラメータを入力してください');
if (selectedFields.length === 0) return alert('対象圃場を選択してください');
if (isConfirmed) return;
if (!setting.param) {
setSaveError('パラメータを入力してください');
return;
}
if (selectedFields.length === 0) {
setSaveError('対象圃場を選択してください');
return;
}
setSaveError(null);
const targetFields = calcNewOnly
? selectedFields.filter((f) => {
@@ -222,7 +259,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
})
: selectedFields;
if (targetFields.length === 0) return alert('未入力の圃場がありません。「全圃場」で実行してください。');
if (targetFields.length === 0) {
setSaveError('未入力の圃場がありません。「全圃場」で実行してください。');
return;
}
try {
const res = await api.post('/fertilizer/calculate/', {
@@ -246,7 +286,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// adjusted は保持するテキストボックスにDB/確定値を維持し、ラベルに計算結果を表示)
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
alert(err.response?.data?.error ?? '計算に失敗しました');
setSaveError(err.response?.data?.error ?? '計算に失敗しました');
}
};
@@ -258,6 +298,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── セル更新adjusted を更新)
const updateCell = (fieldId: number, fertId: number, value: string) => {
if (isConfirmed) return;
setAdjusted((prev) => {
const next = { ...prev };
if (!next[fieldId]) next[fieldId] = {};
@@ -268,6 +309,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 列単位で四捨五入 / 元に戻す(トグル)
const roundColumn = (fertId: number) => {
if (isConfirmed) return;
if (roundedColumns.has(fertId)) {
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
setAdjusted((prev) => {
@@ -318,10 +360,30 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
selectedFields.reduce((sum, f) => sum + effectiveValue(f.id, fertId), 0);
const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 0);
const getNumericValue = (value: string | null | undefined) => {
const parsed = parseFloat(value ?? '0');
return isNaN(parsed) ? 0 : parsed;
};
const getStockInfo = (fertilizer: Fertilizer) =>
fertilizer.material_id ? stockByMaterialId[fertilizer.material_id] ?? null : null;
const getPlanAvailableStock = (fertilizer: Fertilizer) => {
const stock = getStockInfo(fertilizer);
if (!stock) return null;
return getNumericValue(stock.available_stock) + (initialPlanTotals[fertilizer.id] ?? 0);
};
const getPlanShortage = (fertilizer: Fertilizer) => {
const available = getPlanAvailableStock(fertilizer);
if (available === null) return 0;
return Math.max(colTotal(fertilizer.id) - available, 0);
};
// ─── 保存adjusted 優先、なければ calc 値を使用)
const handleSave = async () => {
setSaveError(null);
if (isConfirmed) {
setSaveError('確定済みの施肥計画は編集できません。');
return;
}
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
if (!varietyId) { setSaveError('品種を選択してください'); return; }
if (selectedFields.length === 0) { setSaveError('圃場を1つ以上選択してください'); return; }
@@ -362,9 +424,35 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
}
};
// ─── 確定取消
const handleUnconfirm = async () => {
if (!planId) return;
setSaveError(null);
try {
await api.post(`/fertilizer/plans/${planId}/unconfirm/`);
setIsConfirmed(false);
setConfirmedAt(null);
// 引当が再作成されるので在庫情報を再取得
const stockRes = await api.get('/materials/fertilizer-stock/');
setStockByMaterialId(
stockRes.data.reduce(
(acc: Record<number, StockSummary>, summary: StockSummary) => {
acc[summary.material_id] = summary;
return acc;
},
{}
)
);
} catch (e) {
console.error(e);
setSaveError('確定取消に失敗しました');
}
};
// ─── PDF出力
const handlePdf = async () => {
if (!planId) return;
setSaveError(null);
try {
const res = await api.get(`/fertilizer/plans/${planId}/pdf/`, { responseType: 'blob' });
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
@@ -374,7 +462,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
a.click();
URL.revokeObjectURL(url);
} catch (e) {
alert('PDF出力に失敗しました');
console.error(e);
setSaveError('PDF出力に失敗しました');
}
};
@@ -406,6 +495,15 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
</h1>
</div>
<div className="flex items-center gap-2">
{!isNew && isConfirmed && (
<button
onClick={handleUnconfirm}
className="flex items-center gap-2 px-4 py-2 border border-amber-300 rounded-lg text-sm text-amber-700 hover:bg-amber-50"
>
<Undo2 className="h-4 w-4" />
</button>
)}
{!isNew && (
<button
onClick={handlePdf}
@@ -417,11 +515,11 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
)}
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
disabled={saving || isConfirmed}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'}
{isConfirmed ? '確定済み' : saving ? '保存中...' : '保存'}
</button>
</div>
</div>
@@ -435,6 +533,16 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
</div>
)}
{isConfirmed && (
<div className="mb-4 flex items-start gap-2 bg-sky-50 border border-sky-300 text-sky-800 rounded-lg px-4 py-3 text-sm">
<span className="font-bold shrink-0">i</span>
<span>
{confirmedAt ? ` 確定日時: ${new Date(confirmedAt).toLocaleString('ja-JP')}` : ''}
</span>
</div>
)}
{/* 基本情報 */}
<div className="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-48">
@@ -444,6 +552,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例: 2025年度 コシヒカリ 元肥"
disabled={isConfirmed}
/>
</div>
<div>
@@ -452,6 +561,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
disabled={isConfirmed}
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
@@ -462,6 +572,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
value={varietyId}
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
disabled={isConfirmed}
>
<option value=""></option>
{crops.map((crop) => (
@@ -487,7 +598,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
</h2>
<button
onClick={() => setShowFieldPicker(true)}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
disabled={isConfirmed}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="h-3 w-3" />
</button>
@@ -504,7 +616,11 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="flex items-center gap-1 bg-green-50 border border-green-200 rounded-full px-3 py-1 text-xs text-green-800"
>
{f.name}{f.area_tan}
<button onClick={() => removeField(f.id)} className="text-green-400 hover:text-red-500">
<button
onClick={() => removeField(f.id)}
disabled={isConfirmed}
className="text-green-400 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
>
<X className="h-3 w-3" />
</button>
</span>
@@ -522,13 +638,15 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
type="checkbox"
checked={calcNewOnly}
onChange={(e) => setCalcNewOnly(e.target.checked)}
disabled={isConfirmed}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</label>
<button
onClick={() => setShowFertPicker(true)}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
disabled={isConfirmed}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="h-3 w-3" />
</button>
@@ -550,6 +668,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="border border-gray-300 rounded px-2 py-1 text-xs"
value={setting.method}
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
disabled={isConfirmed}
>
{Object.entries(METHOD_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
@@ -562,17 +681,20 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
value={setting.param}
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
placeholder="値"
disabled={isConfirmed}
/>
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
<button
onClick={() => runCalc(setting)}
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100"
disabled={isConfirmed}
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Calculator className="h-3 w-3" />
</button>
<button
onClick={() => removeFertilizer(fert.id)}
className="ml-auto text-gray-300 hover:text-red-500"
disabled={isConfirmed}
className="ml-auto text-gray-300 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
>
<X className="h-4 w-4" />
</button>
@@ -593,18 +715,44 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">()</th>
{planFertilizers.map((f) => {
const isRounded = roundedColumns.has(f.id);
const stock = getStockInfo(f);
const planAvailable = getPlanAvailableStock(f);
const shortage = getPlanShortage(f);
return (
<th key={f.id} className="text-center px-3 py-2 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">
{f.name}
<div>{f.name}</div>
{stock ? (
<div className="mt-1 space-y-0.5 text-[11px] font-normal leading-4">
<div className="text-gray-500">
{stock.current_stock}{stock.stock_unit_display}
{planAvailable !== null && (
<span className="ml-1">
/ {planAvailable.toFixed(2)}{stock.stock_unit_display}
</span>
)}
</div>
<div className={shortage > 0 ? 'text-red-600' : 'text-gray-500'}>
{colTotal(f.id).toFixed(2)}{stock.stock_unit_display}
{shortage > 0 && (
<span className="ml-1">/ {shortage.toFixed(2)}{stock.stock_unit_display}</span>
)}
</div>
</div>
) : (
<div className="mt-1 text-[11px] font-normal text-amber-600">
</div>
)}
<span className="flex items-center justify-center gap-1.5 text-xs font-normal text-gray-400 mt-0.5">
<button
onClick={() => roundColumn(f.id)}
disabled={isConfirmed}
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
isRounded
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
: 'bg-blue-100 text-blue-500 hover:bg-blue-200'
}`}
} disabled:opacity-40 disabled:cursor-not-allowed`}
title={isRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
>
{isRounded ? '↩' : '≈'}
@@ -641,6 +789,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
value={inputValue}
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
placeholder="-"
disabled={isConfirmed}
/>
</div>
</td>
@@ -689,7 +838,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<button
key={f.id}
onClick={() => addField(f)}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between"
disabled={isConfirmed}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
>
<span>{f.name}</span>
<span className="text-gray-400">{f.area_tan}</span>
@@ -703,7 +853,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<button
key={f.id}
onClick={() => addField(f)}
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between"
disabled={isConfirmed}
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
>
<span>{f.name}</span>
<span className="text-gray-400">{f.area_tan}</span>
@@ -730,7 +881,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<button
key={f.id}
onClick={() => addFertilizer(f)}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm"
disabled={isConfirmed}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
>
<span className="font-medium">{f.name}</span>
{f.maker && <span className="ml-2 text-gray-400 text-xs">{f.maker}</span>}

View File

@@ -15,6 +15,8 @@ const emptyForm = (): Omit<Fertilizer, 'id'> => ({
phosphorus_pct: null,
potassium_pct: null,
notes: null,
material: null,
material_id: null,
});
export default function FertilizerMastersPage() {
@@ -55,6 +57,8 @@ export default function FertilizerMastersPage() {
phosphorus_pct: f.phosphorus_pct,
potassium_pct: f.potassium_pct,
notes: f.notes,
material: f.material,
material_id: f.material_id,
});
setEditingId(f.id);
};

View File

@@ -2,7 +2,9 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Pencil, Trash2, FileDown, Sprout } from 'lucide-react';
import { Plus, Pencil, Trash2, FileDown, Sprout, BadgeCheck, Undo2 } from 'lucide-react';
import ConfirmSpreadingModal from './_components/ConfirmSpreadingModal';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { FertilizationPlan } from '@/types';
@@ -21,6 +23,8 @@ export default function FertilizerPage() {
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
const [loading, setLoading] = useState(true);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [confirmTarget, setConfirmTarget] = useState<FertilizationPlan | null>(null);
useEffect(() => {
localStorage.setItem('fertilizerYear', String(year));
@@ -41,6 +45,7 @@ export default function FertilizerPage() {
const handleDelete = async (id: number, name: string) => {
setDeleteError(null);
setActionError(null);
try {
await api.delete(`/fertilizer/plans/${id}/`);
await fetchPlans();
@@ -50,7 +55,19 @@ export default function FertilizerPage() {
}
};
const handleUnconfirm = async (id: number, name: string) => {
setActionError(null);
try {
await api.post(`/fertilizer/plans/${id}/unconfirm/`);
await fetchPlans();
} catch (e) {
console.error(e);
setActionError(`${name}」の確定取消に失敗しました`);
}
};
const handlePdf = async (id: number, name: string) => {
setActionError(null);
try {
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
@@ -61,7 +78,7 @@ export default function FertilizerPage() {
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
alert('PDF出力に失敗しました');
setActionError('PDF出力に失敗しました');
}
};
@@ -115,6 +132,14 @@ export default function FertilizerPage() {
</div>
)}
{actionError && (
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
<span className="font-bold shrink-0"></span>
<span>{actionError}</span>
<button onClick={() => setActionError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600"></button>
</div>
)}
{loading ? (
<p className="text-gray-500">...</p>
) : plans.length === 0 ? (
@@ -135,6 +160,7 @@ export default function FertilizerPage() {
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-700"></th>
<th className="text-left px-4 py-3 font-medium text-gray-700"> / </th>
<th className="text-left px-4 py-3 font-medium text-gray-700"></th>
<th className="text-right px-4 py-3 font-medium text-gray-700"></th>
<th className="text-right px-4 py-3 font-medium text-gray-700"></th>
<th className="px-4 py-3"></th>
@@ -142,15 +168,52 @@ export default function FertilizerPage() {
</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">{plan.name}</td>
<tr
key={plan.id}
className={plan.is_confirmed ? 'bg-sky-50 hover:bg-sky-100/60' : 'hover:bg-gray-50'}
>
<td className="px-4 py-3 font-medium">
<div className="flex items-center gap-2">
<span>{plan.name}</span>
{plan.is_confirmed && (
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">
<BadgeCheck className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-gray-600">
{plan.crop_name} / {plan.variety_name}
</td>
<td className="px-4 py-3 text-gray-600">
{plan.is_confirmed
? `散布確定 ${plan.confirmed_at ? new Date(plan.confirmed_at).toLocaleString('ja-JP') : ''}`
: '未確定'}
</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 justify-end">
{!plan.is_confirmed ? (
<button
onClick={() => setConfirmTarget(plan)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-emerald-300 rounded hover:bg-emerald-50 text-emerald-700"
title="散布確定"
>
<BadgeCheck className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => handleUnconfirm(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-amber-300 rounded hover:bg-amber-50 text-amber-700"
title="確定取消"
>
<Undo2 className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => handlePdf(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"
@@ -184,6 +247,13 @@ export default function FertilizerPage() {
</div>
)}
</div>
<ConfirmSpreadingModal
isOpen={confirmTarget !== null}
plan={confirmTarget}
onClose={() => setConfirmTarget(null)}
onConfirmed={fetchPlans}
/>
</div>
);
}

View File

@@ -0,0 +1,341 @@
'use client';
import { Check, X } from 'lucide-react';
import { Material } from '@/types';
export type MaterialTab = 'fertilizer' | 'pesticide' | 'misc';
export interface MaterialFormState {
name: string;
material_type: Material['material_type'];
maker: string;
stock_unit: Material['stock_unit'];
is_active: boolean;
notes: string;
fertilizer_profile: {
capacity_kg: string;
nitrogen_pct: string;
phosphorus_pct: string;
potassium_pct: string;
};
pesticide_profile: {
registration_no: string;
formulation: string;
usage_unit: string;
dilution_ratio: string;
active_ingredient: string;
category: string;
};
}
interface MaterialFormProps {
tab: MaterialTab;
form: MaterialFormState;
saving: boolean;
onBaseFieldChange: (
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
value: string | boolean
) => void;
onFertilizerFieldChange: (
field: keyof MaterialFormState['fertilizer_profile'],
value: string
) => void;
onPesticideFieldChange: (
field: keyof MaterialFormState['pesticide_profile'],
value: string
) => void;
onSave: () => void;
onCancel: () => void;
}
const inputClassName =
'w-full rounded-md border border-gray-300 px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500';
export default function MaterialForm({
tab,
form,
saving,
onBaseFieldChange,
onFertilizerFieldChange,
onPesticideFieldChange,
onSave,
onCancel,
}: MaterialFormProps) {
if (tab === 'fertilizer') {
return (
<tr className="bg-green-50">
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.name}
onChange={(e) => onBaseFieldChange('name', e.target.value)}
placeholder="資材名"
autoFocus
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.maker}
onChange={(e) => onBaseFieldChange('maker', e.target.value)}
placeholder="メーカー"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
type="number"
step="0.001"
value={form.fertilizer_profile.capacity_kg}
onChange={(e) => onFertilizerFieldChange('capacity_kg', e.target.value)}
placeholder="kg"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
type="number"
step="0.01"
value={form.fertilizer_profile.nitrogen_pct}
onChange={(e) => onFertilizerFieldChange('nitrogen_pct', e.target.value)}
placeholder="%"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
type="number"
step="0.01"
value={form.fertilizer_profile.phosphorus_pct}
onChange={(e) => onFertilizerFieldChange('phosphorus_pct', e.target.value)}
placeholder="%"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
type="number"
step="0.01"
value={form.fertilizer_profile.potassium_pct}
onChange={(e) => onFertilizerFieldChange('potassium_pct', e.target.value)}
placeholder="%"
/>
</td>
<td className="px-2 py-2">
<StockUnitSelect
value={form.stock_unit}
onChange={(value) => onBaseFieldChange('stock_unit', value)}
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.notes}
onChange={(e) => onBaseFieldChange('notes', e.target.value)}
placeholder="備考"
/>
</td>
<td className="px-2 py-2 text-center">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => onBaseFieldChange('is_active', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</td>
<td className="px-2 py-2">
<ActionButtons onSave={onSave} onCancel={onCancel} saving={saving} />
</td>
</tr>
);
}
if (tab === 'pesticide') {
return (
<tr className="bg-green-50">
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.name}
onChange={(e) => onBaseFieldChange('name', e.target.value)}
placeholder="資材名"
autoFocus
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.maker}
onChange={(e) => onBaseFieldChange('maker', e.target.value)}
placeholder="メーカー"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.pesticide_profile.registration_no}
onChange={(e) => onPesticideFieldChange('registration_no', e.target.value)}
placeholder="登録番号"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.pesticide_profile.formulation}
onChange={(e) => onPesticideFieldChange('formulation', e.target.value)}
placeholder="剤型"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.pesticide_profile.active_ingredient}
onChange={(e) => onPesticideFieldChange('active_ingredient', e.target.value)}
placeholder="有効成分"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.pesticide_profile.category}
onChange={(e) => onPesticideFieldChange('category', e.target.value)}
placeholder="分類"
/>
</td>
<td className="px-2 py-2">
<StockUnitSelect
value={form.stock_unit}
onChange={(value) => onBaseFieldChange('stock_unit', value)}
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.notes}
onChange={(e) => onBaseFieldChange('notes', e.target.value)}
placeholder="備考"
/>
</td>
<td className="px-2 py-2 text-center">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => onBaseFieldChange('is_active', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</td>
<td className="px-2 py-2">
<ActionButtons onSave={onSave} onCancel={onCancel} saving={saving} />
</td>
</tr>
);
}
return (
<tr className="bg-green-50">
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.name}
onChange={(e) => onBaseFieldChange('name', e.target.value)}
placeholder="資材名"
autoFocus
/>
</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>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.maker}
onChange={(e) => onBaseFieldChange('maker', e.target.value)}
placeholder="メーカー"
/>
</td>
<td className="px-2 py-2">
<StockUnitSelect
value={form.stock_unit}
onChange={(value) => onBaseFieldChange('stock_unit', value)}
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.notes}
onChange={(e) => onBaseFieldChange('notes', e.target.value)}
placeholder="備考"
/>
</td>
<td className="px-2 py-2 text-center">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => onBaseFieldChange('is_active', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</td>
<td className="px-2 py-2">
<ActionButtons onSave={onSave} onCancel={onCancel} saving={saving} />
</td>
</tr>
);
}
function ActionButtons({
onSave,
onCancel,
saving,
}: {
onSave: () => void;
onCancel: () => void;
saving: boolean;
}) {
return (
<div className="flex items-center justify-end gap-1">
<button
onClick={onSave}
disabled={saving}
className="text-green-600 transition hover:text-green-800 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={onCancel}
className="text-gray-400 transition hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
function StockUnitSelect({
value,
onChange,
}: {
value: Material['stock_unit'];
onChange: (value: Material['stock_unit']) => void;
}) {
return (
<select
className={inputClassName}
value={value}
onChange={(e) => onChange(e.target.value as Material['stock_unit'])}
>
<option value="bag"></option>
<option value="bottle"></option>
<option value="kg">kg</option>
<option value="liter">L</option>
<option value="piece"></option>
</select>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import { Fragment } from 'react';
import { Clock3, Download, Upload } from 'lucide-react';
import { StockSummary, StockTransaction } from '@/types';
interface StockOverviewProps {
loading: boolean;
items: StockSummary[];
expandedMaterialId: number | null;
historyLoadingId: number | null;
histories: Record<number, StockTransaction[]>;
onOpenTransaction: (
materialId: number,
transactionType: StockTransaction['transaction_type']
) => void;
onToggleHistory: (materialId: number) => void;
}
export default function StockOverview({
loading,
items,
expandedMaterialId,
historyLoadingId,
histories,
onOpenTransaction,
onToggleHistory,
}: StockOverviewProps) {
if (loading) {
return <p className="text-sm text-gray-500">...</p>;
}
if (items.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white px-6 py-12 text-center text-gray-500">
</div>
);
}
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
<table className="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-right 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-right font-medium text-gray-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map((item) => {
const isExpanded = expandedMaterialId === item.material_id;
const history = histories[item.material_id] ?? [];
return (
<Fragment key={item.material_id}>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">
<div className="flex items-center gap-2">
<span>{item.name}</span>
{!item.is_active && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-gray-600">{item.material_type_display}</td>
<td className="px-4 py-3 text-gray-600">{item.maker || '-'}</td>
<td className="px-4 py-3 text-right">
<div className="font-semibold text-gray-900">
{item.current_stock}
{item.reserved_stock !== '0.000' && (
<span className="ml-1 text-xs font-normal text-amber-600">
{item.reserved_stock}
</span>
)}
</div>
<div className="text-xs text-gray-500">
{item.available_stock}
</div>
</td>
<td className="px-4 py-3 text-gray-600">{item.stock_unit_display}</td>
<td className="px-4 py-3 text-gray-600">
{item.last_transaction_date ?? '-'}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button
onClick={() => onOpenTransaction(item.material_id, 'purchase')}
className="inline-flex items-center gap-1 rounded-lg border border-emerald-300 px-2.5 py-1.5 text-xs text-emerald-700 transition hover:bg-emerald-50"
>
<Download className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onOpenTransaction(item.material_id, 'use')}
className="inline-flex items-center gap-1 rounded-lg border border-amber-300 px-2.5 py-1.5 text-xs text-amber-700 transition hover:bg-amber-50"
>
<Upload className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onToggleHistory(item.material_id)}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 transition hover:bg-gray-100"
>
<Clock3 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
{isExpanded && (
<tr className="bg-gray-50/70">
<td colSpan={7} className="px-4 py-4">
<div className="rounded-xl border border-gray-200 bg-white p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800">
{item.name}
</h3>
{historyLoadingId === item.material_id && (
<span className="text-xs text-gray-500">...</span>
)}
</div>
{history.length === 0 ? (
<p className="text-sm text-gray-500"></p>
) : (
<div className="space-y-2">
{history.map((transaction) => (
<div
key={transaction.id}
className="flex flex-col gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-700 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-center gap-3">
<span className="font-medium text-gray-900">
{transaction.transaction_type_display}
</span>
<span>
{transaction.quantity} {transaction.stock_unit_display}
</span>
<span className="text-gray-500">{transaction.occurred_on}</span>
</div>
<span className="text-gray-500">
{transaction.note || '備考なし'}
</span>
</div>
))}
</div>
)}
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, X } from 'lucide-react';
import { api } from '@/lib/api';
import { Material, StockTransaction } from '@/types';
type TransactionType = StockTransaction['transaction_type'];
interface StockTransactionFormProps {
isOpen: boolean;
materials: Material[];
presetMaterialId?: number | null;
presetTransactionType?: TransactionType | null;
onClose: () => void;
onSaved: () => Promise<void> | void;
}
const transactionOptions: { value: TransactionType; label: string }[] = [
{ value: 'purchase', label: '入庫' },
{ value: 'use', label: '使用' },
{ value: 'adjustment_plus', label: '棚卸増' },
{ value: 'adjustment_minus', label: '棚卸減' },
{ value: 'discard', label: '廃棄' },
];
const today = () => new Date().toISOString().slice(0, 10);
export default function StockTransactionForm({
isOpen,
materials,
presetMaterialId = null,
presetTransactionType = null,
onClose,
onSaved,
}: StockTransactionFormProps) {
const [materialId, setMaterialId] = useState<string>('');
const [transactionType, setTransactionType] = useState<TransactionType>('purchase');
const [quantity, setQuantity] = useState('');
const [occurredOn, setOccurredOn] = useState(today());
const [note, setNote] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) {
return;
}
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
setTransactionType(presetTransactionType ?? 'purchase');
setQuantity('');
setOccurredOn(today());
setNote('');
setError(null);
}, [isOpen, presetMaterialId, presetTransactionType]);
if (!isOpen) {
return null;
}
const handleSave = async () => {
setError(null);
if (!materialId) {
setError('資材を選択してください。');
return;
}
if (!quantity || Number(quantity) <= 0) {
setError('数量は0より大きい値を入力してください。');
return;
}
setSaving(true);
try {
await api.post('/materials/stock-transactions/', {
material: Number(materialId),
transaction_type: transactionType,
quantity,
occurred_on: occurredOn,
note,
});
await onSaved();
onClose();
} catch (e: unknown) {
const detail =
typeof e === 'object' &&
e !== null &&
'response' in e &&
typeof e.response === 'object' &&
e.response !== null &&
'data' in e.response
? JSON.stringify(e.response.data)
: '入出庫の登録に失敗しました。';
setError(detail);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 px-4">
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500"></p>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-6 py-5">
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700"></span>
<select
value={materialId}
onChange={(e) => setMaterialId(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
>
<option value=""></option>
{materials.map((material) => (
<option key={material.id} value={material.id}>
{material.name} ({material.material_type_display})
</option>
))}
</select>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700"></span>
<select
value={transactionType}
onChange={(e) => setTransactionType(e.target.value as TransactionType)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
>
{transactionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700"></span>
<input
type="number"
min="0"
step="0.001"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
placeholder="0.000"
/>
</label>
</div>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700"></span>
<input
type="date"
value={occurredOn}
onChange={(e) => setOccurredOn(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
/>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700"></span>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
placeholder="任意でメモを残せます"
/>
</label>
</div>
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
<button
onClick={onClose}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-100"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,590 @@
'use client';
import { useEffect, useState } from 'react';
import { ChevronLeft, Pencil, Plus, Trash2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import MaterialForm, {
MaterialFormState,
MaterialTab,
} from '../_components/MaterialForm';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Material } from '@/types';
const tabs: { key: MaterialTab; label: string }[] = [
{ key: 'fertilizer', label: '肥料' },
{ key: 'pesticide', label: '農薬' },
{ key: 'misc', label: 'その他' },
];
const emptyForm = (tab: MaterialTab): MaterialFormState => ({
name: '',
material_type:
tab === 'fertilizer' ? 'fertilizer' : tab === 'pesticide' ? 'pesticide' : 'other',
maker: '',
stock_unit: tab === 'fertilizer' ? 'bag' : tab === 'pesticide' ? 'bottle' : 'piece',
is_active: true,
notes: '',
fertilizer_profile: {
capacity_kg: '',
nitrogen_pct: '',
phosphorus_pct: '',
potassium_pct: '',
},
pesticide_profile: {
registration_no: '',
formulation: '',
usage_unit: '',
dilution_ratio: '',
active_ingredient: '',
category: '',
},
});
export default function MaterialMastersPage() {
const router = useRouter();
const [tab, setTab] = useState<MaterialTab>('fertilizer');
const [materials, setMaterials] = useState<Material[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
const [form, setForm] = useState<MaterialFormState>(emptyForm('fertilizer'));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchMaterials();
}, []);
useEffect(() => {
if (editingId === 'new') {
setForm(emptyForm(tab));
}
}, [tab, editingId]);
const fetchMaterials = async () => {
setLoading(true);
try {
const res = await api.get('/materials/materials/');
setMaterials(res.data);
} catch (e) {
console.error(e);
setError('資材マスタの取得に失敗しました。');
} finally {
setLoading(false);
}
};
const visibleMaterials = materials.filter((material) => {
if (tab === 'misc') {
return material.material_type === 'other' || material.material_type === 'seedling';
}
return material.material_type === tab;
});
const startNew = () => {
setError(null);
setForm(emptyForm(tab));
setEditingId('new');
};
const startEdit = (material: Material) => {
setError(null);
setForm({
name: material.name,
material_type: material.material_type,
maker: material.maker,
stock_unit: material.stock_unit,
is_active: material.is_active,
notes: material.notes,
fertilizer_profile: {
capacity_kg: material.fertilizer_profile?.capacity_kg ?? '',
nitrogen_pct: material.fertilizer_profile?.nitrogen_pct ?? '',
phosphorus_pct: material.fertilizer_profile?.phosphorus_pct ?? '',
potassium_pct: material.fertilizer_profile?.potassium_pct ?? '',
},
pesticide_profile: {
registration_no: material.pesticide_profile?.registration_no ?? '',
formulation: material.pesticide_profile?.formulation ?? '',
usage_unit: material.pesticide_profile?.usage_unit ?? '',
dilution_ratio: material.pesticide_profile?.dilution_ratio ?? '',
active_ingredient: material.pesticide_profile?.active_ingredient ?? '',
category: material.pesticide_profile?.category ?? '',
},
});
setEditingId(material.id);
};
const cancelEdit = () => {
setEditingId(null);
setForm(emptyForm(tab));
};
const handleSave = async () => {
setError(null);
if (!form.name.trim()) {
setError('資材名を入力してください。');
return;
}
setSaving(true);
try {
const payload = {
name: form.name,
material_type: form.material_type,
maker: form.maker,
stock_unit: form.stock_unit,
is_active: form.is_active,
notes: form.notes,
fertilizer_profile:
form.material_type === 'fertilizer'
? {
capacity_kg: form.fertilizer_profile.capacity_kg || null,
nitrogen_pct: form.fertilizer_profile.nitrogen_pct || null,
phosphorus_pct: form.fertilizer_profile.phosphorus_pct || null,
potassium_pct: form.fertilizer_profile.potassium_pct || null,
}
: undefined,
pesticide_profile:
form.material_type === 'pesticide'
? {
registration_no: form.pesticide_profile.registration_no,
formulation: form.pesticide_profile.formulation,
usage_unit: form.pesticide_profile.usage_unit,
dilution_ratio: form.pesticide_profile.dilution_ratio,
active_ingredient: form.pesticide_profile.active_ingredient,
category: form.pesticide_profile.category,
}
: undefined,
};
if (editingId === 'new') {
await api.post('/materials/materials/', payload);
} else {
await api.put(`/materials/materials/${editingId}/`, payload);
}
await fetchMaterials();
setEditingId(null);
setForm(emptyForm(tab));
} catch (e: unknown) {
console.error(e);
const detail =
typeof e === 'object' &&
e !== null &&
'response' in e &&
typeof e.response === 'object' &&
e.response !== null &&
'data' in e.response
? JSON.stringify(e.response.data)
: '保存に失敗しました。';
setError(detail);
} finally {
setSaving(false);
}
};
const handleDelete = async (material: Material) => {
setError(null);
try {
await api.delete(`/materials/materials/${material.id}/`);
await fetchMaterials();
} catch (e: unknown) {
console.error(e);
const detail =
typeof e === 'object' &&
e !== null &&
'response' in e &&
typeof e.response === 'object' &&
e.response !== null &&
'data' in e.response
? JSON.stringify(e.response.data)
: `${material.name}」の削除に失敗しました。`;
setError(detail);
}
};
const handleBaseFieldChange = (
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
value: string | boolean
) => {
setForm((prev) => ({
...prev,
[field]: value,
}));
};
const handleFertilizerFieldChange = (
field: keyof MaterialFormState['fertilizer_profile'],
value: string
) => {
setForm((prev) => ({
...prev,
fertilizer_profile: {
...prev.fertilizer_profile,
[field]: value,
},
}));
};
const handlePesticideFieldChange = (
field: keyof MaterialFormState['pesticide_profile'],
value: string
) => {
setForm((prev) => ({
...prev,
pesticide_profile: {
...prev.pesticide_profile,
[field]: value,
},
}));
};
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="mx-auto max-w-7xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/materials')}
className="text-gray-500 transition hover:text-gray-700"
>
<ChevronLeft className="h-5 w-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500"></p>
</div>
</div>
<button
onClick={startNew}
disabled={editingId !== null}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="mb-5 flex flex-wrap gap-2">
{tabs.map((item) => (
<button
key={item.key}
onClick={() => {
setTab(item.key);
setEditingId(null);
setForm(emptyForm(item.key));
}}
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
tab === item.key
? 'bg-green-600 text-white shadow-sm'
: 'bg-white text-gray-600 hover:bg-gray-100'
}`}
>
{item.label}
</button>
))}
</div>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
<span>{error}</span>
<button
onClick={() => setError(null)}
className="ml-auto text-red-400 transition hover:text-red-600"
>
×
</button>
</div>
)}
{loading ? (
<p className="text-sm text-gray-500">...</p>
) : (
<div className="overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm">
{tab === 'fertilizer' && (
<FertilizerTable
materials={visibleMaterials}
editingId={editingId}
form={form}
saving={saving}
onEdit={startEdit}
onDelete={handleDelete}
onBaseFieldChange={handleBaseFieldChange}
onFertilizerFieldChange={handleFertilizerFieldChange}
onPesticideFieldChange={handlePesticideFieldChange}
onSave={handleSave}
onCancel={cancelEdit}
/>
)}
{tab === 'pesticide' && (
<PesticideTable
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}
editingId={editingId}
form={form}
saving={saving}
onEdit={startEdit}
onDelete={handleDelete}
onBaseFieldChange={handleBaseFieldChange}
onFertilizerFieldChange={handleFertilizerFieldChange}
onPesticideFieldChange={handlePesticideFieldChange}
onSave={handleSave}
onCancel={cancelEdit}
/>
)}
</div>
)}
</div>
</div>
);
}
interface TableProps {
materials: Material[];
editingId: number | 'new' | null;
form: MaterialFormState;
saving: boolean;
onEdit: (material: Material) => void;
onDelete: (material: Material) => void;
onBaseFieldChange: (
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
value: string | boolean
) => void;
onFertilizerFieldChange: (
field: keyof MaterialFormState['fertilizer_profile'],
value: string
) => void;
onPesticideFieldChange: (
field: keyof MaterialFormState['pesticide_profile'],
value: string
) => void;
onSave: () => void;
onCancel: () => void;
}
function FertilizerTable(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-right font-medium text-gray-700">1(kg)</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-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="fertilizer" {...props} />}
{props.materials.map((material) =>
props.editingId === material.id ? (
<MaterialForm key={material.id} tab="fertilizer" {...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.maker || '-'}</td>
<td className="px-4 py-3 text-right text-gray-600">
{material.fertilizer_profile?.capacity_kg ?? '-'}
</td>
<td className="px-4 py-3 text-right text-gray-600">
{material.fertilizer_profile?.nitrogen_pct ?? '-'}
</td>
<td className="px-4 py-3 text-right text-gray-600">
{material.fertilizer_profile?.phosphorus_pct ?? '-'}
</td>
<td className="px-4 py-3 text-right text-gray-600">
{material.fertilizer_profile?.potassium_pct ?? '-'}
</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={10} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
);
}
function PesticideTable(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-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="pesticide" {...props} />}
{props.materials.map((material) =>
props.editingId === material.id ? (
<MaterialForm key={material.id} tab="pesticide" {...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.maker || '-'}</td>
<td className="px-4 py-3 text-gray-600">
{material.pesticide_profile?.registration_no || '-'}
</td>
<td className="px-4 py-3 text-gray-600">
{material.pesticide_profile?.formulation || '-'}
</td>
<td className="px-4 py-3 text-gray-600">
{material.pesticide_profile?.active_ingredient || '-'}
</td>
<td className="px-4 py-3 text-gray-600">
{material.pesticide_profile?.category || '-'}
</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={10} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
);
}
function MiscTable(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="misc" {...props} />}
{props.materials.map((material) =>
props.editingId === material.id ? (
<MaterialForm key={material.id} tab="misc" {...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,
onDelete,
}: {
disabled: boolean;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div className="flex items-center justify-end gap-2">
<button
onClick={onEdit}
disabled={disabled}
className="text-gray-400 transition hover:text-blue-600 disabled:opacity-30"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={onDelete}
disabled={disabled}
className="text-gray-400 transition hover:text-red-600 disabled:opacity-30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { useEffect, useState } from 'react';
import { Package, Plus, Settings2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import StockOverview from './_components/StockOverview';
import StockTransactionForm from './_components/StockTransactionForm';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Material, StockSummary, StockTransaction } from '@/types';
type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'misc';
const tabs: { key: FilterTab; label: string }[] = [
{ key: 'all', label: '全て' },
{ key: 'fertilizer', label: '肥料' },
{ key: 'pesticide', label: '農薬' },
{ key: 'misc', label: 'その他' },
];
export default function MaterialsPage() {
const router = useRouter();
const [tab, setTab] = useState<FilterTab>('all');
const [loading, setLoading] = useState(true);
const [materials, setMaterials] = useState<Material[]>([]);
const [summaries, setSummaries] = useState<StockSummary[]>([]);
const [histories, setHistories] = useState<Record<number, StockTransaction[]>>({});
const [expandedMaterialId, setExpandedMaterialId] = useState<number | null>(null);
const [historyLoadingId, setHistoryLoadingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [isTransactionOpen, setIsTransactionOpen] = useState(false);
const [presetMaterialId, setPresetMaterialId] = useState<number | null>(null);
const [presetTransactionType, setPresetTransactionType] =
useState<StockTransaction['transaction_type'] | null>(null);
useEffect(() => {
fetchInitialData();
}, []);
const fetchInitialData = async () => {
setLoading(true);
setError(null);
try {
const [materialsRes, summariesRes] = await Promise.all([
api.get('/materials/materials/'),
api.get('/materials/stock-summary/'),
]);
setMaterials(materialsRes.data);
setSummaries(summariesRes.data);
} catch (e) {
console.error(e);
setError('在庫データの取得に失敗しました。');
} finally {
setLoading(false);
}
};
const fetchSummaryOnly = async () => {
try {
const res = await api.get('/materials/stock-summary/');
setSummaries(res.data);
} catch (e) {
console.error(e);
setError('在庫一覧の更新に失敗しました。');
}
};
const handleToggleHistory = async (materialId: number) => {
if (expandedMaterialId === materialId) {
setExpandedMaterialId(null);
return;
}
setExpandedMaterialId(materialId);
if (histories[materialId]) {
return;
}
setHistoryLoadingId(materialId);
try {
const res = await api.get(`/materials/stock-transactions/?material_id=${materialId}`);
setHistories((prev) => ({ ...prev, [materialId]: res.data }));
} catch (e) {
console.error(e);
setError('履歴の取得に失敗しました。');
} finally {
setHistoryLoadingId(null);
}
};
const handleOpenTransaction = (
materialId: number | null,
transactionType: StockTransaction['transaction_type'] | null
) => {
setPresetMaterialId(materialId);
setPresetTransactionType(transactionType);
setIsTransactionOpen(true);
};
const handleSavedTransaction = async () => {
await fetchSummaryOnly();
if (expandedMaterialId !== null) {
try {
const res = await api.get(
`/materials/stock-transactions/?material_id=${expandedMaterialId}`
);
setHistories((prev) => ({ ...prev, [expandedMaterialId]: res.data }));
} catch (e) {
console.error(e);
}
}
};
const filteredSummaries = summaries.filter((summary) => {
if (tab === 'all') {
return true;
}
if (tab === 'misc') {
return summary.material_type === 'other' || summary.material_type === 'seedling';
}
return summary.material_type === tab;
});
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 flex-col gap-4 rounded-3xl bg-gradient-to-r from-emerald-600 via-green-600 to-lime-500 px-6 py-7 text-white shadow-lg sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-sm">
<Package className="h-4 w-4" />
Materials Inventory
</div>
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-emerald-50">
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={() => router.push('/materials/masters')}
className="inline-flex items-center gap-2 rounded-xl border border-white/30 bg-white/10 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/20"
>
<Settings2 className="h-4 w-4" />
</button>
<button
onClick={() => handleOpenTransaction(null, null)}
className="inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-sm font-medium text-green-700 transition hover:bg-green-50"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
<span>{error}</span>
<button
onClick={() => setError(null)}
className="ml-auto text-red-400 transition hover:text-red-600"
>
×
</button>
</div>
)}
<div className="mb-5 flex flex-wrap gap-2">
{tabs.map((item) => (
<button
key={item.key}
onClick={() => setTab(item.key)}
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
tab === item.key
? 'bg-green-600 text-white shadow-sm'
: 'bg-white text-gray-600 hover:bg-gray-100'
}`}
>
{item.label}
</button>
))}
</div>
<StockOverview
loading={loading}
items={filteredSummaries}
expandedMaterialId={expandedMaterialId}
historyLoadingId={historyLoadingId}
histories={histories}
onOpenTransaction={handleOpenTransaction}
onToggleHistory={handleToggleHistory}
/>
</div>
<StockTransactionForm
isOpen={isTransactionOpen}
materials={materials}
presetMaterialId={presetMaterialId}
presetTransactionType={presetTransactionType}
onClose={() => setIsTransactionOpen(false)}
onSaved={handleSavedTransaction}
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout, FlaskConical } from 'lucide-react';
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package } from 'lucide-react';
import { logout } from '@/lib/api';
export default function Navbar() {
@@ -133,6 +133,17 @@ export default function Navbar() {
<FlaskConical className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/materials')}
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
pathname?.startsWith('/materials')
? 'text-green-700 bg-green-50'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Package className="h-4 w-4 mr-2" />
</button>
</div>
</div>
<div className="flex items-center space-x-1">

View File

@@ -65,6 +65,72 @@ export interface Fertilizer {
phosphorus_pct: string | null;
potassium_pct: string | null;
notes: string | null;
material: number | null;
material_id: number | null;
}
export interface FertilizerProfile {
capacity_kg: string | null;
nitrogen_pct: string | null;
phosphorus_pct: string | null;
potassium_pct: string | null;
}
export interface PesticideProfile {
registration_no: string;
formulation: string;
usage_unit: string;
dilution_ratio: string;
active_ingredient: string;
category: string;
}
export interface Material {
id: number;
name: string;
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
material_type_display: string;
maker: string;
stock_unit: 'bag' | 'bottle' | 'kg' | 'liter' | 'piece';
stock_unit_display: string;
is_active: boolean;
notes: string;
fertilizer_profile: FertilizerProfile | null;
pesticide_profile: PesticideProfile | null;
current_stock: string;
created_at: string;
updated_at: string;
}
export interface StockTransaction {
id: number;
material: number;
material_name: string;
material_type: string;
transaction_type: 'purchase' | 'use' | 'reserve' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
transaction_type_display: string;
quantity: string;
stock_unit: string;
stock_unit_display: string;
occurred_on: string;
note: string;
fertilization_plan: number | null;
created_at: string;
}
export interface StockSummary {
material_id: number;
name: string;
material_type: 'fertilizer' | 'pesticide' | 'seedling' | 'other';
material_type_display: string;
maker: string;
stock_unit: string;
stock_unit_display: string;
is_active: boolean;
current_stock: string;
reserved_stock: string;
available_stock: string;
last_transaction_date: string | null;
}
export interface FertilizationEntry {
@@ -88,6 +154,8 @@ export interface FertilizationPlan {
entries: FertilizationEntry[];
field_count: number;
fertilizer_count: number;
is_confirmed: boolean;
confirmed_at: string | null;
created_at: string;
updated_at: string;
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff