完璧に動作しています。

テスト	結果
確定取消 API	 is_confirmed: false, confirmed_at: null
USE トランザクション削除	 current_stock が 27.5→32 に復帰
引当再作成	 reserved_stock = 5.000 に復帰
追加した変更:

stock_service.py:81-93 — unconfirm_spreading(): USE削除→確定フラグリセット→引当再作成
fertilizer/views.py — unconfirm アクション(POST /api/fertilizer/plans/{id}/unconfirm/)
fertilizer/page.tsx — 一覧に「確定取消」ボタン(確定済み計画のみ表示)
FertilizerEditPage.tsx — 編集画面ヘッダーに「確定取消」ボタン + 在庫情報再取得
This commit is contained in:
Akira
2026-03-15 13:28:02 +09:00
parent 42b11a5df8
commit 72b4d670fe
18 changed files with 807 additions and 60 deletions

View File

@@ -0,0 +1,204 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Loader2, X } from 'lucide-react';
import { api } from '@/lib/api';
import { FertilizationPlan } from '@/types';
interface ConfirmSpreadingModalProps {
plan: FertilizationPlan | null;
isOpen: boolean;
onClose: () => void;
onConfirmed: () => Promise<void> | void;
}
type ActualMap = Record<string, string>;
const entryKey = (fieldId: number, fertilizerId: number) => `${fieldId}-${fertilizerId}`;
export default function ConfirmSpreadingModal({
plan,
isOpen,
onClose,
onConfirmed,
}: ConfirmSpreadingModalProps) {
const [actuals, setActuals] = useState<ActualMap>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen || !plan) {
return;
}
const nextActuals: ActualMap = {};
plan.entries.forEach((entry) => {
nextActuals[entryKey(entry.field, entry.fertilizer)] = String(entry.bags);
});
setActuals(nextActuals);
setError(null);
}, [isOpen, plan]);
const groupedEntries = useMemo(() => {
if (!plan) {
return [];
}
const groups = new Map<
number,
{ fertilizerId: number; fertilizerName: string; entries: FertilizationPlan['entries'] }
>();
plan.entries.forEach((entry) => {
const existing = groups.get(entry.fertilizer);
if (existing) {
existing.entries.push(entry);
return;
}
groups.set(entry.fertilizer, {
fertilizerId: entry.fertilizer,
fertilizerName: entry.fertilizer_name ?? `肥料ID:${entry.fertilizer}`,
entries: [entry],
});
});
return Array.from(groups.values()).sort((a, b) =>
a.fertilizerName.localeCompare(b.fertilizerName, 'ja')
);
}, [plan]);
if (!isOpen || !plan) {
return null;
}
const handleConfirm = async () => {
setSaving(true);
setError(null);
try {
await api.post(`/fertilizer/plans/${plan.id}/confirm_spreading/`, {
entries: plan.entries.map((entry) => ({
field_id: entry.field,
fertilizer_id: entry.fertilizer,
actual_bags: Number(actuals[entryKey(entry.field, entry.fertilizer)] || 0),
})),
});
await onConfirmed();
onClose();
} catch (e: unknown) {
console.error(e);
const detail =
typeof e === 'object' &&
e !== null &&
'response' in e &&
typeof e.response === 'object' &&
e.response !== null &&
'data' in e.response
? JSON.stringify(e.response.data)
: '散布確定に失敗しました。';
setError(detail);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 px-4">
<div className="max-h-[90vh] w-full max-w-4xl overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">
: {plan.name}
</h2>
<p className="text-sm text-gray-500">
reserve use
</p>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[calc(90vh-144px)] overflow-y-auto px-6 py-5">
{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>
)}
<div className="space-y-6">
{groupedEntries.map((group) => (
<section key={group.fertilizerId}>
<h3 className="mb-3 text-base font-semibold text-gray-800">
: {group.fertilizerName}
</h3>
<div className="overflow-hidden rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr className="border-b border-gray-200">
<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>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{group.entries.map((entry) => {
const key = entryKey(entry.field, entry.fertilizer);
return (
<tr key={key}>
<td className="px-4 py-3 text-gray-800">
{entry.field_name ?? `圃場ID:${entry.field}`}
</td>
<td className="px-4 py-3 text-right text-gray-600">
{entry.bags}
</td>
<td className="px-4 py-3 text-right">
<input
type="number"
min="0"
step="0.1"
value={actuals[key] ?? ''}
onChange={(e) =>
setActuals((prev) => ({
...prev,
[key]: e.target.value,
}))
}
className="w-24 rounded-lg border border-gray-300 px-3 py-2 text-right text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
))}
</div>
</div>
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
<button
onClick={onClose}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-100"
>
</button>
<button
onClick={handleConfirm}
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
</button>
</div>
</div>
</div>
);
}

View File

@@ -2,10 +2,10 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ChevronLeft, Plus, X, Calculator, Save, FileDown } from 'lucide-react';
import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Undo2 } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Fertilizer, FertilizationPlan, Crop, Field } from '@/types';
import { Crop, FertilizationPlan, Fertilizer, Field, StockSummary } from '@/types';
type CalcMethod = 'per_tan' | 'even' | 'nitrogen';
@@ -63,6 +63,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
const [calcMatrix, setCalcMatrix] = useState<Matrix>({});
const [adjusted, setAdjusted] = useState<Matrix>({});
const [roundedColumns, setRoundedColumns] = useState<Set<number>>(new Set());
const [stockByMaterialId, setStockByMaterialId] = useState<Record<number, StockSummary>>({});
const [initialPlanTotals, setInitialPlanTotals] = useState<Record<number, number>>({});
const [isConfirmed, setIsConfirmed] = useState(false);
const [confirmedAt, setConfirmedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
@@ -71,15 +75,26 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 初期データ取得
useEffect(() => {
const init = async () => {
setSaveError(null);
try {
const [cropsRes, fertsRes, fieldsRes] = await Promise.all([
const [cropsRes, fertsRes, fieldsRes, stockRes] = await Promise.all([
api.get('/plans/crops/'),
api.get('/fertilizer/fertilizers/'),
api.get('/fields/?ordering=display_order,id'),
api.get('/materials/fertilizer-stock/'),
]);
setCrops(cropsRes.data);
setAllFertilizers(fertsRes.data);
setAllFields(fieldsRes.data);
setStockByMaterialId(
stockRes.data.reduce(
(acc: Record<number, StockSummary>, summary: StockSummary) => {
acc[summary.material_id] = summary;
return acc;
},
{}
)
);
if (!isNew && planId) {
const planRes = await api.get(`/fertilizer/plans/${planId}/`);
@@ -87,6 +102,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
setName(plan.name);
setYear(plan.year);
setVarietyId(plan.variety);
setIsConfirmed(plan.is_confirmed);
setConfirmedAt(plan.confirmed_at);
const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer)));
const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id));
@@ -110,6 +127,12 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
newAdjusted[e.field][e.fertilizer] = String(e.bags);
});
setAdjusted(newAdjusted);
setInitialPlanTotals(
plan.entries.reduce((acc: Record<number, number>, entry) => {
acc[entry.fertilizer] = (acc[entry.fertilizer] ?? 0) + Number(entry.bags);
return acc;
}, {})
);
// 保存済み calc_settings でページ開時に自動計算してラベル用 calcMatrix を生成
const validSettings = plan.calc_settings?.filter((s) => s.param) ?? [];
@@ -142,7 +165,9 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: unknown } };
console.error('初期データ取得エラー:', err);
alert(`データの読み込みに失敗しました (${err.response?.status ?? 'network error'})\n${JSON.stringify(err.response?.data ?? '')}`);
setSaveError(
`データの読み込みに失敗しました (${err.response?.status ?? 'network error'}): ${JSON.stringify(err.response?.data ?? '')}`
);
} finally {
setLoading(false);
}
@@ -170,6 +195,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 肥料追加・削除
const addFertilizer = (fert: Fertilizer) => {
if (isConfirmed) return;
if (planFertilizers.find((f) => f.id === fert.id)) return;
setPlanFertilizers((prev) => [...prev, fert]);
setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]);
@@ -177,6 +203,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
};
const removeFertilizer = (id: number) => {
if (isConfirmed) return;
setPlanFertilizers((prev) => prev.filter((f) => f.id !== id));
setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id));
const dropCol = (m: Matrix): Matrix => {
@@ -195,12 +222,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 圃場追加・削除
const addField = (field: Field) => {
if (isConfirmed) return;
if (selectedFields.find((f) => f.id === field.id)) return;
setSelectedFields((prev) => [...prev, field]);
setShowFieldPicker(false);
};
const removeField = (id: number) => {
if (isConfirmed) return;
setSelectedFields((prev) => prev.filter((f) => f.id !== id));
setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; });
setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; });
@@ -210,8 +239,16 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 自動計算
const runCalc = async (setting: CalcSetting) => {
if (!setting.param) return alert('パラメータを入力してください');
if (selectedFields.length === 0) return alert('対象圃場を選択してください');
if (isConfirmed) return;
if (!setting.param) {
setSaveError('パラメータを入力してください');
return;
}
if (selectedFields.length === 0) {
setSaveError('対象圃場を選択してください');
return;
}
setSaveError(null);
const targetFields = calcNewOnly
? selectedFields.filter((f) => {
@@ -222,7 +259,10 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
})
: selectedFields;
if (targetFields.length === 0) return alert('未入力の圃場がありません。「全圃場」で実行してください。');
if (targetFields.length === 0) {
setSaveError('未入力の圃場がありません。「全圃場」で実行してください。');
return;
}
try {
const res = await api.post('/fertilizer/calculate/', {
@@ -246,7 +286,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// adjusted は保持するテキストボックスにDB/確定値を維持し、ラベルに計算結果を表示)
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
alert(err.response?.data?.error ?? '計算に失敗しました');
setSaveError(err.response?.data?.error ?? '計算に失敗しました');
}
};
@@ -258,6 +298,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── セル更新adjusted を更新)
const updateCell = (fieldId: number, fertId: number, value: string) => {
if (isConfirmed) return;
setAdjusted((prev) => {
const next = { ...prev };
if (!next[fieldId]) next[fieldId] = {};
@@ -268,6 +309,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
// ─── 列単位で四捨五入 / 元に戻す(トグル)
const roundColumn = (fertId: number) => {
if (isConfirmed) return;
if (roundedColumns.has(fertId)) {
// 元に戻す: adjusted からこの列を削除 → calc値が再び表示される
setAdjusted((prev) => {
@@ -318,10 +360,30 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
selectedFields.reduce((sum, f) => sum + effectiveValue(f.id, fertId), 0);
const grandTotal = planFertilizers.reduce((sum, f) => sum + colTotal(f.id), 0);
const getNumericValue = (value: string | null | undefined) => {
const parsed = parseFloat(value ?? '0');
return isNaN(parsed) ? 0 : parsed;
};
const getStockInfo = (fertilizer: Fertilizer) =>
fertilizer.material_id ? stockByMaterialId[fertilizer.material_id] ?? null : null;
const getPlanAvailableStock = (fertilizer: Fertilizer) => {
const stock = getStockInfo(fertilizer);
if (!stock) return null;
return getNumericValue(stock.available_stock) + (initialPlanTotals[fertilizer.id] ?? 0);
};
const getPlanShortage = (fertilizer: Fertilizer) => {
const available = getPlanAvailableStock(fertilizer);
if (available === null) return 0;
return Math.max(colTotal(fertilizer.id) - available, 0);
};
// ─── 保存adjusted 優先、なければ calc 値を使用)
const handleSave = async () => {
setSaveError(null);
if (isConfirmed) {
setSaveError('確定済みの施肥計画は編集できません。');
return;
}
if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
if (!varietyId) { setSaveError('品種を選択してください'); return; }
if (selectedFields.length === 0) { setSaveError('圃場を1つ以上選択してください'); return; }
@@ -362,9 +424,35 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
}
};
// ─── 確定取消
const handleUnconfirm = async () => {
if (!planId) return;
setSaveError(null);
try {
await api.post(`/fertilizer/plans/${planId}/unconfirm/`);
setIsConfirmed(false);
setConfirmedAt(null);
// 引当が再作成されるので在庫情報を再取得
const stockRes = await api.get('/materials/fertilizer-stock/');
setStockByMaterialId(
stockRes.data.reduce(
(acc: Record<number, StockSummary>, summary: StockSummary) => {
acc[summary.material_id] = summary;
return acc;
},
{}
)
);
} catch (e) {
console.error(e);
setSaveError('確定取消に失敗しました');
}
};
// ─── PDF出力
const handlePdf = async () => {
if (!planId) return;
setSaveError(null);
try {
const res = await api.get(`/fertilizer/plans/${planId}/pdf/`, { responseType: 'blob' });
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
@@ -374,7 +462,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
a.click();
URL.revokeObjectURL(url);
} catch (e) {
alert('PDF出力に失敗しました');
console.error(e);
setSaveError('PDF出力に失敗しました');
}
};
@@ -406,6 +495,15 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
</h1>
</div>
<div className="flex items-center gap-2">
{!isNew && isConfirmed && (
<button
onClick={handleUnconfirm}
className="flex items-center gap-2 px-4 py-2 border border-amber-300 rounded-lg text-sm text-amber-700 hover:bg-amber-50"
>
<Undo2 className="h-4 w-4" />
</button>
)}
{!isNew && (
<button
onClick={handlePdf}
@@ -417,11 +515,11 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
)}
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
disabled={saving || isConfirmed}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'}
{isConfirmed ? '確定済み' : saving ? '保存中...' : '保存'}
</button>
</div>
</div>
@@ -435,6 +533,16 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
</div>
)}
{isConfirmed && (
<div className="mb-4 flex items-start gap-2 bg-sky-50 border border-sky-300 text-sky-800 rounded-lg px-4 py-3 text-sm">
<span className="font-bold shrink-0">i</span>
<span>
{confirmedAt ? ` 確定日時: ${new Date(confirmedAt).toLocaleString('ja-JP')}` : ''}
</span>
</div>
)}
{/* 基本情報 */}
<div className="bg-white rounded-lg shadow p-4 mb-4 flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-48">
@@ -444,6 +552,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例: 2025年度 コシヒカリ 元肥"
disabled={isConfirmed}
/>
</div>
<div>
@@ -452,6 +561,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
disabled={isConfirmed}
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
@@ -462,6 +572,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
value={varietyId}
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')}
disabled={isConfirmed}
>
<option value=""></option>
{crops.map((crop) => (
@@ -487,7 +598,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
</h2>
<button
onClick={() => setShowFieldPicker(true)}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
disabled={isConfirmed}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="h-3 w-3" />
</button>
@@ -504,7 +616,11 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="flex items-center gap-1 bg-green-50 border border-green-200 rounded-full px-3 py-1 text-xs text-green-800"
>
{f.name}{f.area_tan}
<button onClick={() => removeField(f.id)} className="text-green-400 hover:text-red-500">
<button
onClick={() => removeField(f.id)}
disabled={isConfirmed}
className="text-green-400 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
>
<X className="h-3 w-3" />
</button>
</span>
@@ -522,13 +638,15 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
type="checkbox"
checked={calcNewOnly}
onChange={(e) => setCalcNewOnly(e.target.checked)}
disabled={isConfirmed}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</label>
<button
onClick={() => setShowFertPicker(true)}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1"
disabled={isConfirmed}
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 border border-green-300 rounded px-2 py-1 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="h-3 w-3" />
</button>
@@ -550,6 +668,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
className="border border-gray-300 rounded px-2 py-1 text-xs"
value={setting.method}
onChange={(e) => updateCalcSetting(fert.id, 'method', e.target.value)}
disabled={isConfirmed}
>
{Object.entries(METHOD_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
@@ -562,17 +681,20 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
value={setting.param}
onChange={(e) => updateCalcSetting(fert.id, 'param', e.target.value)}
placeholder="値"
disabled={isConfirmed}
/>
<span className="text-xs text-gray-500 w-24">{METHOD_UNIT[setting.method]}</span>
<button
onClick={() => runCalc(setting)}
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100"
disabled={isConfirmed}
className="flex items-center gap-1 text-xs bg-blue-50 border border-blue-300 text-blue-700 rounded px-3 py-1 hover:bg-blue-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Calculator className="h-3 w-3" />
</button>
<button
onClick={() => removeFertilizer(fert.id)}
className="ml-auto text-gray-300 hover:text-red-500"
disabled={isConfirmed}
className="ml-auto text-gray-300 hover:text-red-500 disabled:opacity-40 disabled:cursor-not-allowed"
>
<X className="h-4 w-4" />
</button>
@@ -593,18 +715,44 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<th className="text-right px-3 py-3 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">()</th>
{planFertilizers.map((f) => {
const isRounded = roundedColumns.has(f.id);
const stock = getStockInfo(f);
const planAvailable = getPlanAvailableStock(f);
const shortage = getPlanShortage(f);
return (
<th key={f.id} className="text-center px-3 py-2 border border-gray-200 font-medium text-gray-700 whitespace-nowrap">
{f.name}
<div>{f.name}</div>
{stock ? (
<div className="mt-1 space-y-0.5 text-[11px] font-normal leading-4">
<div className="text-gray-500">
{stock.current_stock}{stock.stock_unit_display}
{planAvailable !== null && (
<span className="ml-1">
/ {planAvailable.toFixed(2)}{stock.stock_unit_display}
</span>
)}
</div>
<div className={shortage > 0 ? 'text-red-600' : 'text-gray-500'}>
{colTotal(f.id).toFixed(2)}{stock.stock_unit_display}
{shortage > 0 && (
<span className="ml-1">/ {shortage.toFixed(2)}{stock.stock_unit_display}</span>
)}
</div>
</div>
) : (
<div className="mt-1 text-[11px] font-normal text-amber-600">
</div>
)}
<span className="flex items-center justify-center gap-1.5 text-xs font-normal text-gray-400 mt-0.5">
<button
onClick={() => roundColumn(f.id)}
disabled={isConfirmed}
className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold leading-none ${
isRounded
? 'bg-amber-100 text-amber-600 hover:bg-amber-200'
: 'bg-blue-100 text-blue-500 hover:bg-blue-200'
}`}
} disabled:opacity-40 disabled:cursor-not-allowed`}
title={isRounded ? '元の計算値に戻す' : '四捨五入して整数に丸める'}
>
{isRounded ? '↩' : '≈'}
@@ -641,6 +789,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
value={inputValue}
onChange={(e) => updateCell(field.id, fert.id, e.target.value)}
placeholder="-"
disabled={isConfirmed}
/>
</div>
</td>
@@ -689,7 +838,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<button
key={f.id}
onClick={() => addField(f)}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between"
disabled={isConfirmed}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
>
<span>{f.name}</span>
<span className="text-gray-400">{f.area_tan}</span>
@@ -703,7 +853,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<button
key={f.id}
onClick={() => addField(f)}
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between"
disabled={isConfirmed}
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex justify-between disabled:opacity-40 disabled:cursor-not-allowed"
>
<span>{f.name}</span>
<span className="text-gray-400">{f.area_tan}</span>
@@ -730,7 +881,8 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
<button
key={f.id}
onClick={() => addFertilizer(f)}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm"
disabled={isConfirmed}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
>
<span className="font-medium">{f.name}</span>
{f.maker && <span className="ml-2 text-gray-400 text-xs">{f.maker}</span>}

View File

@@ -15,6 +15,8 @@ const emptyForm = (): Omit<Fertilizer, 'id'> => ({
phosphorus_pct: null,
potassium_pct: null,
notes: null,
material: null,
material_id: null,
});
export default function FertilizerMastersPage() {
@@ -55,6 +57,8 @@ export default function FertilizerMastersPage() {
phosphorus_pct: f.phosphorus_pct,
potassium_pct: f.potassium_pct,
notes: f.notes,
material: f.material,
material_id: f.material_id,
});
setEditingId(f.id);
};

View File

@@ -2,7 +2,9 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Pencil, Trash2, FileDown, Sprout } from 'lucide-react';
import { Plus, Pencil, Trash2, FileDown, Sprout, BadgeCheck, Undo2 } from 'lucide-react';
import ConfirmSpreadingModal from './_components/ConfirmSpreadingModal';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { FertilizationPlan } from '@/types';
@@ -21,6 +23,8 @@ export default function FertilizerPage() {
const [plans, setPlans] = useState<FertilizationPlan[]>([]);
const [loading, setLoading] = useState(true);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [confirmTarget, setConfirmTarget] = useState<FertilizationPlan | null>(null);
useEffect(() => {
localStorage.setItem('fertilizerYear', String(year));
@@ -41,6 +45,7 @@ export default function FertilizerPage() {
const handleDelete = async (id: number, name: string) => {
setDeleteError(null);
setActionError(null);
try {
await api.delete(`/fertilizer/plans/${id}/`);
await fetchPlans();
@@ -50,7 +55,19 @@ export default function FertilizerPage() {
}
};
const handleUnconfirm = async (id: number, name: string) => {
setActionError(null);
try {
await api.post(`/fertilizer/plans/${id}/unconfirm/`);
await fetchPlans();
} catch (e) {
console.error(e);
setActionError(`${name}」の確定取消に失敗しました`);
}
};
const handlePdf = async (id: number, name: string) => {
setActionError(null);
try {
const res = await api.get(`/fertilizer/plans/${id}/pdf/`, { responseType: 'blob' });
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
@@ -61,7 +78,7 @@ export default function FertilizerPage() {
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
alert('PDF出力に失敗しました');
setActionError('PDF出力に失敗しました');
}
};
@@ -115,6 +132,14 @@ export default function FertilizerPage() {
</div>
)}
{actionError && (
<div className="mb-4 flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-lg px-4 py-3 text-sm">
<span className="font-bold shrink-0"></span>
<span>{actionError}</span>
<button onClick={() => setActionError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600"></button>
</div>
)}
{loading ? (
<p className="text-gray-500">...</p>
) : plans.length === 0 ? (
@@ -135,6 +160,7 @@ export default function FertilizerPage() {
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-700"></th>
<th className="text-left px-4 py-3 font-medium text-gray-700"> / </th>
<th className="text-left px-4 py-3 font-medium text-gray-700"></th>
<th className="text-right px-4 py-3 font-medium text-gray-700"></th>
<th className="text-right px-4 py-3 font-medium text-gray-700"></th>
<th className="px-4 py-3"></th>
@@ -142,15 +168,52 @@ export default function FertilizerPage() {
</thead>
<tbody className="divide-y divide-gray-100">
{plans.map((plan) => (
<tr key={plan.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{plan.name}</td>
<tr
key={plan.id}
className={plan.is_confirmed ? 'bg-sky-50 hover:bg-sky-100/60' : 'hover:bg-gray-50'}
>
<td className="px-4 py-3 font-medium">
<div className="flex items-center gap-2">
<span>{plan.name}</span>
{plan.is_confirmed && (
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">
<BadgeCheck className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-gray-600">
{plan.crop_name} / {plan.variety_name}
</td>
<td className="px-4 py-3 text-gray-600">
{plan.is_confirmed
? `散布確定 ${plan.confirmed_at ? new Date(plan.confirmed_at).toLocaleString('ja-JP') : ''}`
: '未確定'}
</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}</td>
<td className="px-4 py-3 text-right text-gray-600">{plan.fertilizer_count}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 justify-end">
{!plan.is_confirmed ? (
<button
onClick={() => setConfirmTarget(plan)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-emerald-300 rounded hover:bg-emerald-50 text-emerald-700"
title="散布確定"
>
<BadgeCheck className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => handleUnconfirm(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-amber-300 rounded hover:bg-amber-50 text-amber-700"
title="確定取消"
>
<Undo2 className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => handlePdf(plan.id, plan.name)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"
@@ -184,6 +247,13 @@ export default function FertilizerPage() {
</div>
)}
</div>
<ConfirmSpreadingModal
isOpen={confirmTarget !== null}
plan={confirmTarget}
onClose={() => setConfirmTarget(null)}
onConfirmed={fetchPlans}
/>
</div>
);
}

View File

@@ -73,8 +73,18 @@ export default function StockOverview({
</td>
<td className="px-4 py-3 text-gray-600">{item.material_type_display}</td>
<td className="px-4 py-3 text-gray-600">{item.maker || '-'}</td>
<td className="px-4 py-3 text-right font-semibold text-gray-900">
{item.current_stock}
<td className="px-4 py-3 text-right">
<div className="font-semibold text-gray-900">
{item.current_stock}
{item.reserved_stock !== '0.000' && (
<span className="ml-1 text-xs font-normal text-amber-600">
{item.reserved_stock}
</span>
)}
</div>
<div className="text-xs text-gray-500">
{item.available_stock}
</div>
</td>
<td className="px-4 py-3 text-gray-600">{item.stock_unit_display}</td>
<td className="px-4 py-3 text-gray-600">