Add fertilization plan merge workflow

This commit is contained in:
akira
2026-04-06 16:49:44 +09:00
parent c675b7b7ae
commit c90c6210e1
8 changed files with 475 additions and 6 deletions

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { FileDown, NotebookText, Pencil, Plus, Sprout, Trash2, Truck } from 'lucide-react';
import { FileDown, GitMerge, NotebookText, Pencil, Plus, Sprout, Trash2, Truck, X } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
@@ -36,6 +36,14 @@ export default function FertilizerPage() {
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [mergeSourcePlan, setMergeSourcePlan] = useState<FertilizationPlan | null>(null);
const [mergeTargets, setMergeTargets] = useState<
{ id: number; name: string; field_count: number; planned_total_bags: string; is_confirmed: boolean }[]
>([]);
const [mergeTargetId, setMergeTargetId] = useState<number | ''>('');
const [mergeLoading, setMergeLoading] = useState(false);
const [mergeSubmitting, setMergeSubmitting] = useState(false);
const [mergeError, setMergeError] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem('fertilizerYear', String(year));
@@ -83,6 +91,68 @@ export default function FertilizerPage() {
}
};
const openMergeDialog = async (plan: FertilizationPlan) => {
setMergeSourcePlan(plan);
setMergeTargets([]);
setMergeTargetId('');
setMergeError(null);
setMergeLoading(true);
try {
const res = await api.get(`/fertilizer/plans/${plan.id}/merge_targets/`);
setMergeTargets(res.data);
} catch (e) {
console.error(e);
setMergeError('マージ先候補の読み込みに失敗しました。');
} finally {
setMergeLoading(false);
}
};
const closeMergeDialog = () => {
if (mergeSubmitting) return;
setMergeSourcePlan(null);
setMergeTargets([]);
setMergeTargetId('');
setMergeError(null);
setMergeLoading(false);
};
const handleMerge = async () => {
if (!mergeSourcePlan || !mergeTargetId) {
setMergeError('マージ先の施肥計画を選択してください。');
return;
}
setMergeSubmitting(true);
setMergeError(null);
try {
await api.post(`/fertilizer/plans/${mergeSourcePlan.id}/merge_into/`, {
target_plan_id: mergeTargetId,
});
closeMergeDialog();
await fetchPlans();
} catch (e: unknown) {
const err = e as {
response?: {
data?: {
error?: string;
conflicts?: { field_name: string; fertilizer_name: string }[];
};
};
};
const conflicts = err.response?.data?.conflicts ?? [];
if (conflicts.length > 0) {
const details = conflicts
.map((conflict) => `${conflict.field_name} × ${conflict.fertilizer_name}`)
.join('、');
setMergeError(`${err.response?.data?.error ?? '競合があるためマージできません。'} ${details}`);
} else {
setMergeError(err.response?.data?.error ?? 'マージに失敗しました。');
}
} finally {
setMergeSubmitting(false);
}
};
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
return (
@@ -208,6 +278,16 @@ export default function FertilizerPage() {
<Pencil className="h-3.5 w-3.5" />
</button>
{plan.is_variety_change_plan && (
<button
onClick={() => openMergeDialog(plan)}
className="flex items-center gap-1 rounded border border-emerald-300 px-2.5 py-1.5 text-xs text-emerald-700 hover:bg-emerald-50"
title="既存計画へマージ"
>
<GitMerge className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => handleDelete(plan.id, plan.name)}
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"
@@ -225,6 +305,85 @@ export default function FertilizerPage() {
</div>
)}
</div>
{mergeSourcePlan && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-xl rounded-lg bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500">{mergeSourcePlan.name}</p>
</div>
<button onClick={closeMergeDialog} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-5 py-4">
<p className="text-sm text-gray-600">
×
</p>
{mergeError && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{mergeError}
</div>
)}
{mergeLoading ? (
<p className="text-sm text-gray-500">...</p>
) : mergeTargets.length === 0 ? (
<p className="text-sm text-gray-500"></p>
) : (
<div className="space-y-2">
{mergeTargets.map((target) => (
<label
key={target.id}
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 ${
target.is_confirmed ? 'border-gray-200 bg-gray-50 text-gray-400' : 'border-gray-300'
}`}
>
<input
type="radio"
name="merge-target"
value={target.id}
checked={mergeTargetId === target.id}
onChange={() => setMergeTargetId(target.id)}
disabled={target.is_confirmed}
className="mt-1"
/>
<div className="min-w-0 flex-1">
<div className="font-medium text-gray-800">{target.name}</div>
<div className="mt-1 text-xs text-gray-500">
{target.field_count} / {target.planned_total_bags}
{target.is_confirmed ? ' / 散布確定済みのため選択不可' : ''}
</div>
</div>
</label>
))}
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 border-t px-5 py-4">
<button
onClick={closeMergeDialog}
disabled={mergeSubmitting}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50"
>
</button>
<button
onClick={handleMerge}
disabled={mergeSubmitting || mergeLoading || !mergeTargetId}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:opacity-50"
>
{mergeSubmitting ? 'マージ中...' : 'マージ実行'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -176,6 +176,7 @@ export interface FertilizationPlan {
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
is_confirmed: boolean;
confirmed_at: string | null;
is_variety_change_plan: boolean;
created_at: string;
updated_at: string;
}