Backend: - apps/materials 新規作成(Material, FertilizerProfile, PesticideProfile, StockTransaction) - 資材マスタ CRUD API(/api/materials/materials/) - 入出庫履歴 API(/api/materials/stock-transactions/) - 在庫集計 API(/api/materials/stock-summary/) - 既存 Fertilizer に material OneToOneField 追加(0005マイグレーション、データ移行込み) Frontend: - /materials: 在庫一覧画面(タブフィルタ、履歴展開、入出庫モーダル) - /materials/masters: 資材マスタ管理(肥料/農薬/その他タブ、インライン編集) - Navbar に「在庫管理」メニュー追加 - Material/StockTransaction/StockSummary 型定義追加 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
591 lines
21 KiB
TypeScript
591 lines
21 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { ChevronLeft, Pencil, Plus, Trash2 } from 'lucide-react';
|
||
import { useRouter } from 'next/navigation';
|
||
|
||
import MaterialForm, {
|
||
MaterialFormState,
|
||
MaterialTab,
|
||
} from '../_components/MaterialForm';
|
||
import Navbar from '@/components/Navbar';
|
||
import { api } from '@/lib/api';
|
||
import { Material } from '@/types';
|
||
|
||
const tabs: { key: MaterialTab; label: string }[] = [
|
||
{ key: 'fertilizer', label: '肥料' },
|
||
{ key: 'pesticide', label: '農薬' },
|
||
{ key: 'misc', label: 'その他' },
|
||
];
|
||
|
||
const emptyForm = (tab: MaterialTab): MaterialFormState => ({
|
||
name: '',
|
||
material_type:
|
||
tab === 'fertilizer' ? 'fertilizer' : tab === 'pesticide' ? 'pesticide' : 'other',
|
||
maker: '',
|
||
stock_unit: tab === 'fertilizer' ? 'bag' : tab === 'pesticide' ? 'bottle' : 'piece',
|
||
is_active: true,
|
||
notes: '',
|
||
fertilizer_profile: {
|
||
capacity_kg: '',
|
||
nitrogen_pct: '',
|
||
phosphorus_pct: '',
|
||
potassium_pct: '',
|
||
},
|
||
pesticide_profile: {
|
||
registration_no: '',
|
||
formulation: '',
|
||
usage_unit: '',
|
||
dilution_ratio: '',
|
||
active_ingredient: '',
|
||
category: '',
|
||
},
|
||
});
|
||
|
||
export default function MaterialMastersPage() {
|
||
const router = useRouter();
|
||
const [tab, setTab] = useState<MaterialTab>('fertilizer');
|
||
const [materials, setMaterials] = useState<Material[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
|
||
const [form, setForm] = useState<MaterialFormState>(emptyForm('fertilizer'));
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
fetchMaterials();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (editingId === 'new') {
|
||
setForm(emptyForm(tab));
|
||
}
|
||
}, [tab, editingId]);
|
||
|
||
const fetchMaterials = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await api.get('/materials/materials/');
|
||
setMaterials(res.data);
|
||
} catch (e) {
|
||
console.error(e);
|
||
setError('資材マスタの取得に失敗しました。');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const visibleMaterials = materials.filter((material) => {
|
||
if (tab === 'misc') {
|
||
return material.material_type === 'other' || material.material_type === 'seedling';
|
||
}
|
||
return material.material_type === tab;
|
||
});
|
||
|
||
const startNew = () => {
|
||
setError(null);
|
||
setForm(emptyForm(tab));
|
||
setEditingId('new');
|
||
};
|
||
|
||
const startEdit = (material: Material) => {
|
||
setError(null);
|
||
setForm({
|
||
name: material.name,
|
||
material_type: material.material_type,
|
||
maker: material.maker,
|
||
stock_unit: material.stock_unit,
|
||
is_active: material.is_active,
|
||
notes: material.notes,
|
||
fertilizer_profile: {
|
||
capacity_kg: material.fertilizer_profile?.capacity_kg ?? '',
|
||
nitrogen_pct: material.fertilizer_profile?.nitrogen_pct ?? '',
|
||
phosphorus_pct: material.fertilizer_profile?.phosphorus_pct ?? '',
|
||
potassium_pct: material.fertilizer_profile?.potassium_pct ?? '',
|
||
},
|
||
pesticide_profile: {
|
||
registration_no: material.pesticide_profile?.registration_no ?? '',
|
||
formulation: material.pesticide_profile?.formulation ?? '',
|
||
usage_unit: material.pesticide_profile?.usage_unit ?? '',
|
||
dilution_ratio: material.pesticide_profile?.dilution_ratio ?? '',
|
||
active_ingredient: material.pesticide_profile?.active_ingredient ?? '',
|
||
category: material.pesticide_profile?.category ?? '',
|
||
},
|
||
});
|
||
setEditingId(material.id);
|
||
};
|
||
|
||
const cancelEdit = () => {
|
||
setEditingId(null);
|
||
setForm(emptyForm(tab));
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
setError(null);
|
||
|
||
if (!form.name.trim()) {
|
||
setError('資材名を入力してください。');
|
||
return;
|
||
}
|
||
|
||
setSaving(true);
|
||
try {
|
||
const payload = {
|
||
name: form.name,
|
||
material_type: form.material_type,
|
||
maker: form.maker,
|
||
stock_unit: form.stock_unit,
|
||
is_active: form.is_active,
|
||
notes: form.notes,
|
||
fertilizer_profile:
|
||
form.material_type === 'fertilizer'
|
||
? {
|
||
capacity_kg: form.fertilizer_profile.capacity_kg || null,
|
||
nitrogen_pct: form.fertilizer_profile.nitrogen_pct || null,
|
||
phosphorus_pct: form.fertilizer_profile.phosphorus_pct || null,
|
||
potassium_pct: form.fertilizer_profile.potassium_pct || null,
|
||
}
|
||
: undefined,
|
||
pesticide_profile:
|
||
form.material_type === 'pesticide'
|
||
? {
|
||
registration_no: form.pesticide_profile.registration_no,
|
||
formulation: form.pesticide_profile.formulation,
|
||
usage_unit: form.pesticide_profile.usage_unit,
|
||
dilution_ratio: form.pesticide_profile.dilution_ratio,
|
||
active_ingredient: form.pesticide_profile.active_ingredient,
|
||
category: form.pesticide_profile.category,
|
||
}
|
||
: undefined,
|
||
};
|
||
|
||
if (editingId === 'new') {
|
||
await api.post('/materials/materials/', payload);
|
||
} else {
|
||
await api.put(`/materials/materials/${editingId}/`, payload);
|
||
}
|
||
|
||
await fetchMaterials();
|
||
setEditingId(null);
|
||
setForm(emptyForm(tab));
|
||
} 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);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (material: Material) => {
|
||
setError(null);
|
||
try {
|
||
await api.delete(`/materials/materials/${material.id}/`);
|
||
await fetchMaterials();
|
||
} 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)
|
||
: `「${material.name}」の削除に失敗しました。`;
|
||
setError(detail);
|
||
}
|
||
};
|
||
|
||
const handleBaseFieldChange = (
|
||
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
|
||
value: string | boolean
|
||
) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
[field]: value,
|
||
}));
|
||
};
|
||
|
||
const handleFertilizerFieldChange = (
|
||
field: keyof MaterialFormState['fertilizer_profile'],
|
||
value: string
|
||
) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
fertilizer_profile: {
|
||
...prev.fertilizer_profile,
|
||
[field]: value,
|
||
},
|
||
}));
|
||
};
|
||
|
||
const handlePesticideFieldChange = (
|
||
field: keyof MaterialFormState['pesticide_profile'],
|
||
value: string
|
||
) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
pesticide_profile: {
|
||
...prev.pesticide_profile,
|
||
[field]: value,
|
||
},
|
||
}));
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
<Navbar />
|
||
<div className="mx-auto max-w-7xl px-4 py-8">
|
||
<div className="mb-6 flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={() => router.push('/materials')}
|
||
className="text-gray-500 transition hover:text-gray-700"
|
||
>
|
||
<ChevronLeft className="h-5 w-5" />
|
||
</button>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-800">資材マスタ管理</h1>
|
||
<p className="text-sm text-gray-500">資材情報をインラインで編集できます。</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={startNew}
|
||
disabled={editingId !== null}
|
||
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-50"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
新規追加
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mb-5 flex flex-wrap gap-2">
|
||
{tabs.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
onClick={() => {
|
||
setTab(item.key);
|
||
setEditingId(null);
|
||
setForm(emptyForm(item.key));
|
||
}}
|
||
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
|
||
tab === item.key
|
||
? 'bg-green-600 text-white shadow-sm'
|
||
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-4 flex items-start gap-2 rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||
<span>{error}</span>
|
||
<button
|
||
onClick={() => setError(null)}
|
||
className="ml-auto text-red-400 transition hover:text-red-600"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<p className="text-sm text-gray-500">読み込み中...</p>
|
||
) : (
|
||
<div className="overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||
{tab === 'fertilizer' && (
|
||
<FertilizerTable
|
||
materials={visibleMaterials}
|
||
editingId={editingId}
|
||
form={form}
|
||
saving={saving}
|
||
onEdit={startEdit}
|
||
onDelete={handleDelete}
|
||
onBaseFieldChange={handleBaseFieldChange}
|
||
onFertilizerFieldChange={handleFertilizerFieldChange}
|
||
onPesticideFieldChange={handlePesticideFieldChange}
|
||
onSave={handleSave}
|
||
onCancel={cancelEdit}
|
||
/>
|
||
)}
|
||
{tab === 'pesticide' && (
|
||
<PesticideTable
|
||
materials={visibleMaterials}
|
||
editingId={editingId}
|
||
form={form}
|
||
saving={saving}
|
||
onEdit={startEdit}
|
||
onDelete={handleDelete}
|
||
onBaseFieldChange={handleBaseFieldChange}
|
||
onFertilizerFieldChange={handleFertilizerFieldChange}
|
||
onPesticideFieldChange={handlePesticideFieldChange}
|
||
onSave={handleSave}
|
||
onCancel={cancelEdit}
|
||
/>
|
||
)}
|
||
{tab === 'misc' && (
|
||
<MiscTable
|
||
materials={visibleMaterials}
|
||
editingId={editingId}
|
||
form={form}
|
||
saving={saving}
|
||
onEdit={startEdit}
|
||
onDelete={handleDelete}
|
||
onBaseFieldChange={handleBaseFieldChange}
|
||
onFertilizerFieldChange={handleFertilizerFieldChange}
|
||
onPesticideFieldChange={handlePesticideFieldChange}
|
||
onSave={handleSave}
|
||
onCancel={cancelEdit}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface TableProps {
|
||
materials: Material[];
|
||
editingId: number | 'new' | null;
|
||
form: MaterialFormState;
|
||
saving: boolean;
|
||
onEdit: (material: Material) => void;
|
||
onDelete: (material: Material) => void;
|
||
onBaseFieldChange: (
|
||
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
|
||
value: string | boolean
|
||
) => void;
|
||
onFertilizerFieldChange: (
|
||
field: keyof MaterialFormState['fertilizer_profile'],
|
||
value: string
|
||
) => void;
|
||
onPesticideFieldChange: (
|
||
field: keyof MaterialFormState['pesticide_profile'],
|
||
value: string
|
||
) => void;
|
||
onSave: () => void;
|
||
onCancel: () => void;
|
||
}
|
||
|
||
function FertilizerTable(props: TableProps) {
|
||
return (
|
||
<table className="min-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-left font-medium text-gray-700">メーカー</th>
|
||
<th className="px-4 py-3 text-right font-medium text-gray-700">1袋(kg)</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>
|
||
<th className="px-4 py-3 text-right font-medium text-gray-700">カリ(%)</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">単位</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">備考</th>
|
||
<th className="px-4 py-3 text-center 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">
|
||
{props.editingId === 'new' && <MaterialForm tab="fertilizer" {...props} />}
|
||
{props.materials.map((material) =>
|
||
props.editingId === material.id ? (
|
||
<MaterialForm key={material.id} tab="fertilizer" {...props} />
|
||
) : (
|
||
<tr key={material.id} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3 font-medium text-gray-900">{material.name}</td>
|
||
<td className="px-4 py-3 text-gray-600">{material.maker || '-'}</td>
|
||
<td className="px-4 py-3 text-right text-gray-600">
|
||
{material.fertilizer_profile?.capacity_kg ?? '-'}
|
||
</td>
|
||
<td className="px-4 py-3 text-right text-gray-600">
|
||
{material.fertilizer_profile?.nitrogen_pct ?? '-'}
|
||
</td>
|
||
<td className="px-4 py-3 text-right text-gray-600">
|
||
{material.fertilizer_profile?.phosphorus_pct ?? '-'}
|
||
</td>
|
||
<td className="px-4 py-3 text-right text-gray-600">
|
||
{material.fertilizer_profile?.potassium_pct ?? '-'}
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-600">{material.stock_unit_display}</td>
|
||
<td className="max-w-xs px-4 py-3 text-gray-600">{material.notes || '-'}</td>
|
||
<td className="px-4 py-3 text-center text-gray-600">
|
||
{material.is_active ? '○' : '-'}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<RowActions
|
||
disabled={props.editingId !== null}
|
||
onEdit={() => props.onEdit(material)}
|
||
onDelete={() => props.onDelete(material)}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
)
|
||
)}
|
||
{props.materials.length === 0 && props.editingId === null && (
|
||
<tr>
|
||
<td colSpan={10} className="px-4 py-8 text-center text-gray-400">
|
||
該当する資材が登録されていません
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function PesticideTable(props: TableProps) {
|
||
return (
|
||
<table className="min-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-left font-medium text-gray-700">メーカー</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">登録番号</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">剤型</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">有効成分</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">分類</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">単位</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">備考</th>
|
||
<th className="px-4 py-3 text-center 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">
|
||
{props.editingId === 'new' && <MaterialForm tab="pesticide" {...props} />}
|
||
{props.materials.map((material) =>
|
||
props.editingId === material.id ? (
|
||
<MaterialForm key={material.id} tab="pesticide" {...props} />
|
||
) : (
|
||
<tr key={material.id} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3 font-medium text-gray-900">{material.name}</td>
|
||
<td className="px-4 py-3 text-gray-600">{material.maker || '-'}</td>
|
||
<td className="px-4 py-3 text-gray-600">
|
||
{material.pesticide_profile?.registration_no || '-'}
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-600">
|
||
{material.pesticide_profile?.formulation || '-'}
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-600">
|
||
{material.pesticide_profile?.active_ingredient || '-'}
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-600">
|
||
{material.pesticide_profile?.category || '-'}
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-600">{material.stock_unit_display}</td>
|
||
<td className="max-w-xs px-4 py-3 text-gray-600">{material.notes || '-'}</td>
|
||
<td className="px-4 py-3 text-center text-gray-600">
|
||
{material.is_active ? '○' : '-'}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<RowActions
|
||
disabled={props.editingId !== null}
|
||
onEdit={() => props.onEdit(material)}
|
||
onDelete={() => props.onDelete(material)}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
)
|
||
)}
|
||
{props.materials.length === 0 && props.editingId === null && (
|
||
<tr>
|
||
<td colSpan={10} className="px-4 py-8 text-center text-gray-400">
|
||
該当する資材が登録されていません
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function MiscTable(props: TableProps) {
|
||
return (
|
||
<table className="min-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-left font-medium text-gray-700">種別</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">メーカー</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">単位</th>
|
||
<th className="px-4 py-3 text-left font-medium text-gray-700">備考</th>
|
||
<th className="px-4 py-3 text-center 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">
|
||
{props.editingId === 'new' && <MaterialForm tab="misc" {...props} />}
|
||
{props.materials.map((material) =>
|
||
props.editingId === material.id ? (
|
||
<MaterialForm key={material.id} tab="misc" {...props} />
|
||
) : (
|
||
<tr key={material.id} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3 font-medium text-gray-900">{material.name}</td>
|
||
<td className="px-4 py-3 text-gray-600">{material.material_type_display}</td>
|
||
<td className="px-4 py-3 text-gray-600">{material.maker || '-'}</td>
|
||
<td className="px-4 py-3 text-gray-600">{material.stock_unit_display}</td>
|
||
<td className="max-w-xs px-4 py-3 text-gray-600">{material.notes || '-'}</td>
|
||
<td className="px-4 py-3 text-center text-gray-600">
|
||
{material.is_active ? '○' : '-'}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<RowActions
|
||
disabled={props.editingId !== null}
|
||
onEdit={() => props.onEdit(material)}
|
||
onDelete={() => props.onDelete(material)}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
)
|
||
)}
|
||
{props.materials.length === 0 && props.editingId === null && (
|
||
<tr>
|
||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||
該当する資材が登録されていません
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function RowActions({
|
||
disabled,
|
||
onEdit,
|
||
onDelete,
|
||
}: {
|
||
disabled: boolean;
|
||
onEdit: () => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-end gap-2">
|
||
<button
|
||
onClick={onEdit}
|
||
disabled={disabled}
|
||
className="text-gray-400 transition hover:text-blue-600 disabled:opacity-30"
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={onDelete}
|
||
disabled={disabled}
|
||
className="text-gray-400 transition hover:text-red-600 disabled:opacity-30"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|