施肥散布実績機能を実装し運搬・作業記録・在庫連携を追加
This commit is contained in:
747
frontend/src/app/fertilizer/spreading/page.tsx
Normal file
747
frontend/src/app/fertilizer/spreading/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user