施肥散布実績機能を実装し運搬・作業記録・在庫連携を追加

This commit is contained in:
Akira
2026-03-17 19:28:52 +09:00
parent 865d53ed9a
commit 140d5e5a4d
31 changed files with 2053 additions and 248 deletions

View File

@@ -2,7 +2,7 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, X, ChevronUp, ChevronDown, Pencil, Check, Truck, ArrowLeft } from 'lucide-react';
import { Plus, X, ChevronUp, ChevronDown, Pencil, Check, Truck, ArrowLeft, Sprout } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { DeliveryPlan, DeliveryAllEntry } from '@/types';
import { api } from '@/lib/api';
@@ -676,9 +676,20 @@ export default function DeliveryEditPage({ planId }: Props) {
</button>
<h1 className="text-xl font-bold text-gray-900 mb-6">
{isEdit ? '運搬計画を編集' : '運搬計画を新規作成'}
</h1>
<div className="mb-6 flex items-center justify-between gap-4">
<h1 className="text-xl font-bold text-gray-900">
{isEdit ? '運搬計画を編集' : '運搬計画を新規作成'}
</h1>
{isEdit && planId && (
<button
onClick={() => router.push(`/fertilizer/spreading?year=${year}&delivery_plan=${planId}`)}
className="flex items-center gap-2 rounded-md border border-emerald-300 px-4 py-2 text-sm text-emerald-700 hover:bg-emerald-50"
>
<Sprout className="h-4 w-4" />
</button>
)}
</div>
{saveError && (
<div className="flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-md px-4 py-3 mb-4 text-sm">

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Truck, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
import { Truck, Plus, FileDown, Pencil, Trash2, X, Sprout } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { DeliveryPlanListItem } from '@/types';
@@ -75,13 +75,22 @@ export default function DeliveryListPage() {
<Truck className="h-7 w-7 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<button
onClick={() => router.push('/distribution/new')}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4" />
</button>
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/fertilizer/spreading')}
className="flex items-center gap-2 rounded-md border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors"
>
<Sprout className="h-4 w-4" />
</button>
<button
onClick={() => router.push('/distribution/new')}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{/* 年度セレクタ */}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Undo2 } from 'lucide-react';
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Undo2, Sprout } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Crop, FertilizationPlan, Fertilizer, Field, StockSummary } from '@/types';
@@ -62,6 +62,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// roundedColumns: 四捨五入済みの肥料列ID↩ トグル用)
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
const [adjusted, setAdjusted] = useState<Matrix>({});
const [actualMatrix, setActualMatrix] = useState<Matrix>({});
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
const [stockByMaterialId, setStockByMaterialId] = useState<Record<number, StockSummary>>({});
const [initialPlanTotals, setInitialPlanTotals] = useState<Record<number, number>>({});
@@ -102,8 +103,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
setName(plan.name);
setYear(plan.year);
setVarietyId(plan.variety);
setIsConfirmed(plan.is_confirmed);
setConfirmedAt(plan.confirmed_at);
setIsConfirmed(false);
setConfirmedAt(null);
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
@@ -122,11 +123,17 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// 保存済みの値は adjusted に復元
const newAdjusted: Matrix = {};
const newActualMatrix: Matrix = {};
plan.entries.forEach((e) => {
if (!newAdjusted[e.field]) newAdjusted[e.field] = {};
newAdjusted[e.field][e.fertilizer] = String(e.bags);
if (e.actual_bags !== null && e.actual_bags !== undefined) {
if (!newActualMatrix[e.field]) newActualMatrix[e.field] = {};
newActualMatrix[e.field][e.fertilizer] = String(e.actual_bags);
}
});
setAdjusted(newAdjusted);
setActualMatrix(newActualMatrix);
setInitialPlanTotals(
plan.entries.reduce((acc: Record<number, number>, entry) => {
acc[entry.fertilizer] = (acc[entry.fertilizer] ?? 0) + Number(entry.bags);
@@ -498,6 +505,15 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
</h1>
</div>
<div className="flex items-center gap-2">
{!isNew && planId && (
<button
onClick={() => router.push(`/fertilizer/spreading?year=${year}&plan=${planId}`)}
className="flex items-center gap-2 px-4 py-2 border border-emerald-300 rounded-lg text-sm text-emerald-700 hover:bg-emerald-50"
>
<Sprout className="h-4 w-4" />
</button>
)}
{!isNew && isConfirmed && (
<button
onClick={handleUnconfirm}
@@ -775,25 +791,33 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
{planFertilizers.map((fert) => {
const calcVal = calcMatrix[field.id]?.[fert.id];
const adjVal = adjusted[field.id]?.[fert.id];
const actualVal = actualMatrix[field.id]?.[fert.id];
// 計算結果があればラベルを表示adjusted が上書きされた場合は参照値として)
const showRef = calcVal !== undefined;
// 入力欄: adjusted → calc値 → 空
const inputValue = adjVal !== undefined ? adjVal : (calcVal ?? '');
return (
<td key={fert.id} className="px-2 py-1 border border-gray-200">
<div className="flex items-center justify-end gap-1.5">
{showRef && (
<span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
<div className="space-y-1">
<div className="flex items-center justify-end gap-1.5">
{showRef && (
<span className="text-gray-300 text-xs tabular-nums">{calcVal}</span>
)}
<input
type="number"
step="0.1"
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
value={inputValue}
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
placeholder="-"
disabled={isConfirmed}
/>
</div>
{actualVal !== undefined && (
<div className="text-right text-[11px] text-sky-700">
{actualVal}
</div>
)}
<input
type="number"
step="0.1"
className="w-14 text-right border border-gray-200 rounded bg-white focus:outline-none focus:ring-1 focus:ring-green-400 px-1 py-0.5 text-sm"
value={inputValue}
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
placeholder="-"
disabled={isConfirmed}
/>
</div>
</td>
);

View File

@@ -1,30 +1,41 @@
'use client';
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Pencil, Trash2, FileDown, Sprout, BadgeCheck, Undo2 } from 'lucide-react';
import { FileDown, NotebookText, Pencil, Plus, Sprout, Trash2, Truck } from 'lucide-react';
import ConfirmSpreadingModal from './_components/ConfirmSpreadingModal';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { FertilizationPlan } from '@/types';
const currentYear = new Date().getFullYear();
const STATUS_LABELS: Record<FertilizationPlan['spread_status'], string> = {
unspread: '未散布',
partial: '一部散布',
completed: '散布済み',
over_applied: '超過散布',
};
const STATUS_CLASSES: Record<FertilizationPlan['spread_status'], string> = {
unspread: 'bg-gray-100 text-gray-700',
partial: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-800',
over_applied: 'bg-rose-100 text-rose-800',
};
export default function FertilizerPage() {
const router = useRouter();
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('fertilizerYear');
if (saved) return parseInt(saved);
if (saved) return parseInt(saved, 10);
}
return currentYear;
});
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
const [loading, setLoading] = useState(true);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [confirmTarget, setConfirmTarget] = useState<FertilizationPlan | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem('fertilizerYear', String(year));
@@ -33,41 +44,31 @@ export default function FertilizerPage() {
const fetchPlans = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get(`/fertilizer/plans/?year=${year}`);
setPlans(res.data);
} catch (e) {
console.error(e);
setError('施肥計画の読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number, name: string) => {
setDeleteError(null);
setActionError(null);
setError(null);
try {
await api.delete(`/fertilizer/plans/${id}/`);
await fetchPlans();
} catch (e) {
console.error(e);
setDeleteError(`${name}」の削除に失敗しました`);
}
};
const handleUnconfirm = async (id: number, name: string) => {
setActionError(null);
try {
await api.post(`/fertilizer/plans/${id}/unconfirm/`);
await fetchPlans();
} catch (e) {
console.error(e);
setActionError(`${name}」の確定取消に失敗しました`);
setError(`${name}」の削除に失敗しました`);
}
};
const handlePdf = async (id: number, name: string) => {
setActionError(null);
setError(null);
try {
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
@@ -78,7 +79,7 @@ export default function FertilizerPage() {
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
setActionError('PDF出力に失敗しました');
setError('PDF出力に失敗しました');
}
};
@@ -87,22 +88,36 @@ export default function FertilizerPage() {
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Sprout className="h-6 w-6 text-green-600" />
<h1 className="text-2xl font-bold text-gray-800"></h1>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/workrecords')}
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<NotebookText className="h-4 w-4" />
</button>
<button
onClick={() => router.push('/fertilizer/spreading')}
className="flex items-center gap-2 rounded-lg border border-emerald-300 px-4 py-2 text-sm text-emerald-700 hover:bg-emerald-50"
>
<Truck className="h-4 w-4" />
</button>
<button
onClick={() => router.push('/fertilizer/masters')}
className="px-4 py-2 text-sm border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-100"
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
</button>
<button
onClick={() => router.push('/fertilizer/new')}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700"
>
<Plus className="h-4 w-4" />
@@ -110,113 +125,76 @@ export default function FertilizerPage() {
</div>
</div>
{/* 年度セレクタ */}
<div className="flex items-center gap-3 mb-6">
<div className="mb-6 flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">:</label>
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
onChange={(e) => setYear(parseInt(e.target.value, 10))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
{deleteError && (
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
<span className="font-bold shrink-0"></span>
<span>{deleteError}</span>
<button onClick={() => setDeleteError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600"></button>
</div>
)}
{actionError && (
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
<span className="font-bold shrink-0"></span>
<span>{actionError}</span>
<button onClick={() => setActionError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600"></button>
{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>
)}
{loading ? (
<p className="text-gray-500">...</p>
) : plans.length === 0 ? (
<div className="bg-white rounded-lg shadow p-12 text-center text-gray-400">
<Sprout className="h-12 w-12 mx-auto mb-3 opacity-30" />
<div className="rounded-lg bg-white p-12 text-center text-gray-400 shadow">
<Sprout className="mx-auto mb-3 h-12 w-12 opacity-30" />
<p>{year}</p>
<button
onClick={() => router.push('/fertilizer/new')}
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
className="mt-4 rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-700"
>
</button>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<thead className="border-b bg-gray-50">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-700"></th>
<th className="text-left px-4 py-3 font-medium text-gray-700"> / </th>
<th className="text-left px-4 py-3 font-medium text-gray-700"></th>
<th className="text-right px-4 py-3 font-medium text-gray-700"></th>
<th className="text-right px-4 py-3 font-medium text-gray-700"></th>
<th className="px-4 py-3"></th>
<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-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" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{plans.map((plan) => (
<tr
key={plan.id}
className={plan.is_confirmed ? 'bg-sky-50 hover:bg-sky-100/60' : 'hover:bg-gray-50'}
>
<td className="px-4 py-3 font-medium">
<div className="flex items-center gap-2">
<span>{plan.name}</span>
{plan.is_confirmed && (
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">
<BadgeCheck className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<tr key={plan.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{plan.name}</td>
<td className="px-4 py-3 text-gray-600">
{plan.crop_name} / {plan.variety_name}
</td>
<td className="px-4 py-3 text-gray-600">
{plan.is_confirmed
? `散布確定 ${plan.confirmed_at ? new Date(plan.confirmed_at).toLocaleString('ja-JP') : ''}`
: '未確定'}
</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 justify-end">
{!plan.is_confirmed ? (
<button
onClick={() => setConfirmTarget(plan)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-emerald-300 rounded hover:bg-emerald-50 text-emerald-700"
title="散布確定"
>
<BadgeCheck className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => handleUnconfirm(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-amber-300 rounded hover:bg-amber-50 text-amber-700"
title="確定取消"
>
<Undo2 className="h-3.5 w-3.5" />
</button>
)}
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_CLASSES[plan.spread_status]}`}>
{STATUS_LABELS[plan.spread_status]}
</span>
</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.planned_total_bags}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.spread_total_bags}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.remaining_total_bags}</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handlePdf(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"
className="flex items-center gap-1 rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
title="PDF出力"
>
<FileDown className="h-3.5 w-3.5" />
@@ -224,7 +202,7 @@ export default function FertilizerPage() {
</button>
<button
onClick={() => router.push(`/fertilizer/${plan.id}/edit`)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-blue-300 rounded hover:bg-blue-50 text-blue-700"
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
title="編集"
>
<Pencil className="h-3.5 w-3.5" />
@@ -232,7 +210,7 @@ export default function FertilizerPage() {
</button>
<button
onClick={() => handleDelete(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-red-300 rounded hover:bg-red-50 text-red-600"
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
title="削除"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -247,13 +225,6 @@ export default function FertilizerPage() {
</div>
)}
</div>
<ConfirmSpreadingModal
isOpen={confirmTarget !== null}
plan={confirmTarget}
onClose={() => setConfirmTarget(null)}
onConfirmed={fetchPlans}
/>
</div>
);
}

View File

@@ -0,0 +1,747 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ChevronLeft, Pencil, Plus, Save, Sprout, Trash2, X } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import {
DeliveryPlan,
FertilizationPlan,
SpreadingCandidate,
SpreadingSession,
} from '@/types';
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_KEY = 'spreadingYear';
type SourceType = 'delivery' | 'plan' | 'year';
type FormState = {
date: string;
name: string;
notes: string;
itemValues: Record<string, string>;
};
type MatrixField = {
id: number;
name: string;
area_tan: string;
};
type MatrixFertilizer = {
id: number;
name: string;
};
const candidateKey = (fieldId: number, fertilizerId: number) => `${fieldId}:${fertilizerId}`;
const toNumber = (value: string | number | null | undefined) => {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
};
const formatDisplay = (value: string | number | null | undefined) => {
const num = toNumber(value);
if (Number.isInteger(num)) {
return String(num);
}
return num.toFixed(4).replace(/\.?0+$/, '');
};
const formatInputValue = (value: number) => {
if (value <= 0) return '0';
return value.toFixed(2).replace(/\.?0+$/, '');
};
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}`;
};
const getSourceType = (deliveryPlanId: number | null, fertilizationPlanId: number | null): SourceType => {
if (deliveryPlanId) return 'delivery';
if (fertilizationPlanId) return 'plan';
return 'year';
};
const buildCreateInitialValues = (rows: SpreadingCandidate[], sourceType: SourceType) => {
const values: Record<string, string> = {};
rows.forEach((candidate) => {
let base = 0;
if (sourceType === 'delivery') {
base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other);
} else if (sourceType === 'plan') {
base = toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other);
} else {
base = toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other);
}
values[candidateKey(candidate.field, candidate.fertilizer)] = formatInputValue(Math.max(base, 0));
});
return values;
};
export default function SpreadingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryYear = Number(searchParams.get('year') || '0') || null;
const deliveryPlanId = Number(searchParams.get('delivery_plan') || '0') || null;
const fertilizationPlanId = Number(searchParams.get('plan') || '0') || null;
const sourceType = getSourceType(deliveryPlanId, fertilizationPlanId);
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<SpreadingSession[]>([]);
const [candidates, setCandidates] = useState<SpreadingCandidate[]>([]);
const [loading, setLoading] = useState(true);
const [formLoading, setFormLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingSessionId, setEditingSessionId] = useState<number | null>(null);
const [form, setForm] = useState<FormState | null>(null);
const [openedFromQuery, setOpenedFromQuery] = useState(false);
const [openedFromSource, setOpenedFromSource] = useState(false);
const [sourceName, setSourceName] = useState<string | null>(null);
useEffect(() => {
if (queryYear && queryYear !== year) {
setYear(queryYear);
}
}, [queryYear, year]);
useEffect(() => {
localStorage.setItem(YEAR_KEY, String(year));
void fetchSessions();
setForm(null);
setEditingSessionId(null);
setOpenedFromQuery(false);
setOpenedFromSource(false);
}, [year]);
useEffect(() => {
const loadSource = async () => {
if (deliveryPlanId) {
try {
const res = await api.get(`/fertilizer/delivery/${deliveryPlanId}/`);
const plan: DeliveryPlan = res.data;
setSourceName(plan.name);
return;
} catch (e) {
console.error(e);
setSourceName(`運搬計画 #${deliveryPlanId}`);
return;
}
}
if (fertilizationPlanId) {
try {
const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`);
const plan: FertilizationPlan = res.data;
setSourceName(plan.name);
return;
} catch (e) {
console.error(e);
setSourceName(`施肥計画 #${fertilizationPlanId}`);
return;
}
}
setSourceName(null);
};
void loadSource();
}, [deliveryPlanId, fertilizationPlanId]);
useEffect(() => {
const sessionParam = searchParams.get('session');
if (!sessionParam || openedFromQuery || sessions.length === 0) {
return;
}
const targetId = Number(sessionParam);
if (!targetId) {
return;
}
const target = sessions.find((session) => session.id === targetId);
if (target) {
void openEditor(target);
setOpenedFromQuery(true);
}
}, [openedFromQuery, searchParams, sessions]);
useEffect(() => {
const sessionParam = searchParams.get('session');
if (sessionParam || sourceType === 'year' || openedFromSource || form || formLoading) {
return;
}
void startCreate();
setOpenedFromSource(true);
}, [form, formLoading, openedFromSource, searchParams, sourceType]);
const fetchSessions = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get(`/fertilizer/spreading/?year=${year}`);
setSessions(res.data);
} catch (e) {
console.error(e);
setError('散布実績の読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
const loadCandidates = async (sessionId?: number) => {
const params = new URLSearchParams({ year: String(year) });
if (sessionId) {
params.set('session_id', String(sessionId));
}
if (deliveryPlanId) {
params.set('delivery_plan_id', String(deliveryPlanId));
}
if (fertilizationPlanId) {
params.set('plan_id', String(fertilizationPlanId));
}
const res = await api.get(`/fertilizer/spreading/candidates/?${params.toString()}`);
setCandidates(res.data);
return res.data as SpreadingCandidate[];
};
const startCreate = async () => {
setFormLoading(true);
setError(null);
try {
const loaded = await loadCandidates();
setEditingSessionId(null);
setForm({
date: getDefaultDate(year),
name: '',
notes: '',
itemValues: buildCreateInitialValues(loaded, sourceType),
});
} catch (e) {
console.error(e);
setError('散布候補の読み込みに失敗しました。');
} finally {
setFormLoading(false);
}
};
const openEditor = async (session: SpreadingSession) => {
setFormLoading(true);
setError(null);
try {
await loadCandidates(session.id);
const itemValues = session.items.reduce<Record<string, string>>((acc, item) => {
acc[candidateKey(item.field, item.fertilizer)] = String(item.actual_bags);
return acc;
}, {});
setEditingSessionId(session.id);
setForm({
date: session.date,
name: session.name,
notes: session.notes,
itemValues,
});
} catch (e) {
console.error(e);
setError('散布候補の読み込みに失敗しました。');
} finally {
setFormLoading(false);
}
};
const closeEditor = () => {
setEditingSessionId(null);
setForm(null);
setCandidates([]);
};
const candidateMap = useMemo(() => {
const map = new Map<string, SpreadingCandidate>();
candidates.forEach((candidate) => {
map.set(candidateKey(candidate.field, candidate.fertilizer), candidate);
});
return map;
}, [candidates]);
const matrixFields = useMemo<MatrixField[]>(() => {
const map = new Map<number, MatrixField>();
candidates.forEach((candidate) => {
if (!map.has(candidate.field)) {
map.set(candidate.field, {
id: candidate.field,
name: candidate.field_name,
area_tan: candidate.field_area_tan,
});
}
});
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja'));
}, [candidates]);
const matrixFertilizers = useMemo<MatrixFertilizer[]>(() => {
const map = new Map<number, MatrixFertilizer>();
candidates.forEach((candidate) => {
if (!map.has(candidate.fertilizer)) {
map.set(candidate.fertilizer, {
id: candidate.fertilizer,
name: candidate.fertilizer_name,
});
}
});
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'ja'));
}, [candidates]);
const handleItemChange = (fieldId: number, fertilizerId: number, value: string) => {
if (!form) return;
const key = candidateKey(fieldId, fertilizerId);
setForm({
...form,
itemValues: {
...form.itemValues,
[key]: value,
},
});
};
const getCellValue = (fieldId: number, fertilizerId: number) => {
if (!form) return '';
return form.itemValues[candidateKey(fieldId, fertilizerId)] ?? '0';
};
const selectedRows = useMemo(() => {
if (!form) return [];
return candidates.filter((candidate) => {
const value = toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0');
return value > 0;
});
}, [candidates, form]);
const getRowTotal = (fieldId: number) => {
if (!form) return 0;
return matrixFertilizers.reduce((sum, fertilizer) => {
const candidate = candidateMap.get(candidateKey(fieldId, fertilizer.id));
if (!candidate) return sum;
return sum + toNumber(getCellValue(fieldId, fertilizer.id));
}, 0);
};
const getColumnTotal = (fertilizerId: number) => {
if (!form) return 0;
return matrixFields.reduce((sum, field) => {
const candidate = candidateMap.get(candidateKey(field.id, fertilizerId));
if (!candidate) return sum;
return sum + toNumber(getCellValue(field.id, fertilizerId));
}, 0);
};
const totalInputBags = selectedRows.reduce((sum, candidate) => {
return sum + toNumber(form?.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0');
}, 0);
const handleSave = async () => {
if (!form) return;
setError(null);
if (!form.date) {
setError('散布日を入力してください。');
return;
}
const items = selectedRows.map((candidate) => ({
field_id: candidate.field,
fertilizer_id: candidate.fertilizer,
actual_bags: toNumber(form.itemValues[candidateKey(candidate.field, candidate.fertilizer)] || '0'),
planned_bags_snapshot: toNumber(candidate.planned_bags),
delivered_bags_snapshot: toNumber(candidate.delivered_bags),
}));
if (items.length === 0) {
setError('散布実績を1件以上入力してください。');
return;
}
setSaving(true);
try {
const payload = {
year,
date: form.date,
name: form.name,
notes: form.notes,
items,
};
if (editingSessionId) {
await api.put(`/fertilizer/spreading/${editingSessionId}/`, payload);
} else {
await api.post('/fertilizer/spreading/', payload);
}
await fetchSessions();
closeEditor();
} catch (e) {
console.error(e);
setError('散布実績の保存に失敗しました。');
} finally {
setSaving(false);
}
};
const handleDelete = async (sessionId: number) => {
setError(null);
try {
await api.delete(`/fertilizer/spreading/${sessionId}/`);
await fetchSessions();
if (editingSessionId === sessionId) {
closeEditor();
}
} catch (e) {
console.error(e);
setError('散布実績の削除に失敗しました。');
}
};
const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
const sourceSummary =
sourceType === 'delivery'
? '初期値は運搬計画値から散布済を引いた値です。'
: sourceType === 'plan'
? '初期値は施肥計画値から散布済を引いた値です。'
: '初期値は運搬済みから散布済を引いた値です。';
const sourceLabel =
sourceType === 'delivery'
? '運搬計画を選択した状態です'
: sourceType === 'plan'
? '施肥計画を選択した状態です'
: null;
const clearFilterHref = `/fertilizer/spreading?year=${year}`;
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">
<div className="flex items-center gap-3">
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
<ChevronLeft className="h-5 w-5" />
</button>
<Sprout className="h-6 w-6 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<button
onClick={() => void startCreate()}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="mb-6 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-green-500"
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
{sourceLabel && (
<div className="mb-6 flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3">
<div>
<div className="text-sm font-medium text-emerald-900">{sourceLabel}</div>
<div className="mt-1 text-sm text-emerald-700">
{sourceName ?? (sourceType === 'delivery' ? `運搬計画 #${deliveryPlanId}` : `施肥計画 #${fertilizationPlanId}`)}
{' '}
</div>
<div className="mt-1 text-xs text-emerald-700">{sourceSummary}</div>
</div>
<button
onClick={() => router.push(clearFilterHref)}
className="flex items-center gap-1 rounded border border-emerald-300 px-3 py-1.5 text-xs text-emerald-700 hover:bg-emerald-100"
>
<X className="h-3.5 w-3.5" />
</button>
</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>
)}
{(form || formLoading) && (
<section className="mb-8 rounded-lg border border-emerald-200 bg-white shadow-sm">
<div className="border-b border-emerald-100 px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900">
{editingSessionId ? '散布実績を編集' : '散布実績を登録'}
</h2>
<p className="mt-1 text-sm text-gray-500">
×
</p>
<p className="mt-1 text-xs text-gray-500">{sourceSummary}</p>
</div>
{formLoading || !form ? (
<div className="px-5 py-8 text-sm text-gray-500">...</div>
) : (
<div className="space-y-5 px-5 py-5">
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<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-green-500"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="例: 3/17 元肥散布"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="任意"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-gray-50">
<tr>
<th className="w-48 border border-gray-200 px-4 py-3 text-left font-medium text-gray-700">
</th>
{matrixFertilizers.map((fertilizer) => (
<th
key={fertilizer.id}
className="min-w-[220px] border border-gray-200 px-3 py-3 text-center font-medium text-gray-700"
>
<div>{fertilizer.name}</div>
<div className="mt-1 text-[11px] font-normal text-gray-400">
{formatDisplay(getColumnTotal(fertilizer.id))}
</div>
</th>
))}
<th className="w-28 border border-gray-200 px-3 py-3 text-right font-medium text-gray-700">
</th>
</tr>
</thead>
<tbody>
{matrixFields.length === 0 ? (
<tr>
<td
colSpan={matrixFertilizers.length + 2}
className="border border-gray-200 px-4 py-8 text-center text-gray-400"
>
</td>
</tr>
) : (
matrixFields.map((field) => (
<tr key={field.id} className="hover:bg-gray-50">
<td className="border border-gray-200 px-4 py-3 align-top">
<div className="font-medium text-gray-900">{field.name}</div>
<div className="text-xs text-gray-400">{field.area_tan}</div>
</td>
{matrixFertilizers.map((fertilizer) => {
const candidate = candidateMap.get(candidateKey(field.id, fertilizer.id));
if (!candidate) {
return (
<td
key={fertilizer.id}
className="border border-gray-200 bg-gray-50 px-3 py-3 text-center text-xs text-gray-300"
>
-
</td>
);
}
return (
<td key={fertilizer.id} className="border border-gray-200 px-3 py-3 align-top">
<div className="flex items-center justify-between gap-3">
<div className="grid flex-1 grid-cols-2 gap-x-3 gap-y-1 text-[11px] leading-5 text-gray-500">
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400"></span>
<span>{formatDisplay(candidate.planned_bags)}</span>
</div>
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400">
{sourceType === 'plan' ? '計画残' : '未散布'}
</span>
<span>
{formatDisplay(
sourceType === 'plan'
? Math.max(toNumber(candidate.planned_bags) - toNumber(candidate.spread_bags_other), 0)
: Math.max(toNumber(candidate.delivered_bags) - toNumber(candidate.spread_bags_other), 0)
)}
</span>
</div>
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400"></span>
<span>{formatDisplay(candidate.delivered_bags)}</span>
</div>
<div className="whitespace-nowrap">
<span className="mr-1 text-gray-400"></span>
<span>{formatDisplay(candidate.spread_bags_other)}</span>
</div>
</div>
<input
type="number"
step="0.1"
value={getCellValue(field.id, fertilizer.id)}
onChange={(e) => handleItemChange(field.id, fertilizer.id, e.target.value)}
className="w-20 shrink-0 rounded border border-gray-300 px-2 py-1.5 text-right text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</td>
);
})}
<td className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700">
{formatDisplay(getRowTotal(field.id))}
</td>
</tr>
))
)}
</tbody>
{matrixFields.length > 0 && (
<tfoot className="bg-gray-50">
<tr>
<td className="border border-gray-200 px-4 py-3 font-medium text-gray-700"></td>
{matrixFertilizers.map((fertilizer) => (
<td
key={fertilizer.id}
className="border border-gray-200 px-3 py-3 text-right font-medium text-gray-700"
>
{formatDisplay(getColumnTotal(fertilizer.id))}
</td>
))}
<td className="border border-gray-200 px-3 py-3 text-right font-bold text-green-700">
{formatDisplay(totalInputBags)}
</td>
</tr>
</tfoot>
)}
</table>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
{selectedRows.length} / {formatDisplay(totalInputBags)}
</p>
<div className="flex items-center gap-3">
<button
onClick={closeEditor}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
</button>
<button
onClick={() => void handleSave()}
disabled={saving}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
</section>
)}
<section className="rounded-lg bg-white shadow-sm">
<div className="border-b px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900"></h2>
</div>
{loading ? (
<div className="px-5 py-8 text-sm text-gray-500">...</div>
) : sessions.length === 0 ? (
<div className="px-5 py-8 text-sm text-gray-400"></div>
) : (
<div className="overflow-x-auto">
<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-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" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sessions.map((session) => {
const totalBags = session.items.reduce((sum, item) => sum + toNumber(item.actual_bags), 0);
return (
<tr key={session.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-700">{session.date}</td>
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{session.name || '名称なし'}</div>
{session.notes && <div className="text-xs text-gray-400">{session.notes}</div>}
</td>
<td className="px-4 py-3 text-right text-gray-600">{session.items.length}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{formatDisplay(totalBags)}</td>
<td className="px-4 py-3 text-right text-gray-600">
{session.work_record_id ? `#${session.work_record_id}` : '-'}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => void openEditor(session)}
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => void handleDelete(session.id)}
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ChevronLeft, NotebookText } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { WorkRecord } from '@/types';
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_KEY = 'workRecordYear';
export default function WorkRecordsPage() {
const router = useRouter();
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
}
return CURRENT_YEAR;
});
const [records, setRecords] = useState<WorkRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem(YEAR_KEY, String(year));
void fetchRecords();
}, [year]);
const fetchRecords = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get(`/workrecords/?year=${year}`);
setRecords(res.data);
} catch (e) {
console.error(e);
setError('作業記録の読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
const moveToSource = (record: WorkRecord) => {
if (record.spreading_session) {
router.push(`/fertilizer/spreading?session=${record.spreading_session}`);
return;
}
if (record.delivery_plan_id) {
router.push(`/distribution/${record.delivery_plan_id}/edit`);
}
};
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-6xl px-4 py-8">
<div className="mb-6 flex items-center gap-3">
<button onClick={() => router.push('/fertilizer')} className="text-gray-500 hover:text-gray-700">
<ChevronLeft className="h-5 w-5" />
</button>
<NotebookText className="h-6 w-6 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<div className="mb-6 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-green-500"
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</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="overflow-hidden rounded-lg bg-white shadow-sm">
{loading ? (
<div className="px-5 py-8 text-sm text-gray-500">...</div>
) : records.length === 0 ? (
<div className="px-5 py-8 text-sm text-gray-400"></div>
) : (
<table className="w-full text-sm">
<thead className="border-b bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-700"></th>
<th className="px-4 py-3 text-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" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{records.map((record) => (
<tr key={record.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-700">{record.work_date}</td>
<td className="px-4 py-3 text-gray-700">{record.work_type_display}</td>
<td className="px-4 py-3 font-medium text-gray-900">{record.title}</td>
<td className="px-4 py-3 text-gray-600">
{record.spreading_session
? `散布実績 #${record.spreading_session}`
: record.delivery_plan_name
? `${record.delivery_plan_name}`
: '-'}
</td>
<td className="px-4 py-3 text-right">
{(record.spreading_session || record.delivery_plan_id) && (
<button
onClick={() => moveToSource(record)}
className="rounded border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</main>
</div>
);
}

View File

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

View File

@@ -140,7 +140,8 @@ export interface FertilizationEntry {
field_area_tan?: string;
fertilizer: number;
fertilizer_name?: string;
bags: number;
bags: number | string;
actual_bags?: string | null;
}
export interface FertilizationPlan {
@@ -154,6 +155,10 @@ export interface FertilizationPlan {
entries: FertilizationEntry[];
field_count: number;
fertilizer_count: number;
planned_total_bags: string;
spread_total_bags: string;
remaining_total_bags: string;
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
is_confirmed: boolean;
confirmed_at: string | null;
created_at: string;
@@ -180,6 +185,8 @@ export interface DeliveryTripItem {
fertilizer: number;
fertilizer_name: string;
bags: string;
spread_bags: string;
remaining_bags: string;
}
export interface DeliveryTrip {
@@ -187,6 +194,7 @@ export interface DeliveryTrip {
order: number;
name: string;
date: string | null;
work_record_id: number | null;
items: DeliveryTripItem[];
}
@@ -197,6 +205,7 @@ export interface DeliveryAllEntry {
fertilizer: number;
fertilizer_name: string;
bags: string;
actual_bags?: string | null;
}
export interface DeliveryPlan {
@@ -222,6 +231,59 @@ export interface DeliveryPlanListItem {
updated_at: string;
}
export interface SpreadingCandidate {
field: number;
field_name: string;
field_area_tan: string;
fertilizer: number;
fertilizer_name: string;
planned_bags: string;
delivered_bags: string;
spread_bags: string;
spread_bags_other: string;
current_session_bags: string;
remaining_bags: string;
}
export interface SpreadingSessionItem {
id: number;
field: number;
field_name: string;
fertilizer: number;
fertilizer_name: string;
actual_bags: string;
planned_bags_snapshot: string;
delivered_bags_snapshot: string;
}
export interface SpreadingSession {
id: number;
year: number;
date: string;
name: string;
notes: string;
work_record_id: number | null;
items: SpreadingSessionItem[];
created_at: string;
updated_at: string;
}
export interface WorkRecord {
id: number;
work_date: string;
work_type: 'fertilizer_delivery' | 'fertilizer_spreading';
work_type_display: string;
title: string;
year: number;
auto_created: boolean;
delivery_trip: number | null;
delivery_plan_id: number | null;
delivery_plan_name: string | null;
spreading_session: number | null;
created_at: string;
updated_at: string;
}
export interface MailSender {
id: number;
type: 'address' | 'domain';