Files
keinasystem/frontend/src/app/materials/masters/page.tsx
Akira 497bc87c24 在庫管理機能 Phase 1 実装(apps/materials + フロントエンド)
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>
2026-03-14 15:42:47 +09:00

591 lines
21 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 { 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>
);
}