完璧に動作しています。
テスト 結果 確定取消 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 — 編集画面ヘッダーに「確定取消」ボタン + 在庫情報再取得
This commit is contained in:
@@ -56,7 +56,14 @@
|
|||||||
"Bash(BASE=\"http://localhost/api/w/admins\")",
|
"Bash(BASE=\"http://localhost/api/w/admins\")",
|
||||||
"Bash(__NEW_LINE_ac825b6748572380__ curl -s -H \"Authorization: Bearer $TOKEN\" \"$BASE/variables/list\")",
|
"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(__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\")"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"C:\\Users\\akira\\AppData\\Local\\Temp"
|
"C:\\Users\\akira\\AppData\\Local\\Temp"
|
||||||
|
|||||||
@@ -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='散布確定日時'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -43,6 +43,8 @@ class FertilizationPlan(models.Model):
|
|||||||
related_name='fertilization_plans', verbose_name='品種'
|
related_name='fertilization_plans', verbose_name='品種'
|
||||||
)
|
)
|
||||||
calc_settings = models.JSONField(default=list, blank=True, 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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,25 @@ from .models import Fertilizer, FertilizationPlan, FertilizationEntry, Distribut
|
|||||||
|
|
||||||
|
|
||||||
class FertilizerSerializer(serializers.ModelSerializer):
|
class FertilizerSerializer(serializers.ModelSerializer):
|
||||||
|
material_id = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Fertilizer
|
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):
|
class FertilizationEntrySerializer(serializers.ModelSerializer):
|
||||||
@@ -26,12 +42,15 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
|
|||||||
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
entries = FertilizationEntrySerializer(many=True, read_only=True)
|
||||||
field_count = serializers.SerializerMethodField()
|
field_count = serializers.SerializerMethodField()
|
||||||
fertilizer_count = serializers.SerializerMethodField()
|
fertilizer_count = serializers.SerializerMethodField()
|
||||||
|
is_confirmed = serializers.BooleanField(read_only=True)
|
||||||
|
confirmed_at = serializers.DateTimeField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FertilizationPlan
|
model = FertilizationPlan
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
|
'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):
|
def get_variety_name(self, obj):
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ from django.http import HttpResponse
|
|||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
|
|
||||||
from apps.fields.models import Field
|
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 apps.plans.models import Plan, Variety
|
||||||
from .models import Fertilizer, FertilizationPlan, DistributionPlan
|
from .models import Fertilizer, FertilizationPlan, DistributionPlan
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
@@ -33,7 +40,7 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
|
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')
|
year = self.request.query_params.get('year')
|
||||||
if year:
|
if year:
|
||||||
@@ -45,6 +52,20 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
|||||||
return FertilizationPlanWriteSerializer
|
return FertilizationPlanWriteSerializer
|
||||||
return FertilizationPlanSerializer
|
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'])
|
@action(detail=True, methods=['get'])
|
||||||
def pdf(self, request, pk=None):
|
def pdf(self, request, pk=None):
|
||||||
plan = self.get_object()
|
plan = self.get_object()
|
||||||
@@ -99,6 +120,67 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
|
|||||||
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
|
||||||
return response
|
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):
|
class CandidateFieldsView(APIView):
|
||||||
"""作付け計画から圃場候補を返す"""
|
"""作付け計画から圃場候補を返す"""
|
||||||
|
|||||||
@@ -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='施肥計画',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -162,6 +162,7 @@ class StockTransaction(models.Model):
|
|||||||
class TransactionType(models.TextChoices):
|
class TransactionType(models.TextChoices):
|
||||||
PURCHASE = 'purchase', '入庫'
|
PURCHASE = 'purchase', '入庫'
|
||||||
USE = 'use', '使用'
|
USE = 'use', '使用'
|
||||||
|
RESERVE = 'reserve', '引当'
|
||||||
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
|
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
|
||||||
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
|
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
|
||||||
DISCARD = 'discard', '廃棄'
|
DISCARD = 'discard', '廃棄'
|
||||||
@@ -172,6 +173,7 @@ class StockTransaction(models.Model):
|
|||||||
}
|
}
|
||||||
DECREASE_TYPES = {
|
DECREASE_TYPES = {
|
||||||
TransactionType.USE,
|
TransactionType.USE,
|
||||||
|
TransactionType.RESERVE,
|
||||||
TransactionType.ADJUSTMENT_MINUS,
|
TransactionType.ADJUSTMENT_MINUS,
|
||||||
TransactionType.DISCARD,
|
TransactionType.DISCARD,
|
||||||
}
|
}
|
||||||
@@ -195,6 +197,14 @@ class StockTransaction(models.Model):
|
|||||||
)
|
)
|
||||||
occurred_on = models.DateField(verbose_name='発生日')
|
occurred_on = models.DateField(verbose_name='発生日')
|
||||||
note = models.TextField(blank=True, default='', 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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ class StockTransactionSerializer(serializers.ModelSerializer):
|
|||||||
'stock_unit_display',
|
'stock_unit_display',
|
||||||
'occurred_on',
|
'occurred_on',
|
||||||
'note',
|
'note',
|
||||||
|
'fertilization_plan',
|
||||||
'created_at',
|
'created_at',
|
||||||
]
|
]
|
||||||
read_only_fields = ['created_at']
|
read_only_fields = ['created_at']
|
||||||
@@ -209,4 +210,6 @@ class StockSummarySerializer(serializers.Serializer):
|
|||||||
stock_unit_display = serializers.CharField()
|
stock_unit_display = serializers.CharField()
|
||||||
is_active = serializers.BooleanField()
|
is_active = serializers.BooleanField()
|
||||||
current_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
|
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)
|
last_transaction_date = serializers.DateField(allow_null=True)
|
||||||
|
|||||||
100
backend/apps/materials/stock_service.py
Normal file
100
backend/apps/materials/stock_service.py
Normal 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')
|
||||||
@@ -14,4 +14,5 @@ router.register(
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'),
|
path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'),
|
||||||
|
path('fertilizer-stock/', views.FertilizerStockView.as_view(), name='fertilizer-stock'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -103,32 +103,62 @@ class StockSummaryView(generics.ListAPIView):
|
|||||||
|
|
||||||
results = []
|
results = []
|
||||||
for material in queryset:
|
for material in queryset:
|
||||||
transactions = list(material.stock_transactions.all())
|
results.append(_build_stock_summary(material))
|
||||||
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
|
|
||||||
)
|
|
||||||
last_date = max((txn.occurred_on for txn in transactions), default=None)
|
|
||||||
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': increase - decrease if transactions else Decimal('0'),
|
|
||||||
'last_transaction_date': last_date,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(results, many=True)
|
serializer = self.get_serializer(results, many=True)
|
||||||
return Response(serializer.data)
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
'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}`;
|
||||||
|
|
||||||
|
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 groupedEntries = useMemo(() => {
|
||||||
|
if (!plan) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = new Map<
|
||||||
|
number,
|
||||||
|
{ fertilizerId: number; fertilizerName: string; entries: FertilizationPlan['entries'] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
plan.entries.forEach((entry) => {
|
||||||
|
const existing = groups.get(entry.fertilizer);
|
||||||
|
if (existing) {
|
||||||
|
existing.entries.push(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
groups.set(entry.fertilizer, {
|
||||||
|
fertilizerId: entry.fertilizer,
|
||||||
|
fertilizerName: entry.fertilizer_name ?? `肥料ID:${entry.fertilizer}`,
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(groups.values()).sort((a, b) =>
|
||||||
|
a.fertilizerName.localeCompare(b.fertilizerName, 'ja')
|
||||||
|
);
|
||||||
|
}, [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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 px-4">
|
||||||
|
<div className="max-h-[90vh] w-full max-w-4xl 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">
|
||||||
|
実績数量を確認して、一括で reserve から use へ変換します。
|
||||||
|
</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(90vh-144px)] overflow-y-auto 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="space-y-6">
|
||||||
|
{groupedEntries.map((group) => (
|
||||||
|
<section key={group.fertilizerId}>
|
||||||
|
<h3 className="mb-3 text-base font-semibold text-gray-800">
|
||||||
|
肥料: {group.fertilizerName}
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200">
|
||||||
|
<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-right 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">
|
||||||
|
{group.entries.map((entry) => {
|
||||||
|
const key = entryKey(entry.field, entry.fertilizer);
|
||||||
|
return (
|
||||||
|
<tr key={key}>
|
||||||
|
<td className="px-4 py-3 text-gray-800">
|
||||||
|
{entry.field_name ?? `圃場ID:${entry.field}`}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">
|
||||||
|
{entry.bags}袋
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={actuals[key] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setActuals((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-24 rounded-lg border border-gray-300 px-3 py-2 text-right text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 Navbar from '@/components/Navbar';
|
||||||
import { api } from '@/lib/api';
|
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';
|
type CalcMethod = 'per_tan' | 'even' | 'nitrogen';
|
||||||
|
|
||||||
@@ -63,6 +63,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
|
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
|
||||||
const [adjusted, setAdjusted] = useState<Matrix>({});
|
const [adjusted, setAdjusted] = useState<Matrix>({});
|
||||||
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
|
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 [loading, setLoading] = useState(!isNew);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -71,15 +75,26 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
// ─── 初期データ取得
|
// ─── 初期データ取得
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
const [cropsRes, fertsRes, fieldsRes] = await Promise.all([
|
const [cropsRes, fertsRes, fieldsRes, stockRes] = await Promise.all([
|
||||||
api.get('/plans/crops/'),
|
api.get('/plans/crops/'),
|
||||||
api.get('/fertilizer/fertilizers/'),
|
api.get('/fertilizer/fertilizers/'),
|
||||||
api.get('/fields/?ordering=display_order,id'),
|
api.get('/fields/?ordering=display_order,id'),
|
||||||
|
api.get('/materials/fertilizer-stock/'),
|
||||||
]);
|
]);
|
||||||
setCrops(cropsRes.data);
|
setCrops(cropsRes.data);
|
||||||
setAllFertilizers(fertsRes.data);
|
setAllFertilizers(fertsRes.data);
|
||||||
setAllFields(fieldsRes.data);
|
setAllFields(fieldsRes.data);
|
||||||
|
setStockByMaterialId(
|
||||||
|
stockRes.data.reduce(
|
||||||
|
(acc: Record<number, StockSummary>, summary: StockSummary) => {
|
||||||
|
acc[summary.material_id] = summary;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (!isNew && planId) {
|
if (!isNew && planId) {
|
||||||
const planRes = await api.get(`/fertilizer/plans/${planId}/`);
|
const planRes = await api.get(`/fertilizer/plans/${planId}/`);
|
||||||
@@ -87,6 +102,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
setName(plan.name);
|
setName(plan.name);
|
||||||
setYear(plan.year);
|
setYear(plan.year);
|
||||||
setVarietyId(plan.variety);
|
setVarietyId(plan.variety);
|
||||||
|
setIsConfirmed(plan.is_confirmed);
|
||||||
|
setConfirmedAt(plan.confirmed_at);
|
||||||
|
|
||||||
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
|
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
|
||||||
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
|
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);
|
newAdjusted[e.field][e.fertilizer] = String(e.bags);
|
||||||
});
|
});
|
||||||
setAdjusted(newAdjusted);
|
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 を生成
|
// 保存済み calc_settings でページ開時に自動計算してラベル用 calcMatrix を生成
|
||||||
const validSettings = plan.calc_settings?.filter((s) => s.param) ?? [];
|
const validSettings = plan.calc_settings?.filter((s) => s.param) ?? [];
|
||||||
@@ -142,7 +165,9 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { status?: number; data?: unknown } };
|
const err = e as { response?: { status?: number; data?: unknown } };
|
||||||
console.error('初期データ取得エラー:', err);
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -170,6 +195,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
|
|
||||||
// ─── 肥料追加・削除
|
// ─── 肥料追加・削除
|
||||||
const addFertilizer = (fert: Fertilizer) => {
|
const addFertilizer = (fert: Fertilizer) => {
|
||||||
|
if (isConfirmed) return;
|
||||||
if (planFertilizers.find((f) => f.id === fert.id)) return;
|
if (planFertilizers.find((f) => f.id === fert.id)) return;
|
||||||
setPlanFertilizers((prev) => [...prev, fert]);
|
setPlanFertilizers((prev) => [...prev, fert]);
|
||||||
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
|
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) => {
|
const removeFertilizer = (id: number) => {
|
||||||
|
if (isConfirmed) return;
|
||||||
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
|
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
|
||||||
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
|
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
|
||||||
const dropCol = (m: Matrix): Matrix => {
|
const dropCol = (m: Matrix): Matrix => {
|
||||||
@@ -195,12 +222,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
|
|
||||||
// ─── 圃場追加・削除
|
// ─── 圃場追加・削除
|
||||||
const addField = (field: Field) => {
|
const addField = (field: Field) => {
|
||||||
|
if (isConfirmed) return;
|
||||||
if (selectedFields.find((f) => f.id === field.id)) return;
|
if (selectedFields.find((f) => f.id === field.id)) return;
|
||||||
setSelectedFields((prev) => [...prev, field]);
|
setSelectedFields((prev) => [...prev, field]);
|
||||||
setShowFieldPicker(false);
|
setShowFieldPicker(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeField = (id: number) => {
|
const removeField = (id: number) => {
|
||||||
|
if (isConfirmed) return;
|
||||||
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
|
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
|
||||||
setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
||||||
setAdjusted((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) => {
|
const runCalc = async (setting: CalcSetting) => {
|
||||||
if (!setting.param) return alert('パラメータを入力してください');
|
if (isConfirmed) return;
|
||||||
if (selectedFields.length === 0) return alert('対象圃場を選択してください');
|
if (!setting.param) {
|
||||||
|
setSaveError('パラメータを入力してください');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedFields.length === 0) {
|
||||||
|
setSaveError('対象圃場を選択してください');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveError(null);
|
||||||
|
|
||||||
const targetFields = calcNewOnly
|
const targetFields = calcNewOnly
|
||||||
? selectedFields.filter((f) => {
|
? selectedFields.filter((f) => {
|
||||||
@@ -222,7 +259,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
})
|
})
|
||||||
: selectedFields;
|
: selectedFields;
|
||||||
|
|
||||||
if (targetFields.length === 0) return alert('未入力の圃場がありません。「全圃場」で実行してください。');
|
if (targetFields.length === 0) {
|
||||||
|
setSaveError('未入力の圃場がありません。「全圃場」で実行してください。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/fertilizer/calculate/', {
|
const res = await api.post('/fertilizer/calculate/', {
|
||||||
@@ -246,7 +286,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
// adjusted は保持する(テキストボックスにDB/確定値を維持し、ラベルに計算結果を表示)
|
// adjusted は保持する(テキストボックスにDB/確定値を維持し、ラベルに計算結果を表示)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
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 を更新)
|
// ─── セル更新(adjusted を更新)
|
||||||
const updateCell = (fieldId: number, fertId: number, value: string) => {
|
const updateCell = (fieldId: number, fertId: number, value: string) => {
|
||||||
|
if (isConfirmed) return;
|
||||||
setAdjusted((prev) => {
|
setAdjusted((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
if (!next[fieldId]) next[fieldId] = {};
|
if (!next[fieldId]) next[fieldId] = {};
|
||||||
@@ -268,6 +309,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
|
|
||||||
// ─── 列単位で四捨五入 / 元に戻す(トグル)
|
// ─── 列単位で四捨五入 / 元に戻す(トグル)
|
||||||
const roundColumn = (fertId: number) => {
|
const roundColumn = (fertId: number) => {
|
||||||
|
if (isConfirmed) return;
|
||||||
if (roundedColumns.has(fertId)) {
|
if (roundedColumns.has(fertId)) {
|
||||||
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
|
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
|
||||||
setAdjusted((prev) => {
|
setAdjusted((prev) => {
|
||||||
@@ -318,10 +360,30 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
selectedFields.reduce((sum, f) => sum + effectiveValue(f.id, fertId), 0);
|
selectedFields.reduce((sum, f) => sum + effectiveValue(f.id, fertId), 0);
|
||||||
|
|
||||||
const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 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 値を使用)
|
// ─── 保存(adjusted 優先、なければ calc 値を使用)
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
if (isConfirmed) {
|
||||||
|
setSaveError('確定済みの施肥計画は編集できません。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
|
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
|
||||||
if (!varietyId) { setSaveError('品種を選択してください'); return; }
|
if (!varietyId) { setSaveError('品種を選択してください'); return; }
|
||||||
if (selectedFields.length === 0) { setSaveError('圃場を1つ以上選択してください'); 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出力
|
// ─── PDF出力
|
||||||
const handlePdf = async () => {
|
const handlePdf = async () => {
|
||||||
if (!planId) return;
|
if (!planId) return;
|
||||||
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/fertilizer/plans/${planId}/pdf/`, { responseType: 'blob' });
|
const res = await api.get(`/fertilizer/plans/${planId}/pdf/`, { responseType: 'blob' });
|
||||||
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||||
@@ -374,7 +462,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('PDF出力に失敗しました');
|
console.error(e);
|
||||||
|
setSaveError('PDF出力に失敗しました');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -406,6 +495,15 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{!isNew && (
|
||||||
<button
|
<button
|
||||||
onClick={handlePdf}
|
onClick={handlePdf}
|
||||||
@@ -417,11 +515,11 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
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"
|
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" />
|
<Save className="h-4 w-4" />
|
||||||
{saving ? '保存中...' : '保存'}
|
{isConfirmed ? '確定済み' : saving ? '保存中...' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,6 +533,16 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
</div>
|
</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="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||||||
<div className="flex-1 min-w-48">
|
<div className="flex-1 min-w-48">
|
||||||
@@ -444,6 +552,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="例: 2025年度 コシヒカリ 元肥"
|
placeholder="例: 2025年度 コシヒカリ 元肥"
|
||||||
|
disabled={isConfirmed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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"
|
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}
|
value={year}
|
||||||
onChange={(e) => setYear(parseInt(e.target.value))}
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||||
|
disabled={isConfirmed}
|
||||||
>
|
>
|
||||||
{years.map((y) => <option key={y} value={y}>{y}年度</option>)}
|
{years.map((y) => <option key={y} value={y}>{y}年度</option>)}
|
||||||
</select>
|
</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"
|
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}
|
value={varietyId}
|
||||||
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
|
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
|
||||||
|
disabled={isConfirmed}
|
||||||
>
|
>
|
||||||
<option value="">品種を選択</option>
|
<option value="">品種を選択</option>
|
||||||
{crops.map((crop) => (
|
{crops.map((crop) => (
|
||||||
@@ -487,7 +598,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFieldPicker(true)}
|
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" />圃場を追加
|
<Plus className="h-3 w-3" />圃場を追加
|
||||||
</button>
|
</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"
|
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}反)
|
{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" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -522,13 +638,15 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={calcNewOnly}
|
checked={calcNewOnly}
|
||||||
onChange={(e) => setCalcNewOnly(e.target.checked)}
|
onChange={(e) => setCalcNewOnly(e.target.checked)}
|
||||||
|
disabled={isConfirmed}
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
未入力圃場のみ
|
未入力圃場のみ
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFertPicker(true)}
|
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" />肥料を追加
|
<Plus className="h-3 w-3" />肥料を追加
|
||||||
</button>
|
</button>
|
||||||
@@ -550,6 +668,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||||
value={setting.method}
|
value={setting.method}
|
||||||
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
|
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
|
||||||
|
disabled={isConfirmed}
|
||||||
>
|
>
|
||||||
{Object.entries(METHOD_LABELS).map(([k, v]) => (
|
{Object.entries(METHOD_LABELS).map(([k, v]) => (
|
||||||
<option key={k} value={k}>{v}</option>
|
<option key={k} value={k}>{v}</option>
|
||||||
@@ -562,17 +681,20 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
value={setting.param}
|
value={setting.param}
|
||||||
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
|
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
|
||||||
placeholder="値"
|
placeholder="値"
|
||||||
|
disabled={isConfirmed}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
|
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => runCalc(setting)}
|
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" />計算
|
<Calculator className="h-3 w-3" />計算
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeFertilizer(fert.id)}
|
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" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</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>
|
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">面積(反)</th>
|
||||||
{planFertilizers.map((f) => {
|
{planFertilizers.map((f) => {
|
||||||
const isRounded = roundedColumns.has(f.id);
|
const isRounded = roundedColumns.has(f.id);
|
||||||
|
const stock = getStockInfo(f);
|
||||||
|
const planAvailable = getPlanAvailableStock(f);
|
||||||
|
const shortage = getPlanShortage(f);
|
||||||
return (
|
return (
|
||||||
<th key={f.id} className="text-center px-3 py-2 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">
|
<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">
|
<span className="flex items-center justify-center gap-1.5 text-xs font-normal text-gray-400 mt-0.5">
|
||||||
(袋)
|
(袋)
|
||||||
<button
|
<button
|
||||||
onClick={() => roundColumn(f.id)}
|
onClick={() => roundColumn(f.id)}
|
||||||
|
disabled={isConfirmed}
|
||||||
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
|
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
|
||||||
isRounded
|
isRounded
|
||||||
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
|
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
|
||||||
: 'bg-blue-100 text-blue-500 hover:bg-blue-200'
|
: 'bg-blue-100 text-blue-500 hover:bg-blue-200'
|
||||||
}`}
|
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||||
title={isRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
|
title={isRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
|
||||||
>
|
>
|
||||||
{isRounded ? '↩' : '≈'}
|
{isRounded ? '↩' : '≈'}
|
||||||
@@ -641,6 +789,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
|
||||||
placeholder="-"
|
placeholder="-"
|
||||||
|
disabled={isConfirmed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -689,7 +838,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
onClick={() => addField(f)}
|
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>{f.name}</span>
|
||||||
<span className="text-gray-400">{f.area_tan}反</span>
|
<span className="text-gray-400">{f.area_tan}反</span>
|
||||||
@@ -703,7 +853,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
onClick={() => addField(f)}
|
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>{f.name}</span>
|
||||||
<span className="text-gray-400">{f.area_tan}反</span>
|
<span className="text-gray-400">{f.area_tan}反</span>
|
||||||
@@ -730,7 +881,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
|
|||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
onClick={() => addFertilizer(f)}
|
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>
|
<span className="font-medium">{f.name}</span>
|
||||||
{f.maker && <span className="ml-2 text-gray-400 text-xs">{f.maker}</span>}
|
{f.maker && <span className="ml-2 text-gray-400 text-xs">{f.maker}</span>}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const emptyForm = (): Omit<Fertilizer, 'id'> => ({
|
|||||||
phosphorus_pct: null,
|
phosphorus_pct: null,
|
||||||
potassium_pct: null,
|
potassium_pct: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
|
material: null,
|
||||||
|
material_id: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function FertilizerMastersPage() {
|
export default function FertilizerMastersPage() {
|
||||||
@@ -55,6 +57,8 @@ export default function FertilizerMastersPage() {
|
|||||||
phosphorus_pct: f.phosphorus_pct,
|
phosphorus_pct: f.phosphorus_pct,
|
||||||
potassium_pct: f.potassium_pct,
|
potassium_pct: f.potassium_pct,
|
||||||
notes: f.notes,
|
notes: f.notes,
|
||||||
|
material: f.material,
|
||||||
|
material_id: f.material_id,
|
||||||
});
|
});
|
||||||
setEditingId(f.id);
|
setEditingId(f.id);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 Navbar from '@/components/Navbar';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { FertilizationPlan } from '@/types';
|
import { FertilizationPlan } from '@/types';
|
||||||
@@ -21,6 +23,8 @@ export default function FertilizerPage() {
|
|||||||
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const [confirmTarget, setConfirmTarget] = useState<FertilizationPlan | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('fertilizerYear', String(year));
|
localStorage.setItem('fertilizerYear', String(year));
|
||||||
@@ -41,6 +45,7 @@ export default function FertilizerPage() {
|
|||||||
|
|
||||||
const handleDelete = async (id: number, name: string) => {
|
const handleDelete = async (id: number, name: string) => {
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
await api.delete(`/fertilizer/plans/${id}/`);
|
await api.delete(`/fertilizer/plans/${id}/`);
|
||||||
await fetchPlans();
|
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) => {
|
const handlePdf = async (id: number, name: string) => {
|
||||||
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
|
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
|
||||||
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
|
||||||
@@ -61,7 +78,7 @@ export default function FertilizerPage() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
alert('PDF出力に失敗しました');
|
setActionError('PDF出力に失敗しました');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,6 +132,14 @@ export default function FertilizerPage() {
|
|||||||
</div>
|
</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 ? (
|
{loading ? (
|
||||||
<p className="text-gray-500">読み込み中...</p>
|
<p className="text-gray-500">読み込み中...</p>
|
||||||
) : plans.length === 0 ? (
|
) : plans.length === 0 ? (
|
||||||
@@ -135,6 +160,7 @@ export default function FertilizerPage() {
|
|||||||
<tr>
|
<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-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="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>
|
<th className="px-4 py-3"></th>
|
||||||
@@ -142,15 +168,52 @@ export default function FertilizerPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<tr key={plan.id} className="hover:bg-gray-50">
|
<tr
|
||||||
<td className="px-4 py-3 font-medium">{plan.name}</td>
|
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">
|
<td className="px-4 py-3 text-gray-600">
|
||||||
{plan.crop_name} / {plan.variety_name}
|
{plan.crop_name} / {plan.variety_name}
|
||||||
</td>
|
</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.field_count}筆</td>
|
||||||
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}種</td>
|
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}種</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<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
|
<button
|
||||||
onClick={() => handlePdf(plan.id, plan.name)}
|
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"
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmSpreadingModal
|
||||||
|
isOpen={confirmTarget !== null}
|
||||||
|
plan={confirmTarget}
|
||||||
|
onClose={() => setConfirmTarget(null)}
|
||||||
|
onConfirmed={fetchPlans}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,18 @@ export default function StockOverview({
|
|||||||
</td>
|
</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.material_type_display}</td>
|
||||||
<td className="px-4 py-3 text-gray-600">{item.maker || '-'}</td>
|
<td className="px-4 py-3 text-gray-600">{item.maker || '-'}</td>
|
||||||
<td className="px-4 py-3 text-right font-semibold text-gray-900">
|
<td className="px-4 py-3 text-right">
|
||||||
{item.current_stock}
|
<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>
|
||||||
<td className="px-4 py-3 text-gray-600">{item.stock_unit_display}</td>
|
<td className="px-4 py-3 text-gray-600">{item.stock_unit_display}</td>
|
||||||
<td className="px-4 py-3 text-gray-600">
|
<td className="px-4 py-3 text-gray-600">
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export interface Fertilizer {
|
|||||||
phosphorus_pct: string | null;
|
phosphorus_pct: string | null;
|
||||||
potassium_pct: string | null;
|
potassium_pct: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
material: number | null;
|
||||||
|
material_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FertilizerProfile {
|
export interface FertilizerProfile {
|
||||||
@@ -105,13 +107,14 @@ export interface StockTransaction {
|
|||||||
material: number;
|
material: number;
|
||||||
material_name: string;
|
material_name: string;
|
||||||
material_type: string;
|
material_type: string;
|
||||||
transaction_type: 'purchase' | 'use' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
|
transaction_type: 'purchase' | 'use' | 'reserve' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
|
||||||
transaction_type_display: string;
|
transaction_type_display: string;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
stock_unit: string;
|
stock_unit: string;
|
||||||
stock_unit_display: string;
|
stock_unit_display: string;
|
||||||
occurred_on: string;
|
occurred_on: string;
|
||||||
note: string;
|
note: string;
|
||||||
|
fertilization_plan: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +128,8 @@ export interface StockSummary {
|
|||||||
stock_unit_display: string;
|
stock_unit_display: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
current_stock: string;
|
current_stock: string;
|
||||||
|
reserved_stock: string;
|
||||||
|
available_stock: string;
|
||||||
last_transaction_date: string | null;
|
last_transaction_date: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +154,8 @@ export interface FertilizationPlan {
|
|||||||
entries: FertilizationEntry[];
|
entries: FertilizationEntry[];
|
||||||
field_count: number;
|
field_count: number;
|
||||||
fertilizer_count: number;
|
fertilizer_count: number;
|
||||||
|
is_confirmed: boolean;
|
||||||
|
confirmed_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user