Add fertilization plan merge workflow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user