今回入れたのは、在庫履歴の編集・削除対応と、種子資材を資材マスタ側で品種に直接結び付ける流れです。backend/apps/materials/views.py と backend/apps/materials/serializers.py で stock-transactions の更新を許可しつつ、計画や実績に紐づく履歴はロック扱いにしました。画面側は frontend/src/app/materials/page.tsx、frontend/src/app/materials/_components/StockOverview.tsx、frontend/src/app/materials/_components/StockTransactionForm.tsx を更新して、手動登録の入出庫履歴をあとから編集・削除できるようにしています。 あわせて frontend/src/app/materials/masters/page.tsx と frontend/src/app/materials/_components/MaterialForm.tsx で、種子 タブから品種を直接選んで紐付ける形にしました。重複を避けるため、作付け計画側の品種管理モーダル frontend/src/app/allocation/page.tsx から種子資材の紐付け UI は外しています。Issue #2 にもこの方針でコメント追記済みです。 確認できたのは python3 -m py_compile までで、フロントのビルドは frontend/node_modules が無いためまだ回していません。次はブラウザで在庫管理の操作感を確認してから、田植え計画側の細部を詰めるのがよさそうです。
360 lines
10 KiB
TypeScript
360 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { Check, X } from 'lucide-react';
|
|
|
|
import { Material } from '@/types';
|
|
|
|
export type MaterialTab = 'fertilizer' | 'pesticide' | 'seed' | 'misc';
|
|
|
|
export interface MaterialFormState {
|
|
name: string;
|
|
material_type: Material['material_type'];
|
|
seed_variety_id: string;
|
|
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;
|
|
seedVarietyOptions?: { id: number; label: string }[];
|
|
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,
|
|
seedVarietyOptions = [],
|
|
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">
|
|
{tab === 'seed' ? (
|
|
<select
|
|
className={inputClassName}
|
|
value={form.seed_variety_id}
|
|
onChange={(e) => onBaseFieldChange('seed_variety_id', e.target.value)}
|
|
>
|
|
<option value="">品種未設定</option>
|
|
{seedVarietyOptions.map((option) => (
|
|
<option key={option.id} value={option.id}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|