在庫管理機能 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>
This commit is contained in:
Akira
2026-03-14 15:42:47 +09:00
parent 67d4197b7f
commit 497bc87c24
20 changed files with 2344 additions and 1 deletions

View File

@@ -0,0 +1,341 @@
'use client';
import { Check, X } from 'lucide-react';
import { Material } from '@/types';
export type MaterialTab = 'fertilizer' | 'pesticide' | 'misc';
export interface MaterialFormState {
name: string;
material_type: Material['material_type'];
maker: string;
stock_unit: Material['stock_unit'];
is_active: boolean;
notes: string;
fertilizer_profile: {
capacity_kg: string;
nitrogen_pct: string;
phosphorus_pct: string;
potassium_pct: string;
};
pesticide_profile: {
registration_no: string;
formulation: string;
usage_unit: string;
dilution_ratio: string;
active_ingredient: string;
category: string;
};
}
interface MaterialFormProps {
tab: MaterialTab;
form: MaterialFormState;
saving: boolean;
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;
}
const inputClassName =
'w-full rounded-md border border-gray-300 px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500';
export default function MaterialForm({
tab,
form,
saving,
onBaseFieldChange,
onFertilizerFieldChange,
onPesticideFieldChange,
onSave,
onCancel,
}: MaterialFormProps) {
if (tab === 'fertilizer') {
return (
<tr className="bg-green-50">
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.name}
onChange={(e) => onBaseFieldChange('name', e.target.value)}
placeholder="資材名"
autoFocus
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.maker}
onChange={(e) => onBaseFieldChange('maker', e.target.value)}
placeholder="メーカー"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
type="number"
step="0.001"
value={form.fertilizer_profile.capacity_kg}
onChange={(e) => onFertilizerFieldChange('capacity_kg', e.target.value)}
placeholder="kg"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
type="number"
step="0.01"
value={form.fertilizer_profile.nitrogen_pct}
onChange={(e) => onFertilizerFieldChange('nitrogen_pct', e.target.value)}
placeholder="%"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
type="number"
step="0.01"
value={form.fertilizer_profile.phosphorus_pct}
onChange={(e) => onFertilizerFieldChange('phosphorus_pct', e.target.value)}
placeholder="%"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
type="number"
step="0.01"
value={form.fertilizer_profile.potassium_pct}
onChange={(e) => onFertilizerFieldChange('potassium_pct', e.target.value)}
placeholder="%"
/>
</td>
<td className="px-2 py-2">
<StockUnitSelect
value={form.stock_unit}
onChange={(value) => onBaseFieldChange('stock_unit', value)}
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.notes}
onChange={(e) => onBaseFieldChange('notes', e.target.value)}
placeholder="備考"
/>
</td>
<td className="px-2 py-2 text-center">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => onBaseFieldChange('is_active', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</td>
<td className="px-2 py-2">
<ActionButtons onSave={onSave} onCancel={onCancel} saving={saving} />
</td>
</tr>
);
}
if (tab === 'pesticide') {
return (
<tr className="bg-green-50">
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.name}
onChange={(e) => onBaseFieldChange('name', e.target.value)}
placeholder="資材名"
autoFocus
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.maker}
onChange={(e) => onBaseFieldChange('maker', e.target.value)}
placeholder="メーカー"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.pesticide_profile.registration_no}
onChange={(e) => onPesticideFieldChange('registration_no', e.target.value)}
placeholder="登録番号"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.pesticide_profile.formulation}
onChange={(e) => onPesticideFieldChange('formulation', e.target.value)}
placeholder="剤型"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.pesticide_profile.active_ingredient}
onChange={(e) => onPesticideFieldChange('active_ingredient', e.target.value)}
placeholder="有効成分"
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.pesticide_profile.category}
onChange={(e) => onPesticideFieldChange('category', e.target.value)}
placeholder="分類"
/>
</td>
<td className="px-2 py-2">
<StockUnitSelect
value={form.stock_unit}
onChange={(value) => onBaseFieldChange('stock_unit', value)}
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.notes}
onChange={(e) => onBaseFieldChange('notes', e.target.value)}
placeholder="備考"
/>
</td>
<td className="px-2 py-2 text-center">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => onBaseFieldChange('is_active', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</td>
<td className="px-2 py-2">
<ActionButtons onSave={onSave} onCancel={onCancel} saving={saving} />
</td>
</tr>
);
}
return (
<tr className="bg-green-50">
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.name}
onChange={(e) => onBaseFieldChange('name', e.target.value)}
placeholder="資材名"
autoFocus
/>
</td>
<td className="px-2 py-2">
<select
className={inputClassName}
value={form.material_type}
onChange={(e) => onBaseFieldChange('material_type', e.target.value)}
>
<option value="other"></option>
<option value="seedling"></option>
</select>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.maker}
onChange={(e) => onBaseFieldChange('maker', e.target.value)}
placeholder="メーカー"
/>
</td>
<td className="px-2 py-2">
<StockUnitSelect
value={form.stock_unit}
onChange={(value) => onBaseFieldChange('stock_unit', value)}
/>
</td>
<td className="px-2 py-2">
<input
className={inputClassName}
value={form.notes}
onChange={(e) => onBaseFieldChange('notes', e.target.value)}
placeholder="備考"
/>
</td>
<td className="px-2 py-2 text-center">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => onBaseFieldChange('is_active', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
</td>
<td className="px-2 py-2">
<ActionButtons onSave={onSave} onCancel={onCancel} saving={saving} />
</td>
</tr>
);
}
function ActionButtons({
onSave,
onCancel,
saving,
}: {
onSave: () => void;
onCancel: () => void;
saving: boolean;
}) {
return (
<div className="flex items-center justify-end gap-1">
<button
onClick={onSave}
disabled={saving}
className="text-green-600 transition hover:text-green-800 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={onCancel}
className="text-gray-400 transition hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
function StockUnitSelect({
value,
onChange,
}: {
value: Material['stock_unit'];
onChange: (value: Material['stock_unit']) => void;
}) {
return (
<select
className={inputClassName}
value={value}
onChange={(e) => onChange(e.target.value as Material['stock_unit'])}
>
<option value="bag"></option>
<option value="bottle"></option>
<option value="kg">kg</option>
<option value="liter">L</option>
<option value="piece"></option>
</select>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { Fragment } from 'react';
import { Clock3, Download, Upload } from 'lucide-react';
import { StockSummary, StockTransaction } from '@/types';
interface StockOverviewProps {
loading: boolean;
items: StockSummary[];
expandedMaterialId: number | null;
historyLoadingId: number | null;
histories: Record<number, StockTransaction[]>;
onOpenTransaction: (
materialId: number,
transactionType: StockTransaction['transaction_type']
) => void;
onToggleHistory: (materialId: number) => void;
}
export default function StockOverview({
loading,
items,
expandedMaterialId,
historyLoadingId,
histories,
onOpenTransaction,
onToggleHistory,
}: StockOverviewProps) {
if (loading) {
return <p className="text-sm text-gray-500">...</p>;
}
if (items.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white px-6 py-12 text-center text-gray-500">
</div>
);
}
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
<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-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"></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-right font-medium text-gray-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map((item) => {
const isExpanded = expandedMaterialId === item.material_id;
const history = histories[item.material_id] ?? [];
return (
<Fragment key={item.material_id}>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">
<div className="flex items-center gap-2">
<span>{item.name}</span>
{!item.is_active && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
</span>
)}
</div>
</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>
<td className="px-4 py-3 text-gray-600">{item.stock_unit_display}</td>
<td className="px-4 py-3 text-gray-600">
{item.last_transaction_date ?? '-'}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button
onClick={() => onOpenTransaction(item.material_id, 'purchase')}
className="inline-flex items-center gap-1 rounded-lg border border-emerald-300 px-2.5 py-1.5 text-xs text-emerald-700 transition hover:bg-emerald-50"
>
<Download className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onOpenTransaction(item.material_id, 'use')}
className="inline-flex items-center gap-1 rounded-lg border border-amber-300 px-2.5 py-1.5 text-xs text-amber-700 transition hover:bg-amber-50"
>
<Upload className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onToggleHistory(item.material_id)}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs text-gray-700 transition hover:bg-gray-100"
>
<Clock3 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
{isExpanded && (
<tr className="bg-gray-50/70">
<td colSpan={7} className="px-4 py-4">
<div className="rounded-xl border border-gray-200 bg-white p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800">
{item.name}
</h3>
{historyLoadingId === item.material_id && (
<span className="text-xs text-gray-500">...</span>
)}
</div>
{history.length === 0 ? (
<p className="text-sm text-gray-500"></p>
) : (
<div className="space-y-2">
{history.map((transaction) => (
<div
key={transaction.id}
className="flex flex-col gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-700 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-center gap-3">
<span className="font-medium text-gray-900">
{transaction.transaction_type_display}
</span>
<span>
{transaction.quantity} {transaction.stock_unit_display}
</span>
<span className="text-gray-500">{transaction.occurred_on}</span>
</div>
<span className="text-gray-500">
{transaction.note || '備考なし'}
</span>
</div>
))}
</div>
)}
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, X } from 'lucide-react';
import { api } from '@/lib/api';
import { Material, StockTransaction } from '@/types';
type TransactionType = StockTransaction['transaction_type'];
interface StockTransactionFormProps {
isOpen: boolean;
materials: Material[];
presetMaterialId?: number | null;
presetTransactionType?: TransactionType | null;
onClose: () => void;
onSaved: () => Promise<void> | void;
}
const transactionOptions: { value: TransactionType; label: string }[] = [
{ value: 'purchase', label: '入庫' },
{ value: 'use', label: '使用' },
{ value: 'adjustment_plus', label: '棚卸増' },
{ value: 'adjustment_minus', label: '棚卸減' },
{ value: 'discard', label: '廃棄' },
];
const today = () => new Date().toISOString().slice(0, 10);
export default function StockTransactionForm({
isOpen,
materials,
presetMaterialId = null,
presetTransactionType = null,
onClose,
onSaved,
}: StockTransactionFormProps) {
const [materialId, setMaterialId] = useState<string>('');
const [transactionType, setTransactionType] = useState<TransactionType>('purchase');
const [quantity, setQuantity] = useState('');
const [occurredOn, setOccurredOn] = useState(today());
const [note, setNote] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) {
return;
}
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
setTransactionType(presetTransactionType ?? 'purchase');
setQuantity('');
setOccurredOn(today());
setNote('');
setError(null);
}, [isOpen, presetMaterialId, presetTransactionType]);
if (!isOpen) {
return null;
}
const handleSave = async () => {
setError(null);
if (!materialId) {
setError('資材を選択してください。');
return;
}
if (!quantity || Number(quantity) <= 0) {
setError('数量は0より大きい値を入力してください。');
return;
}
setSaving(true);
try {
await api.post('/materials/stock-transactions/', {
material: Number(materialId),
transaction_type: transactionType,
quantity,
occurred_on: occurredOn,
note,
});
await onSaved();
onClose();
} catch (e: unknown) {
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="w-full max-w-lg 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"></h2>
<p className="text-sm text-gray-500"></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="space-y-4 px-6 py-5">
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700"></span>
<select
value={materialId}
onChange={(e) => setMaterialId(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
>
<option value=""></option>
{materials.map((material) => (
<option key={material.id} value={material.id}>
{material.name} ({material.material_type_display})
</option>
))}
</select>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700"></span>
<select
value={transactionType}
onChange={(e) => setTransactionType(e.target.value as TransactionType)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
>
{transactionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700"></span>
<input
type="number"
min="0"
step="0.001"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
placeholder="0.000"
/>
</label>
</div>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700"></span>
<input
type="date"
value={occurredOn}
onChange={(e) => setOccurredOn(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
/>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700"></span>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200"
placeholder="任意でメモを残せます"
/>
</label>
</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={handleSave}
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

@@ -0,0 +1,590 @@
'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>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { useEffect, useState } from 'react';
import { Package, Plus, Settings2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import StockOverview from './_components/StockOverview';
import StockTransactionForm from './_components/StockTransactionForm';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Material, StockSummary, StockTransaction } from '@/types';
type FilterTab = 'all' | 'fertilizer' | 'pesticide' | 'misc';
const tabs: { key: FilterTab; label: string }[] = [
{ key: 'all', label: '全て' },
{ key: 'fertilizer', label: '肥料' },
{ key: 'pesticide', label: '農薬' },
{ key: 'misc', label: 'その他' },
];
export default function MaterialsPage() {
const router = useRouter();
const [tab, setTab] = useState<FilterTab>('all');
const [loading, setLoading] = useState(true);
const [materials, setMaterials] = useState<Material[]>([]);
const [summaries, setSummaries] = useState<StockSummary[]>([]);
const [histories, setHistories] = useState<Record<number, StockTransaction[]>>({});
const [expandedMaterialId, setExpandedMaterialId] = useState<number | null>(null);
const [historyLoadingId, setHistoryLoadingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [isTransactionOpen, setIsTransactionOpen] = useState(false);
const [presetMaterialId, setPresetMaterialId] = useState<number | null>(null);
const [presetTransactionType, setPresetTransactionType] =
useState<StockTransaction['transaction_type'] | null>(null);
useEffect(() => {
fetchInitialData();
}, []);
const fetchInitialData = async () => {
setLoading(true);
setError(null);
try {
const [materialsRes, summariesRes] = await Promise.all([
api.get('/materials/materials/'),
api.get('/materials/stock-summary/'),
]);
setMaterials(materialsRes.data);
setSummaries(summariesRes.data);
} catch (e) {
console.error(e);
setError('在庫データの取得に失敗しました。');
} finally {
setLoading(false);
}
};
const fetchSummaryOnly = async () => {
try {
const res = await api.get('/materials/stock-summary/');
setSummaries(res.data);
} catch (e) {
console.error(e);
setError('在庫一覧の更新に失敗しました。');
}
};
const handleToggleHistory = async (materialId: number) => {
if (expandedMaterialId === materialId) {
setExpandedMaterialId(null);
return;
}
setExpandedMaterialId(materialId);
if (histories[materialId]) {
return;
}
setHistoryLoadingId(materialId);
try {
const res = await api.get(`/materials/stock-transactions/?material_id=${materialId}`);
setHistories((prev) => ({ ...prev, [materialId]: res.data }));
} catch (e) {
console.error(e);
setError('履歴の取得に失敗しました。');
} finally {
setHistoryLoadingId(null);
}
};
const handleOpenTransaction = (
materialId: number | null,
transactionType: StockTransaction['transaction_type'] | null
) => {
setPresetMaterialId(materialId);
setPresetTransactionType(transactionType);
setIsTransactionOpen(true);
};
const handleSavedTransaction = async () => {
await fetchSummaryOnly();
if (expandedMaterialId !== null) {
try {
const res = await api.get(
`/materials/stock-transactions/?material_id=${expandedMaterialId}`
);
setHistories((prev) => ({ ...prev, [expandedMaterialId]: res.data }));
} catch (e) {
console.error(e);
}
}
};
const filteredSummaries = summaries.filter((summary) => {
if (tab === 'all') {
return true;
}
if (tab === 'misc') {
return summary.material_type === 'other' || summary.material_type === 'seedling';
}
return summary.material_type === tab;
});
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6 flex flex-col gap-4 rounded-3xl bg-gradient-to-r from-emerald-600 via-green-600 to-lime-500 px-6 py-7 text-white shadow-lg sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-sm">
<Package className="h-4 w-4" />
Materials Inventory
</div>
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-emerald-50">
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={() => router.push('/materials/masters')}
className="inline-flex items-center gap-2 rounded-xl border border-white/30 bg-white/10 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/20"
>
<Settings2 className="h-4 w-4" />
</button>
<button
onClick={() => handleOpenTransaction(null, null)}
className="inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-sm font-medium text-green-700 transition hover:bg-green-50"
>
<Plus className="h-4 w-4" />
</button>
</div>
</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>
)}
<div className="mb-5 flex flex-wrap gap-2">
{tabs.map((item) => (
<button
key={item.key}
onClick={() => setTab(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>
<StockOverview
loading={loading}
items={filteredSummaries}
expandedMaterialId={expandedMaterialId}
historyLoadingId={historyLoadingId}
histories={histories}
onOpenTransaction={handleOpenTransaction}
onToggleHistory={handleToggleHistory}
/>
</div>
<StockTransactionForm
isOpen={isTransactionOpen}
materials={materials}
presetMaterialId={presetMaterialId}
presetTransactionType={presetTransactionType}
onClose={() => setIsTransactionOpen(false)}
onSaved={handleSavedTransaction}
/>
</div>
);
}