在庫管理機能 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>
This commit is contained in:
@@ -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),
|
||||||
|
]
|
||||||
@@ -17,6 +17,14 @@ class Fertilizer(models.Model):
|
|||||||
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)'
|
max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='カリ含有率(%)'
|
||||||
)
|
)
|
||||||
notes = models.TextField(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:
|
class Meta:
|
||||||
verbose_name = '肥料マスタ'
|
verbose_name = '肥料マスタ'
|
||||||
|
|||||||
1
backend/apps/materials/__init__.py
Normal file
1
backend/apps/materials/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
28
backend/apps/materials/admin.py
Normal file
28
backend/apps/materials/admin.py
Normal 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']
|
||||||
8
backend/apps/materials/apps.py
Normal file
8
backend/apps/materials/apps.py
Normal 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 = '資材管理'
|
||||||
|
|
||||||
87
backend/apps/materials/migrations/0001_initial.py
Normal file
87
backend/apps/materials/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/apps/materials/migrations/__init__.py
Normal file
1
backend/apps/materials/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
210
backend/apps/materials/models.py
Normal file
210
backend/apps/materials/models.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
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', '使用'
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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='備考')
|
||||||
|
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}'
|
||||||
|
)
|
||||||
212
backend/apps/materials/serializers.py
Normal file
212
backend/apps/materials/serializers.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
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',
|
||||||
|
'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)
|
||||||
|
last_transaction_date = serializers.DateField(allow_null=True)
|
||||||
17
backend/apps/materials/urls.py
Normal file
17
backend/apps/materials/urls.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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'),
|
||||||
|
]
|
||||||
134
backend/apps/materials/views.py
Normal file
134
backend/apps/materials/views.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
return Response(serializer.data)
|
||||||
@@ -44,6 +44,7 @@ INSTALLED_APPS = [
|
|||||||
'apps.mail',
|
'apps.mail',
|
||||||
'apps.weather',
|
'apps.weather',
|
||||||
'apps.fertilizer',
|
'apps.fertilizer',
|
||||||
|
'apps.materials',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -58,4 +58,5 @@ urlpatterns = [
|
|||||||
path('api/mail/', include('apps.mail.urls')),
|
path('api/mail/', include('apps.mail.urls')),
|
||||||
path('api/weather/', include('apps.weather.urls')),
|
path('api/weather/', include('apps.weather.urls')),
|
||||||
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
||||||
|
path('api/materials/', include('apps.materials.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
341
frontend/src/app/materials/_components/MaterialForm.tsx
Normal file
341
frontend/src/app/materials/_components/MaterialForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/app/materials/_components/StockOverview.tsx
Normal file
157
frontend/src/app/materials/_components/StockOverview.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
'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 font-semibold text-gray-900">
|
||||||
|
{item.current_stock}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
frontend/src/app/materials/_components/StockTransactionForm.tsx
Normal file
211
frontend/src/app/materials/_components/StockTransactionForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
590
frontend/src/app/materials/masters/page.tsx
Normal file
590
frontend/src/app/materials/masters/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
frontend/src/app/materials/page.tsx
Normal file
208
frontend/src/app/materials/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, 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';
|
import { logout } from '@/lib/api';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -133,6 +133,17 @@ export default function Navbar() {
|
|||||||
<FlaskConical className="h-4 w-4 mr-2" />
|
<FlaskConical className="h-4 w-4 mr-2" />
|
||||||
分配計画
|
分配計画
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
|
|||||||
@@ -67,6 +67,67 @@ export interface Fertilizer {
|
|||||||
notes: string | null;
|
notes: string | 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' | 'adjustment_plus' | 'adjustment_minus' | 'discard';
|
||||||
|
transaction_type_display: string;
|
||||||
|
quantity: string;
|
||||||
|
stock_unit: string;
|
||||||
|
stock_unit_display: string;
|
||||||
|
occurred_on: string;
|
||||||
|
note: string;
|
||||||
|
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;
|
||||||
|
last_transaction_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FertilizationEntry {
|
export interface FertilizationEntry {
|
||||||
id?: number;
|
id?: number;
|
||||||
field: number;
|
field: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user