Files
keinasystem/frontend/src/app/distribution/_components/DistributionEditPage.tsx
Akira a331f8b30a 未割り当て圃場の圃場名が切れる問題を修正
w-32 truncate(128px固定)を flex-1 min-w-0 truncate に変更し、
利用可能な幅いっぱいに伸びるようにした。
ホバーで全文確認できるよう title 属性も追加。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 09:50:42 +09:00

652 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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="space-y-1">
{unassignedFields.map(fi => (
<div key={fi.id} className="flex items-center gap-2 text-sm">
<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>
);
}