feat: add levee work records
This commit is contained in:
1
backend/apps/levee_work/__init__.py
Normal file
1
backend/apps/levee_work/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
17
backend/apps/levee_work/admin.py
Normal file
17
backend/apps/levee_work/admin.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||||
|
|
||||||
|
|
||||||
|
class LeveeWorkSessionItemInline(admin.TabularInline):
|
||||||
|
model = LeveeWorkSessionItem
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(LeveeWorkSession)
|
||||||
|
class LeveeWorkSessionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['date', 'title', 'year', 'created_at']
|
||||||
|
list_filter = ['year', 'date']
|
||||||
|
search_fields = ['title', 'items__field__name']
|
||||||
|
inlines = [LeveeWorkSessionItemInline]
|
||||||
|
|
||||||
8
backend/apps/levee_work/apps.py
Normal file
8
backend/apps/levee_work/apps.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class LeveeWorkConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.levee_work'
|
||||||
|
verbose_name = '畔塗作業'
|
||||||
|
|
||||||
54
backend/apps/levee_work/migrations/0001_initial.py
Normal file
54
backend/apps/levee_work/migrations/0001_initial.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 5.2 on 2026-04-04
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fields', '0006_e1c_chusankan_17_fields'),
|
||||||
|
('plans', '0004_crop_base_temp'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LeveeWorkSession',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('year', models.IntegerField(verbose_name='年度')),
|
||||||
|
('date', models.DateField(verbose_name='畔塗日')),
|
||||||
|
('title', models.CharField(default='水稲畔塗', max_length=100, 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': ['-date', '-id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LeveeWorkSessionItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('crop_name_snapshot', models.CharField(max_length=100, verbose_name='作物名スナップショット')),
|
||||||
|
('variety_name_snapshot', models.CharField(blank=True, default='', max_length=100, verbose_name='品種名スナップショット')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
|
||||||
|
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='plans.plan', verbose_name='作付け計画')),
|
||||||
|
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='levee_work.leveeworksession', verbose_name='畔塗記録')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '畔塗対象圃場',
|
||||||
|
'verbose_name_plural': '畔塗対象圃場',
|
||||||
|
'ordering': ['field__display_order', 'field__id'],
|
||||||
|
'unique_together': {('session', 'field')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
1
backend/apps/levee_work/migrations/__init__.py
Normal file
1
backend/apps/levee_work/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
59
backend/apps/levee_work/models.py
Normal file
59
backend/apps/levee_work/models.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class LeveeWorkSession(models.Model):
|
||||||
|
year = models.IntegerField(verbose_name='年度')
|
||||||
|
date = models.DateField(verbose_name='畔塗日')
|
||||||
|
title = models.CharField(max_length=100, default='水稲畔塗', 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:
|
||||||
|
verbose_name = '畔塗記録'
|
||||||
|
verbose_name_plural = '畔塗記録'
|
||||||
|
ordering = ['-date', '-id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.date} {self.title}'
|
||||||
|
|
||||||
|
|
||||||
|
class LeveeWorkSessionItem(models.Model):
|
||||||
|
session = models.ForeignKey(
|
||||||
|
LeveeWorkSession,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='items',
|
||||||
|
verbose_name='畔塗記録',
|
||||||
|
)
|
||||||
|
field = models.ForeignKey(
|
||||||
|
'fields.Field',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
verbose_name='圃場',
|
||||||
|
)
|
||||||
|
plan = models.ForeignKey(
|
||||||
|
'plans.Plan',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='+',
|
||||||
|
verbose_name='作付け計画',
|
||||||
|
)
|
||||||
|
crop_name_snapshot = models.CharField(max_length=100, verbose_name='作物名スナップショット')
|
||||||
|
variety_name_snapshot = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
verbose_name='品種名スナップショット',
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '畔塗対象圃場'
|
||||||
|
verbose_name_plural = '畔塗対象圃場'
|
||||||
|
unique_together = [['session', 'field']]
|
||||||
|
ordering = ['field__display_order', 'field__id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.session} / {self.field.name}'
|
||||||
|
|
||||||
142
backend/apps/levee_work/serializers.py
Normal file
142
backend/apps/levee_work/serializers.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.plans.models import Plan
|
||||||
|
from apps.workrecords.services import sync_levee_work_record
|
||||||
|
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||||
|
|
||||||
|
|
||||||
|
class LeveeWorkSessionItemReadSerializer(serializers.ModelSerializer):
|
||||||
|
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||||
|
field_area_tan = serializers.DecimalField(
|
||||||
|
source='field.area_tan',
|
||||||
|
max_digits=6,
|
||||||
|
decimal_places=4,
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
group_name = serializers.CharField(source='field.group_name', read_only=True, allow_null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = LeveeWorkSessionItem
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'field',
|
||||||
|
'field_name',
|
||||||
|
'field_area_tan',
|
||||||
|
'group_name',
|
||||||
|
'plan',
|
||||||
|
'crop_name_snapshot',
|
||||||
|
'variety_name_snapshot',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class LeveeWorkSessionSerializer(serializers.ModelSerializer):
|
||||||
|
items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True)
|
||||||
|
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||||
|
item_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = LeveeWorkSession
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'year',
|
||||||
|
'date',
|
||||||
|
'title',
|
||||||
|
'notes',
|
||||||
|
'work_record_id',
|
||||||
|
'item_count',
|
||||||
|
'items',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_item_count(self, obj):
|
||||||
|
return len(obj.items.all())
|
||||||
|
|
||||||
|
|
||||||
|
class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer):
|
||||||
|
field = serializers.IntegerField()
|
||||||
|
plan = serializers.IntegerField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LeveeWorkSessionWriteSerializer(serializers.ModelSerializer):
|
||||||
|
items = LeveeWorkSessionItemWriteInputSerializer(many=True, write_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = LeveeWorkSession
|
||||||
|
fields = ['id', 'year', 'date', 'title', 'notes', 'items']
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
year = attrs.get('year', getattr(self.instance, 'year', None))
|
||||||
|
date = attrs.get('date', getattr(self.instance, 'date', None))
|
||||||
|
if year is not None and date is not None and year != date.year:
|
||||||
|
raise serializers.ValidationError({'year': 'year は date.year と一致させてください。'})
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def validate_items(self, value):
|
||||||
|
if not value:
|
||||||
|
raise serializers.ValidationError('items を1件以上指定してください。')
|
||||||
|
seen = set()
|
||||||
|
for item in value:
|
||||||
|
key = item['field']
|
||||||
|
if key in seen:
|
||||||
|
raise serializers.ValidationError('同一 session 内で同じ圃場を重複登録できません。')
|
||||||
|
seen.add(key)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create(self, validated_data):
|
||||||
|
items_data = validated_data.pop('items', [])
|
||||||
|
validated_data['title'] = (validated_data.get('title') or '').strip() or '水稲畔塗'
|
||||||
|
session = LeveeWorkSession.objects.create(**validated_data)
|
||||||
|
self._replace_items(session, items_data)
|
||||||
|
sync_levee_work_record(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
items_data = validated_data.pop('items', None)
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
if attr == 'title':
|
||||||
|
value = (value or '').strip() or '水稲畔塗'
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
if 'title' not in validated_data:
|
||||||
|
instance.title = (instance.title or '').strip() or '水稲畔塗'
|
||||||
|
instance.save()
|
||||||
|
if items_data is not None:
|
||||||
|
self._replace_items(instance, items_data)
|
||||||
|
sync_levee_work_record(instance)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def _replace_items(self, session, items_data):
|
||||||
|
session.items.all().delete()
|
||||||
|
for item in items_data:
|
||||||
|
plan = self._resolve_plan(session.year, item['field'], item.get('plan'))
|
||||||
|
LeveeWorkSessionItem.objects.create(
|
||||||
|
session=session,
|
||||||
|
field_id=item['field'],
|
||||||
|
plan=plan,
|
||||||
|
crop_name_snapshot=plan.crop.name,
|
||||||
|
variety_name_snapshot=plan.variety.name if plan.variety else '',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_plan(self, year, field_id, plan_id):
|
||||||
|
queryset = Plan.objects.select_related('crop', 'variety').filter(
|
||||||
|
year=year,
|
||||||
|
field_id=field_id,
|
||||||
|
crop__name='水稲',
|
||||||
|
)
|
||||||
|
if plan_id is not None:
|
||||||
|
try:
|
||||||
|
return queryset.get(id=plan_id)
|
||||||
|
except Plan.DoesNotExist as exc:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{'items': f'field={field_id} に対応する水稲作付け計画(plan={plan_id})が見つかりません。'}
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
plan = queryset.first()
|
||||||
|
if plan is None:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{'items': f'field={field_id} は当年の水稲作付け圃場ではありません。'}
|
||||||
|
)
|
||||||
|
return plan
|
||||||
13
backend/apps/levee_work/urls.py
Normal file
13
backend/apps/levee_work/urls.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from .views import LeveeWorkCandidatesView, LeveeWorkSessionViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'sessions', LeveeWorkSessionViewSet, basename='levee-work-session')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('candidates/', LeveeWorkCandidatesView.as_view(), name='levee-work-candidates'),
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
|
|
||||||
70
backend/apps/levee_work/views.py
Normal file
70
backend/apps/levee_work/views.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.plans.models import Plan
|
||||||
|
from .models import LeveeWorkSession
|
||||||
|
from .serializers import LeveeWorkSessionSerializer, LeveeWorkSessionWriteSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LeveeWorkSessionViewSet(viewsets.ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = LeveeWorkSession.objects.prefetch_related(
|
||||||
|
'items',
|
||||||
|
'items__field',
|
||||||
|
'items__plan',
|
||||||
|
).select_related('work_record')
|
||||||
|
year = self.request.query_params.get('year')
|
||||||
|
if year:
|
||||||
|
queryset = queryset.filter(year=year)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action in ['create', 'update', 'partial_update']:
|
||||||
|
return LeveeWorkSessionWriteSerializer
|
||||||
|
return LeveeWorkSessionSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LeveeWorkCandidatesView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
year = request.query_params.get('year')
|
||||||
|
if not year:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'year が必要です。'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
year = int(year)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response(
|
||||||
|
{'detail': 'year は数値で指定してください。'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
plans = (
|
||||||
|
Plan.objects.select_related('field', 'crop', 'variety')
|
||||||
|
.filter(year=year, crop__name='水稲')
|
||||||
|
.order_by('field__display_order', 'field__id')
|
||||||
|
)
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'field_id': plan.field_id,
|
||||||
|
'field_name': plan.field.name,
|
||||||
|
'field_area_tan': str(plan.field.area_tan),
|
||||||
|
'group_name': plan.field.group_name,
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'crop_name': plan.crop.name,
|
||||||
|
'variety_name': plan.variety.name if plan.variety else '',
|
||||||
|
'selected': True,
|
||||||
|
}
|
||||||
|
for plan in plans
|
||||||
|
]
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Generated by Django 5.2 on 2026-04-04
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('levee_work', '0001_initial'),
|
||||||
|
('workrecords', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workrecord',
|
||||||
|
name='work_type',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('fertilizer_delivery', '肥料運搬'),
|
||||||
|
('fertilizer_spreading', '肥料散布'),
|
||||||
|
('levee_work', '畔塗'),
|
||||||
|
],
|
||||||
|
max_length=40,
|
||||||
|
verbose_name='作業種別',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workrecord',
|
||||||
|
name='levee_work_session',
|
||||||
|
field=models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='work_record',
|
||||||
|
to='levee_work.leveeworksession',
|
||||||
|
verbose_name='畔塗記録',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ class WorkRecord(models.Model):
|
|||||||
class WorkType(models.TextChoices):
|
class WorkType(models.TextChoices):
|
||||||
FERTILIZER_DELIVERY = 'fertilizer_delivery', '肥料運搬'
|
FERTILIZER_DELIVERY = 'fertilizer_delivery', '肥料運搬'
|
||||||
FERTILIZER_SPREADING = 'fertilizer_spreading', '肥料散布'
|
FERTILIZER_SPREADING = 'fertilizer_spreading', '肥料散布'
|
||||||
|
LEVEE_WORK = 'levee_work', '畔塗'
|
||||||
|
|
||||||
work_date = models.DateField(verbose_name='作業日')
|
work_date = models.DateField(verbose_name='作業日')
|
||||||
work_type = models.CharField(
|
work_type = models.CharField(
|
||||||
@@ -31,6 +32,14 @@ class WorkRecord(models.Model):
|
|||||||
related_name='work_record',
|
related_name='work_record',
|
||||||
verbose_name='散布実績',
|
verbose_name='散布実績',
|
||||||
)
|
)
|
||||||
|
levee_work_session = models.OneToOneField(
|
||||||
|
'levee_work.LeveeWorkSession',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='work_record',
|
||||||
|
verbose_name='畔塗記録',
|
||||||
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -41,4 +50,3 @@ class WorkRecord(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.work_date} {self.get_work_type_display()}'
|
return f'{self.work_date} {self.get_work_type_display()}'
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class WorkRecordSerializer(serializers.ModelSerializer):
|
|||||||
'delivery_plan_id',
|
'delivery_plan_id',
|
||||||
'delivery_plan_name',
|
'delivery_plan_name',
|
||||||
'spreading_session',
|
'spreading_session',
|
||||||
|
'levee_work_session',
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
]
|
]
|
||||||
@@ -35,4 +36,3 @@ class WorkRecordSerializer(serializers.ModelSerializer):
|
|||||||
if obj.delivery_trip_id:
|
if obj.delivery_trip_id:
|
||||||
return obj.delivery_trip.delivery_plan.name
|
return obj.delivery_trip.delivery_plan.name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,18 @@ def sync_spreading_work_record(session):
|
|||||||
'delivery_trip': None,
|
'delivery_trip': None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_levee_work_record(session):
|
||||||
|
WorkRecord.objects.update_or_create(
|
||||||
|
levee_work_session=session,
|
||||||
|
defaults={
|
||||||
|
'work_date': session.date,
|
||||||
|
'work_type': WorkRecord.WorkType.LEVEE_WORK,
|
||||||
|
'title': session.title,
|
||||||
|
'year': session.year,
|
||||||
|
'auto_created': True,
|
||||||
|
'delivery_trip': None,
|
||||||
|
'spreading_session': None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ class WorkRecordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
'delivery_trip',
|
'delivery_trip',
|
||||||
'delivery_trip__delivery_plan',
|
'delivery_trip__delivery_plan',
|
||||||
'spreading_session',
|
'spreading_session',
|
||||||
|
'levee_work_session',
|
||||||
)
|
)
|
||||||
year = self.request.query_params.get('year')
|
year = self.request.query_params.get('year')
|
||||||
if year:
|
if year:
|
||||||
queryset = queryset.filter(year=year)
|
queryset = queryset.filter(year=year)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
|||||||
'apps.fertilizer',
|
'apps.fertilizer',
|
||||||
'apps.materials',
|
'apps.materials',
|
||||||
'apps.workrecords',
|
'apps.workrecords',
|
||||||
|
'apps.levee_work',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -60,4 +60,5 @@ urlpatterns = [
|
|||||||
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
path('api/fertilizer/', include('apps.fertilizer.urls')),
|
||||||
path('api/materials/', include('apps.materials.urls')),
|
path('api/materials/', include('apps.materials.urls')),
|
||||||
path('api/workrecords/', include('apps.workrecords.urls')),
|
path('api/workrecords/', include('apps.workrecords.urls')),
|
||||||
|
path('api/levee-work/', include('apps.levee_work.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
439
frontend/src/app/levee-work/page.tsx
Normal file
439
frontend/src/app/levee-work/page.tsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { ChevronLeft, PencilLine, Plus, Save, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { LeveeWorkCandidate, LeveeWorkSession } from '@/types';
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear();
|
||||||
|
const YEAR_KEY = 'leveeWorkYear';
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
notes: string;
|
||||||
|
selectedFieldIds: Set<number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractErrorMessage = (error: any) => {
|
||||||
|
const data = error?.response?.data;
|
||||||
|
if (!data) return '保存に失敗しました。';
|
||||||
|
if (typeof data.detail === 'string') return data.detail;
|
||||||
|
if (Array.isArray(data.year) && data.year[0]) return data.year[0];
|
||||||
|
if (Array.isArray(data.items) && data.items[0]) return data.items[0];
|
||||||
|
if (typeof data.items === 'string') return data.items;
|
||||||
|
return '保存に失敗しました。';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultDate = (year: number) => {
|
||||||
|
const today = new Date();
|
||||||
|
if (today.getFullYear() !== year) {
|
||||||
|
return `${year}-01-01`;
|
||||||
|
}
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeveeWorkPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="min-h-screen bg-gray-50"><Navbar /><div className="mx-auto max-w-7xl px-4 py-8 text-gray-500">読み込み中...</div></div>}>
|
||||||
|
<LeveeWorkPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeveeWorkPageContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [year, setYear] = useState<number>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
|
||||||
|
}
|
||||||
|
return CURRENT_YEAR;
|
||||||
|
});
|
||||||
|
const [sessions, setSessions] = useState<LeveeWorkSession[]>([]);
|
||||||
|
const [candidates, setCandidates] = useState<LeveeWorkCandidate[]>([]);
|
||||||
|
const [form, setForm] = useState<FormState | null>(null);
|
||||||
|
const [editingSessionId, setEditingSessionId] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [openedFromQuery, setOpenedFromQuery] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(YEAR_KEY, String(year));
|
||||||
|
void fetchSessions();
|
||||||
|
setForm(null);
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setOpenedFromQuery(false);
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sessionParam = Number(searchParams.get('session') || '0') || null;
|
||||||
|
if (!sessionParam || openedFromQuery || sessions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = sessions.find((session) => session.id === sessionParam);
|
||||||
|
if (target) {
|
||||||
|
void openEditor(target);
|
||||||
|
setOpenedFromQuery(true);
|
||||||
|
}
|
||||||
|
}, [openedFromQuery, searchParams, sessions]);
|
||||||
|
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/levee-work/sessions/?year=${year}`);
|
||||||
|
setSessions(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('畔塗記録の読み込みに失敗しました。');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCandidates = async () => {
|
||||||
|
const res = await api.get(`/levee-work/candidates/?year=${year}`);
|
||||||
|
setCandidates(res.data);
|
||||||
|
return res.data as LeveeWorkCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCreate = async () => {
|
||||||
|
setFormLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const loaded = await loadCandidates();
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setForm({
|
||||||
|
date: getDefaultDate(year),
|
||||||
|
title: '水稲畔塗',
|
||||||
|
notes: '',
|
||||||
|
selectedFieldIds: new Set(loaded.filter((candidate) => candidate.selected).map((candidate) => candidate.field_id)),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('候補圃場の読み込みに失敗しました。');
|
||||||
|
} finally {
|
||||||
|
setFormLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditor = async (session: LeveeWorkSession) => {
|
||||||
|
setFormLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const loaded = await loadCandidates();
|
||||||
|
const selectedIds = new Set(session.items.map((item) => item.field));
|
||||||
|
const fallbackSelected = loaded.filter((candidate) => candidate.selected).map((candidate) => candidate.field_id);
|
||||||
|
setEditingSessionId(session.id);
|
||||||
|
setForm({
|
||||||
|
date: session.date,
|
||||||
|
title: session.title,
|
||||||
|
notes: session.notes,
|
||||||
|
selectedFieldIds: selectedIds.size > 0 ? selectedIds : new Set(fallbackSelected),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('編集用データの読み込みに失敗しました。');
|
||||||
|
} finally {
|
||||||
|
setFormLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleField = (fieldId: number) => {
|
||||||
|
if (!form) return;
|
||||||
|
const next = new Set(form.selectedFieldIds);
|
||||||
|
if (next.has(fieldId)) {
|
||||||
|
next.delete(fieldId);
|
||||||
|
} else {
|
||||||
|
next.add(fieldId);
|
||||||
|
}
|
||||||
|
setForm({ ...form, selectedFieldIds: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (!form) return;
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
selectedFieldIds: new Set(candidates.map((candidate) => candidate.field_id)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
if (!form) return;
|
||||||
|
setForm({ ...form, selectedFieldIds: new Set() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCount = form?.selectedFieldIds.size ?? 0;
|
||||||
|
|
||||||
|
const selectedCandidates = useMemo(() => {
|
||||||
|
if (!form) return [];
|
||||||
|
return candidates.filter((candidate) => form.selectedFieldIds.has(candidate.field_id));
|
||||||
|
}, [candidates, form]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form) return;
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
setError('対象圃場を1件以上選択してください。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
year,
|
||||||
|
date: form.date,
|
||||||
|
title: form.title,
|
||||||
|
notes: form.notes,
|
||||||
|
items: selectedCandidates.map((candidate) => ({
|
||||||
|
field: candidate.field_id,
|
||||||
|
plan: candidate.plan_id,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (editingSessionId) {
|
||||||
|
await api.put(`/levee-work/sessions/${editingSessionId}/`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post('/levee-work/sessions/', payload);
|
||||||
|
}
|
||||||
|
await fetchSessions();
|
||||||
|
await startCreate();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setError(extractErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!editingSessionId) return;
|
||||||
|
if (!window.confirm('この畔塗記録を削除しますか?')) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.delete(`/levee-work/sessions/${editingSessionId}/`);
|
||||||
|
await fetchSessions();
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setForm(null);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('削除に失敗しました。');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => router.push('/workrecords')} className="text-gray-500 hover:text-gray-700">
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<PencilLine className="h-6 w-6 text-amber-700" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">畔塗記録</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||||
|
<select
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(Number(e.target.value))}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}年度
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => void startCreate()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新規作成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<section className="overflow-hidden rounded-lg bg-white shadow-sm">
|
||||||
|
<div className="border-b bg-gray-50 px-4 py-3 text-sm font-medium text-gray-700">記録一覧</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-4 py-8 text-sm text-gray-500">読み込み中...</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-sm text-gray-400">この年度の畔塗記録はまだありません。</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<button
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => void openEditor(session)}
|
||||||
|
className={`block w-full px-4 py-4 text-left hover:bg-amber-50 ${
|
||||||
|
editingSessionId === session.id ? 'bg-amber-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{session.title}</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-600">{session.date}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">{session.item_count}圃場</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg bg-white shadow-sm">
|
||||||
|
<div className="border-b bg-gray-50 px-5 py-3 text-sm font-medium text-gray-700">
|
||||||
|
{editingSessionId ? '畔塗記録を編集' : '畔塗記録を作成'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!form ? (
|
||||||
|
<div className="px-5 py-10 text-sm text-gray-500">
|
||||||
|
{formLoading ? 'フォームを準備中...' : '「新規作成」または既存記録の選択で編集を始められます。'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 px-5 py-5">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<div className="mb-1 text-sm font-medium text-gray-700">日付</div>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<div className="mb-1 text-sm font-medium text-gray-700">タイトル</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<div className="mb-1 text-sm font-medium text-gray-700">備考</div>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-gray-900">対象圃場一覧</h2>
|
||||||
|
<p className="text-xs text-gray-500">{selectedCount} / {candidates.length} 圃場を選択中</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
className="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
全選択
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="rounded border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
全解除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formLoading ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-500">
|
||||||
|
候補圃場を読み込み中...
|
||||||
|
</div>
|
||||||
|
) : candidates.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-400">
|
||||||
|
この年度の水稲作付け圃場がありません。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-700">選択</th>
|
||||||
|
<th className="px-4 py-3 text-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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{candidates.map((candidate) => {
|
||||||
|
const checked = form.selectedFieldIds.has(candidate.field_id);
|
||||||
|
return (
|
||||||
|
<tr key={candidate.field_id} className={checked ? 'bg-amber-50/40' : ''}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => handleToggleField(candidate.field_id)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-900">{candidate.field_name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{candidate.field_area_tan}反</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{candidate.group_name || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{candidate.variety_name || '(未設定)'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={saving || formLoading}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
{editingSessionId && (
|
||||||
|
<button
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
削除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,10 @@ export default function WorkRecordsPage() {
|
|||||||
router.push(`/fertilizer/spreading?session=${record.spreading_session}`);
|
router.push(`/fertilizer/spreading?session=${record.spreading_session}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (record.levee_work_session) {
|
||||||
|
router.push(`/levee-work?session=${record.levee_work_session}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (record.delivery_plan_id) {
|
if (record.delivery_plan_id) {
|
||||||
router.push(`/distribution/${record.delivery_plan_id}/edit`);
|
router.push(`/distribution/${record.delivery_plan_id}/edit`);
|
||||||
}
|
}
|
||||||
@@ -112,12 +116,14 @@ export default function WorkRecordsPage() {
|
|||||||
<td className="px-4 py-3 text-gray-600">
|
<td className="px-4 py-3 text-gray-600">
|
||||||
{record.spreading_session
|
{record.spreading_session
|
||||||
? `散布実績 #${record.spreading_session}`
|
? `散布実績 #${record.spreading_session}`
|
||||||
|
: record.levee_work_session
|
||||||
|
? `畔塗記録 #${record.levee_work_session}`
|
||||||
: record.delivery_plan_name
|
: record.delivery_plan_name
|
||||||
? `${record.delivery_plan_name}`
|
? `${record.delivery_plan_name}`
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{(record.spreading_session || record.delivery_plan_id) && (
|
{(record.spreading_session || record.levee_work_session || record.delivery_plan_id) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => moveToSource(record)}
|
onClick={() => moveToSource(record)}
|
||||||
className="rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
className="rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||||
|
|||||||
@@ -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, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine } from 'lucide-react';
|
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine, Construction } from 'lucide-react';
|
||||||
import { logout } from '@/lib/api';
|
import { logout } from '@/lib/api';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -144,6 +144,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('/levee-work')}
|
||||||
|
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
|
pathname?.startsWith('/levee-work')
|
||||||
|
? 'text-green-700 bg-green-50'
|
||||||
|
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Construction className="h-4 w-4 mr-2" />
|
||||||
|
畔塗記録
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/materials')}
|
onClick={() => router.push('/materials')}
|
||||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||||
|
|||||||
@@ -268,10 +268,45 @@ export interface SpreadingSession {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LeveeWorkCandidate {
|
||||||
|
field_id: number;
|
||||||
|
field_name: string;
|
||||||
|
field_area_tan: string;
|
||||||
|
group_name: string | null;
|
||||||
|
plan_id: number;
|
||||||
|
crop_name: string;
|
||||||
|
variety_name: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeveeWorkSessionItem {
|
||||||
|
id: number;
|
||||||
|
field: number;
|
||||||
|
field_name: string;
|
||||||
|
field_area_tan: string;
|
||||||
|
group_name: string | null;
|
||||||
|
plan: number | null;
|
||||||
|
crop_name_snapshot: string;
|
||||||
|
variety_name_snapshot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeveeWorkSession {
|
||||||
|
id: number;
|
||||||
|
year: number;
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
notes: string;
|
||||||
|
work_record_id: number | null;
|
||||||
|
item_count: number;
|
||||||
|
items: LeveeWorkSessionItem[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkRecord {
|
export interface WorkRecord {
|
||||||
id: number;
|
id: number;
|
||||||
work_date: string;
|
work_date: string;
|
||||||
work_type: 'fertilizer_delivery' | 'fertilizer_spreading';
|
work_type: 'fertilizer_delivery' | 'fertilizer_spreading' | 'levee_work';
|
||||||
work_type_display: string;
|
work_type_display: string;
|
||||||
title: string;
|
title: string;
|
||||||
year: number;
|
year: number;
|
||||||
@@ -280,6 +315,7 @@ export interface WorkRecord {
|
|||||||
delivery_plan_id: number | null;
|
delivery_plan_id: number | null;
|
||||||
delivery_plan_name: string | null;
|
delivery_plan_name: string | null;
|
||||||
spreading_session: number | null;
|
spreading_session: number | null;
|
||||||
|
levee_work_session: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user