分配計画を運搬計画に再設計: 軽トラ1回分を基本単位とする運搬回モデルを導入
実運用のワークフロー(複数施肥計画混在・軽トラ複数回・肥料指定)に合わせ、 旧 DistributionPlan/Group/GroupField を DeliveryPlan/Group/GroupField/Trip/TripItem に置き換え。 施肥計画への直接FK廃止→年度ベースで全施肥計画を横断。 回ごとの日付記録、圃場の回間移動、対象肥料フィルタ、回ごとPDF出力に対応。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import DistributionEditPage from '../../_components/DistributionEditPage';
|
||||
import DeliveryEditPage from '../../_components/DeliveryEditPage';
|
||||
|
||||
export default function DistributionEditRoute({ params }: { params: { id: string } }) {
|
||||
return <DistributionEditPage planId={Number(params.id)} />;
|
||||
export default function DeliveryEditRoute({ params }: { params: { id: string } }) {
|
||||
return <DeliveryEditPage planId={Number(params.id)} />;
|
||||
}
|
||||
|
||||
1061
frontend/src/app/distribution/_components/DeliveryEditPage.tsx
Normal file
1061
frontend/src/app/distribution/_components/DeliveryEditPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,651 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X, ChevronUp, ChevronDown, Pencil, Check } from 'lucide-react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { DistributionPlan, FertilizationPlan } from '@/types';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
// ローカル管理用のグループ型(ID未採番の新規グループも持てる)
|
||||
interface LocalGroup {
|
||||
tempId: string;
|
||||
name: string;
|
||||
order: number;
|
||||
fieldIds: number[];
|
||||
isRenamingName?: string; // 名前変更中の一時値
|
||||
}
|
||||
|
||||
interface FieldInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
area_tan: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
planId?: number; // 編集時のみ
|
||||
}
|
||||
|
||||
export default function DistributionEditPage({ planId }: Props) {
|
||||
const router = useRouter();
|
||||
const isEdit = planId !== undefined;
|
||||
|
||||
// 基本情報
|
||||
const [name, setName] = useState('');
|
||||
const [fertilizationPlanId, setFertilizationPlanId] = useState<number | ''>('');
|
||||
const [year] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return parseInt(localStorage.getItem('distributionYear') || String(CURRENT_YEAR), 10);
|
||||
}
|
||||
return CURRENT_YEAR;
|
||||
});
|
||||
|
||||
// 施肥計画一覧(セレクタ用)
|
||||
const [fertilizationPlans, setFertilizationPlans] = useState<FertilizationPlan[]>([]);
|
||||
// 選択中の施肥計画の詳細(肥料・entries)
|
||||
const [fertPlanDetail, setFertPlanDetail] = useState<DistributionPlan['fertilization_plan'] | null>(null);
|
||||
|
||||
// ローカルグループ状態
|
||||
const [groups, setGroups] = useState<LocalGroup[]>([]);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
|
||||
// UI状態
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ── 初期データ読み込み ──────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
// 施肥計画一覧を全年度取得(分配計画のベースになる)
|
||||
const res = await api.get('/fertilizer/plans/');
|
||||
setFertilizationPlans(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (isEdit && planId) {
|
||||
try {
|
||||
// 既存の分配計画を読み込む
|
||||
const detailRes = await api.get(`/fertilizer/distribution/${planId}/`);
|
||||
const detail: DistributionPlan = detailRes.data;
|
||||
setName(detail.name);
|
||||
setFertilizationPlanId(detail.fertilization_plan.id);
|
||||
setFertPlanDetail(detail.fertilization_plan);
|
||||
// グループを LocalGroup 形式に変換
|
||||
setGroups(
|
||||
detail.groups.map((g, i) => ({
|
||||
tempId: String(g.id),
|
||||
name: g.name,
|
||||
order: g.order ?? i,
|
||||
fieldIds: g.fields.map(f => f.id),
|
||||
}))
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
init();
|
||||
}, [planId]);
|
||||
|
||||
// 施肥計画が変わったら詳細を取得
|
||||
useEffect(() => {
|
||||
if (!fertilizationPlanId) {
|
||||
setFertPlanDetail(null);
|
||||
if (!isEdit) setGroups([]);
|
||||
return;
|
||||
}
|
||||
if (isEdit && fertPlanDetail?.id === fertilizationPlanId) return;
|
||||
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`);
|
||||
const data: FertilizationPlan = res.data;
|
||||
// FertilizationPlanForDistributionSerializer と同じ構造に合わせる
|
||||
const ferts = Array.from(
|
||||
new Map(
|
||||
data.entries.map(e => [e.fertilizer, { id: e.fertilizer, name: e.fertilizer_name || '' }])
|
||||
).values()
|
||||
).sort((a, b) => a.name.localeCompare(b.name));
|
||||
setFertPlanDetail({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
year: data.year,
|
||||
variety_name: data.variety_name,
|
||||
crop_name: data.crop_name,
|
||||
fertilizers: ferts,
|
||||
entries: data.entries.map(e => ({
|
||||
field: e.field,
|
||||
fertilizer: e.fertilizer,
|
||||
bags: String(e.bags),
|
||||
})),
|
||||
});
|
||||
if (!isEdit) setGroups([]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
fetchDetail();
|
||||
}, [fertilizationPlanId]);
|
||||
|
||||
// ── 計算ヘルパー ──────────────────────────────────────
|
||||
|
||||
// 全圃場一覧(施肥計画のentries に含まれる圃場)
|
||||
const allPlanFields: FieldInfo[] = (() => {
|
||||
if (!fertPlanDetail) return [];
|
||||
const seen = new Map<number, FieldInfo>();
|
||||
for (const e of fertPlanDetail.entries) {
|
||||
if (!seen.has(e.field)) {
|
||||
// field名は後述の fertilizationPlans から取る
|
||||
seen.set(e.field, { id: e.field, name: String(e.field), area_tan: '0' });
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
})();
|
||||
|
||||
// fertilizationPlans から field情報を取得(FertilizationPlanSerializer の entries に field_name が含まれる)
|
||||
const fieldInfoMap = (() => {
|
||||
const map = new Map<number, FieldInfo>();
|
||||
if (!fertPlanDetail) return map;
|
||||
const plan = fertilizationPlans.find(p => p.id === fertPlanDetail.id);
|
||||
if (plan) {
|
||||
for (const e of plan.entries) {
|
||||
if (e.field && !map.has(e.field)) {
|
||||
map.set(e.field, {
|
||||
id: e.field,
|
||||
name: e.field_name || String(e.field),
|
||||
area_tan: e.field_area_tan || '0',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
const getFieldInfo = (fieldId: number): FieldInfo =>
|
||||
fieldInfoMap.get(fieldId) ?? { id: fieldId, name: `圃場#${fieldId}`, area_tan: '0' };
|
||||
|
||||
// 割り当て済みフィールドIDセット
|
||||
const assignedFieldIds = new Set(groups.flatMap(g => g.fieldIds));
|
||||
|
||||
// 未割り当て圃場
|
||||
const unassignedFields = fertPlanDetail
|
||||
? Array.from(
|
||||
new Map(
|
||||
fertPlanDetail.entries
|
||||
.map(e => e.field)
|
||||
.filter(id => !assignedFieldIds.has(id))
|
||||
.map(id => [id, getFieldInfo(id)])
|
||||
).values()
|
||||
)
|
||||
: [];
|
||||
|
||||
// bags取得
|
||||
const getBags = (fieldId: number, fertilizerId: number): number => {
|
||||
if (!fertPlanDetail) return 0;
|
||||
const entry = fertPlanDetail.entries.find(
|
||||
e => e.field === fieldId && e.fertilizer === fertilizerId
|
||||
);
|
||||
return entry ? parseFloat(entry.bags) : 0;
|
||||
};
|
||||
|
||||
// グループごとの集計
|
||||
const groupSummaries = groups.map(g => {
|
||||
const fertTotals = (fertPlanDetail?.fertilizers || []).map(fert => ({
|
||||
fertilizerId: fert.id,
|
||||
fertilizerName: fert.name,
|
||||
total: g.fieldIds.reduce((sum, fId) => sum + getBags(fId, fert.id), 0),
|
||||
}));
|
||||
const rowTotal = fertTotals.reduce((s, f) => s + f.total, 0);
|
||||
return { ...g, fertTotals, rowTotal };
|
||||
});
|
||||
|
||||
// 未割り当てグループの集計
|
||||
const unassignedSummary = {
|
||||
fertTotals: (fertPlanDetail?.fertilizers || []).map(fert => ({
|
||||
fertilizerId: fert.id,
|
||||
fertilizerName: fert.name,
|
||||
total: unassignedFields.reduce((sum, f) => sum + getBags(f.id, fert.id), 0),
|
||||
})),
|
||||
rowTotal: 0 as number,
|
||||
};
|
||||
unassignedSummary.rowTotal = unassignedSummary.fertTotals.reduce((s, f) => s + f.total, 0);
|
||||
|
||||
// 肥料合計行
|
||||
const fertColumnTotals = (fertPlanDetail?.fertilizers || []).map(fert => {
|
||||
const groupTotal = groupSummaries.reduce(
|
||||
(sum, g) => sum + (g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0),
|
||||
0
|
||||
);
|
||||
const unassignedTotal = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
|
||||
return { id: fert.id, total: groupTotal + unassignedTotal };
|
||||
});
|
||||
const grandTotal = fertColumnTotals.reduce((s, f) => s + f.total, 0);
|
||||
|
||||
// ── グループ操作 ──────────────────────────────────────
|
||||
|
||||
const addGroup = () => {
|
||||
const n = newGroupName.trim();
|
||||
if (!n) return;
|
||||
if (groups.some(g => g.name === n)) {
|
||||
setSaveError(`グループ名「${n}」はすでに存在します`);
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
setGroups(prev => [
|
||||
...prev,
|
||||
{ tempId: crypto.randomUUID(), name: n, order: prev.length, fieldIds: [] },
|
||||
]);
|
||||
setNewGroupName('');
|
||||
};
|
||||
|
||||
const removeGroup = (tempId: string) => {
|
||||
setGroups(prev => prev.filter(g => g.tempId !== tempId));
|
||||
};
|
||||
|
||||
const moveGroup = (tempId: string, dir: -1 | 1) => {
|
||||
setGroups(prev => {
|
||||
const idx = prev.findIndex(g => g.tempId === tempId);
|
||||
if (idx < 0 || idx + dir < 0 || idx + dir >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[idx], next[idx + dir]] = [next[idx + dir], next[idx]];
|
||||
return next.map((g, i) => ({ ...g, order: i }));
|
||||
});
|
||||
};
|
||||
|
||||
const startRename = (tempId: string) => {
|
||||
setGroups(prev =>
|
||||
prev.map(g => (g.tempId === tempId ? { ...g, isRenamingName: g.name } : g))
|
||||
);
|
||||
};
|
||||
|
||||
const commitRename = (tempId: string) => {
|
||||
setGroups(prev =>
|
||||
prev.map(g => {
|
||||
if (g.tempId !== tempId) return g;
|
||||
const newName = (g.isRenamingName || '').trim();
|
||||
if (!newName || newName === g.name) return { ...g, isRenamingName: undefined };
|
||||
if (prev.some(other => other.tempId !== tempId && other.name === newName)) {
|
||||
setSaveError(`グループ名「${newName}」はすでに存在します`);
|
||||
return { ...g, isRenamingName: undefined };
|
||||
}
|
||||
return { ...g, name: newName, isRenamingName: undefined };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const assignFieldToGroup = (fieldId: number, groupTempId: string) => {
|
||||
setGroups(prev =>
|
||||
prev.map(g => {
|
||||
if (g.tempId === groupTempId) {
|
||||
return { ...g, fieldIds: [...g.fieldIds, fieldId] };
|
||||
}
|
||||
return { ...g, fieldIds: g.fieldIds.filter(id => id !== fieldId) };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const removeFieldFromGroup = (fieldId: number, groupTempId: string) => {
|
||||
setGroups(prev =>
|
||||
prev.map(g =>
|
||||
g.tempId === groupTempId ? { ...g, fieldIds: g.fieldIds.filter(id => id !== fieldId) } : g
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// ── 保存 ──────────────────────────────────────────────
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveError(null);
|
||||
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
|
||||
if (!fertilizationPlanId) { setSaveError('施肥計画を選択してください'); return; }
|
||||
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
fertilization_plan_id: fertilizationPlanId,
|
||||
groups: groups.map((g, i) => ({
|
||||
name: g.name,
|
||||
order: i,
|
||||
field_ids: g.fieldIds,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.put(`/fertilizer/distribution/${planId}/`, payload);
|
||||
} else {
|
||||
await api.post('/fertilizer/distribution/', payload);
|
||||
}
|
||||
setSaving(false);
|
||||
router.push('/distribution');
|
||||
} catch (e: unknown) {
|
||||
setSaving(false);
|
||||
const axiosErr = e as { response?: { data?: unknown } };
|
||||
const errData = axiosErr?.response?.data;
|
||||
setSaveError(errData ? JSON.stringify(errData) : '保存に失敗しました');
|
||||
}
|
||||
};
|
||||
|
||||
// ── レンダリング ──────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<main className="max-w-5xl mx-auto px-4 py-8 text-gray-500 text-sm">読み込み中...</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fertilizers = fertPlanDetail?.fertilizers || [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||
{/* ヘッダー */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<button onClick={() => router.push('/distribution')} className="text-sm text-gray-500 hover:text-gray-700">
|
||||
← 分配計画一覧
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-6">
|
||||
{isEdit ? '分配計画を編集' : '分配計画を新規作成'}
|
||||
</h1>
|
||||
|
||||
{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">
|
||||
<span className="flex-1">{saveError}</span>
|
||||
<button onClick={() => setSaveError(null)}><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本情報 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-48">
|
||||
<label className="text-sm font-medium text-gray-700 whitespace-nowrap">計画名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="例: 2025年コシヒカリ 分配計画"
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-64">
|
||||
<label className="text-sm font-medium text-gray-700 whitespace-nowrap">施肥計画</label>
|
||||
<select
|
||||
value={fertilizationPlanId}
|
||||
onChange={e => setFertilizationPlanId(e.target.value ? Number(e.target.value) : '')}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="">-- 選択 --</option>
|
||||
{fertilizationPlans.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.year}年 {p.name}({p.crop_name}/{p.variety_name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!fertPlanDetail ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center text-gray-400 text-sm">
|
||||
施肥計画を選択するとグループ割り当て画面が表示されます
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* グループ割り当て */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-4">グループ割り当て</h2>
|
||||
|
||||
{/* 新規グループ追加 */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupName}
|
||||
onChange={e => setNewGroupName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addGroup()}
|
||||
placeholder="新規グループ名"
|
||||
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500 w-48"
|
||||
/>
|
||||
<button
|
||||
onClick={addGroup}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
グループを追加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* グループ一覧 */}
|
||||
<div className="space-y-3">
|
||||
{groups.map((group, idx) => (
|
||||
<div key={group.tempId} className="border border-gray-200 rounded-md overflow-hidden">
|
||||
{/* グループヘッダー */}
|
||||
<div className="flex items-center gap-2 bg-green-50 px-3 py-2">
|
||||
{group.isRenamingName !== undefined ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={group.isRenamingName}
|
||||
onChange={e =>
|
||||
setGroups(prev =>
|
||||
prev.map(g =>
|
||||
g.tempId === group.tempId ? { ...g, isRenamingName: e.target.value } : g
|
||||
)
|
||||
)
|
||||
}
|
||||
onKeyDown={e => e.key === 'Enter' && commitRename(group.tempId)}
|
||||
className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => commitRename(group.tempId)}
|
||||
className="p-1 text-green-700 hover:text-green-900"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium text-sm text-gray-800 flex-1">{group.name}</span>
|
||||
<button
|
||||
onClick={() => moveGroup(group.tempId, -1)}
|
||||
disabled={idx === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveGroup(group.tempId, 1)}
|
||||
disabled={idx === groups.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startRename(group.tempId)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
title="名前変更"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeGroup(group.tempId)}
|
||||
className="p-1 text-gray-400 hover:text-red-600"
|
||||
title="グループを削除"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* グループ内圃場 */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{group.fieldIds.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic">圃場が割り当てられていません</p>
|
||||
) : (
|
||||
group.fieldIds.map(fId => {
|
||||
const fi = getFieldInfo(fId);
|
||||
const bags = fertilizers.map(fert => getBags(fId, fert.id));
|
||||
return (
|
||||
<div key={fId} className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
onClick={() => removeFieldFromGroup(fId, group.tempId)}
|
||||
className="text-gray-400 hover:text-red-500 flex-shrink-0"
|
||||
title="グループから外す"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="text-gray-800 font-medium w-32 truncate">{fi.name}</span>
|
||||
<span className="text-gray-400 text-xs w-16 text-right">{fi.area_tan}反</span>
|
||||
<span className="text-gray-400 text-xs">
|
||||
{fertilizers.map((fert, i) => (
|
||||
<span key={fert.id}>
|
||||
{i > 0 && ' / '}
|
||||
{fert.name}: {bags[i].toFixed(2)}袋
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 未割り当て圃場 */}
|
||||
{unassignedFields.length > 0 && (
|
||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase mb-2">未割り当て圃場</p>
|
||||
<div className="rounded border border-gray-200 overflow-hidden">
|
||||
{unassignedFields.map((fi, idx) => (
|
||||
<div key={fi.id} className={`flex items-center gap-2 text-sm px-3 py-1.5 ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}`}>
|
||||
<span className="text-gray-800 font-medium flex-1 min-w-0 truncate" title={fi.name}>{fi.name}</span>
|
||||
<span className="text-gray-400 text-xs w-16 shrink-0 text-right">{fi.area_tan}反</span>
|
||||
<select
|
||||
defaultValue=""
|
||||
onChange={e => {
|
||||
if (e.target.value) {
|
||||
assignFieldToGroup(fi.id, e.target.value);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500"
|
||||
>
|
||||
<option value="">グループに追加...</option>
|
||||
{groups.map(g => (
|
||||
<option key={g.tempId} value={g.tempId}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 集計プレビュー */}
|
||||
{(groups.length > 0 || unassignedFields.length > 0) && fertilizers.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">集計プレビュー</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-medium text-gray-600 text-xs">グループ</th>
|
||||
{fertilizers.map(fert => (
|
||||
<th key={fert.id} className="border border-gray-200 bg-gray-50 px-3 py-2 text-right font-medium text-gray-600 text-xs">
|
||||
{fert.name}
|
||||
</th>
|
||||
))}
|
||||
<th className="border border-gray-200 bg-gray-50 px-3 py-2 text-right font-medium text-gray-600 text-xs">合計(袋)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupSummaries.map(g => (
|
||||
<tr key={g.tempId} className="hover:bg-green-50">
|
||||
<td className="border border-gray-200 px-3 py-2 font-medium text-gray-800">{g.name}</td>
|
||||
{fertilizers.map(fert => {
|
||||
const t = g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
|
||||
return (
|
||||
<td key={fert.id} className="border border-gray-200 px-3 py-2 text-right text-gray-700">
|
||||
{t > 0 ? t.toFixed(2) : <span className="text-gray-300">-</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="border border-gray-200 px-3 py-2 text-right font-medium text-gray-800">
|
||||
{g.rowTotal.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{unassignedSummary.rowTotal > 0 && (
|
||||
<tr className="bg-yellow-50">
|
||||
<td className="border border-gray-200 px-3 py-2 text-gray-500 italic">未割り当て</td>
|
||||
{fertilizers.map(fert => {
|
||||
const t = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
|
||||
return (
|
||||
<td key={fert.id} className="border border-gray-200 px-3 py-2 text-right text-gray-500">
|
||||
{t > 0 ? t.toFixed(2) : <span className="text-gray-300">-</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="border border-gray-200 px-3 py-2 text-right text-gray-500">
|
||||
{unassignedSummary.rowTotal.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="font-bold bg-gray-50">
|
||||
<td className="border border-gray-200 px-3 py-2 text-gray-800">合計</td>
|
||||
{fertColumnTotals.map(f => (
|
||||
<td key={f.id} className="border border-gray-200 px-3 py-2 text-right text-gray-800">
|
||||
{f.total.toFixed(2)}
|
||||
</td>
|
||||
))}
|
||||
<td className="border border-gray-200 px-3 py-2 text-right text-gray-800">
|
||||
{grandTotal.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* フッターボタン */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/distribution')}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-100 text-gray-700"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import DistributionEditPage from '../_components/DistributionEditPage';
|
||||
import DeliveryEditPage from '../_components/DeliveryEditPage';
|
||||
|
||||
export default function DistributionNewPage() {
|
||||
return <DistributionEditPage />;
|
||||
export default function DeliveryNewPage() {
|
||||
return <DeliveryEditPage />;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FlaskConical, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
|
||||
import { Truck, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { DistributionPlanListItem } from '@/types';
|
||||
import { DeliveryPlanListItem } from '@/types';
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
const YEAR_KEY = 'distributionYear';
|
||||
|
||||
export default function DistributionListPage() {
|
||||
export default function DeliveryListPage() {
|
||||
const router = useRouter();
|
||||
const [year, setYear] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -18,7 +18,7 @@ export default function DistributionListPage() {
|
||||
}
|
||||
return CURRENT_YEAR;
|
||||
});
|
||||
const [plans, setPlans] = useState<DistributionPlanListItem[]>([]);
|
||||
const [plans, setPlans] = useState<DeliveryPlanListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function DistributionListPage() {
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/distribution/?year=${year}`);
|
||||
const res = await api.get(`/fertilizer/delivery/?year=${year}`);
|
||||
setPlans(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -44,7 +44,7 @@ export default function DistributionListPage() {
|
||||
const handleDelete = async (id: number) => {
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.delete(`/fertilizer/distribution/${id}/`);
|
||||
await api.delete(`/fertilizer/delivery/${id}/`);
|
||||
setPlans(prev => prev.filter(p => p.id !== id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -54,11 +54,11 @@ export default function DistributionListPage() {
|
||||
|
||||
const handlePdf = async (id: number, planName: string) => {
|
||||
try {
|
||||
const res = await api.get(`/fertilizer/distribution/${id}/pdf/`, { responseType: 'blob' });
|
||||
const res = await api.get(`/fertilizer/delivery/${id}/pdf/`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `distribution_${planName}.pdf`;
|
||||
a.download = `delivery_${planName}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
@@ -72,8 +72,8 @@ export default function DistributionListPage() {
|
||||
<main className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<FlaskConical className="h-7 w-7 text-green-700" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">分配計画</h1>
|
||||
<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')}
|
||||
@@ -109,9 +109,9 @@ export default function DistributionListPage() {
|
||||
<p className="text-gray-500 text-sm">読み込み中...</p>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<FlaskConical className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-lg font-medium mb-1">{year}年の分配計画はありません</p>
|
||||
<p className="text-sm mb-6">施肥計画を元に分配計画を作成できます</p>
|
||||
<Truck className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-lg font-medium mb-1">{year}年の運搬計画はありません</p>
|
||||
<p className="text-sm mb-6">施肥計画を元に運搬計画を作成できます</p>
|
||||
<button
|
||||
onClick={() => router.push('/distribution/new')}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
|
||||
@@ -125,10 +125,8 @@ export default function DistributionListPage() {
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">計画名</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">施肥計画</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">作物/品種</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">グループ数</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">圃場数</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">回数</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -136,10 +134,8 @@ export default function DistributionListPage() {
|
||||
{plans.map(plan => (
|
||||
<tr key={plan.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{plan.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{plan.fertilization_plan_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{plan.crop_name} / {plan.variety_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.group_count}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.field_count}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.trip_count}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user