在庫管理機能 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>
);
}