その判断で進めました。在庫管理を先に固めるように切り替えて、手元の実装もそちらを優先して直しています。

今回入れたのは、在庫履歴の編集・削除対応と、種子資材を資材マスタ側で品種に直接結び付ける流れです。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 が無いためまだ回していません。次はブラウザで在庫管理の操作感を確認してから、田植え計画側の細部を詰めるのがよさそうです。
This commit is contained in:
akira
2026-04-05 11:43:03 +09:00
parent a38472e4a0
commit 491f05eee8
9 changed files with 249 additions and 178 deletions

View File

@@ -183,6 +183,7 @@ class StockTransactionSerializer(serializers.ModelSerializer):
source='get_transaction_type_display', source='get_transaction_type_display',
read_only=True, read_only=True,
) )
is_locked = serializers.SerializerMethodField()
class Meta: class Meta:
model = StockTransaction model = StockTransaction
@@ -199,10 +200,15 @@ class StockTransactionSerializer(serializers.ModelSerializer):
'occurred_on', 'occurred_on',
'note', 'note',
'fertilization_plan', 'fertilization_plan',
'spreading_item',
'is_locked',
'created_at', 'created_at',
] ]
read_only_fields = ['created_at'] read_only_fields = ['created_at']
def get_is_locked(self, obj):
return bool(obj.fertilization_plan_id or obj.spreading_item_id)
class StockSummarySerializer(serializers.Serializer): class StockSummarySerializer(serializers.Serializer):
material_id = serializers.IntegerField() material_id = serializers.IntegerField()

View File

@@ -54,7 +54,7 @@ class StockTransactionViewSet(viewsets.ModelViewSet):
serializer_class = StockTransactionSerializer serializer_class = StockTransactionSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
http_method_names = ['get', 'post', 'delete', 'head', 'options'] http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']
def get_queryset(self): def get_queryset(self):
queryset = StockTransaction.objects.select_related('material') queryset = StockTransaction.objects.select_related('material')
@@ -77,6 +77,33 @@ class StockTransactionViewSet(viewsets.ModelViewSet):
return queryset return queryset
def update(self, request, *args, **kwargs):
instance = self.get_object()
if instance.fertilization_plan_id or instance.spreading_item_id:
return Response(
{'detail': '計画や実績に紐づく入出庫履歴は編集できません。'},
status=status.HTTP_400_BAD_REQUEST,
)
return super().update(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
instance = self.get_object()
if instance.fertilization_plan_id or instance.spreading_item_id:
return Response(
{'detail': '計画や実績に紐づく入出庫履歴は編集できません。'},
status=status.HTTP_400_BAD_REQUEST,
)
return super().partial_update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.fertilization_plan_id or instance.spreading_item_id:
return Response(
{'detail': '計画や実績に紐づく入出庫履歴は削除できません。'},
status=status.HTTP_400_BAD_REQUEST,
)
return super().destroy(request, *args, **kwargs)
class StockSummaryView(generics.ListAPIView): class StockSummaryView(generics.ListAPIView):
"""在庫集計一覧""" """在庫集計一覧"""

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Field, Crop, Material, Plan } from '@/types'; import { Field, Crop, Plan } from '@/types';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react'; import { Menu, X, BarChart3, ChevronRight, ChevronDown, ArrowUp, ArrowDown, Copy, Settings, Trash2, CheckSquare, Search } from 'lucide-react';
@@ -23,7 +23,6 @@ export default function AllocationPage() {
const [fields, setFields] = useState<Field[]>([]); const [fields, setFields] = useState<Field[]>([]);
const [crops, setCrops] = useState<Crop[]>([]); const [crops, setCrops] = useState<Crop[]>([]);
const [plans, setPlans] = useState<Plan[]>([]); const [plans, setPlans] = useState<Plan[]>([]);
const [seedMaterials, setSeedMaterials] = useState<Material[]>([]);
const [year, setYear] = useState<number>(() => { const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const saved = localStorage.getItem('allocationYear'); const saved = localStorage.getItem('allocationYear');
@@ -61,16 +60,14 @@ export default function AllocationPage() {
const fetchData = async (background = false) => { const fetchData = async (background = false) => {
if (!background) setLoading(true); if (!background) setLoading(true);
try { try {
const [fieldsRes, cropsRes, plansRes, seedMaterialsRes] = await Promise.all([ const [fieldsRes, cropsRes, plansRes] = await Promise.all([
api.get('/fields/?ordering=group_name,display_order,id'), api.get('/fields/?ordering=group_name,display_order,id'),
api.get('/plans/crops/'), api.get('/plans/crops/'),
api.get(`/plans/?year=${year}`), api.get(`/plans/?year=${year}`),
api.get('/materials/materials/?material_type=seed'),
]); ]);
setFields(fieldsRes.data); setFields(fieldsRes.data);
setCrops(cropsRes.data); setCrops(cropsRes.data);
setPlans(plansRes.data); setPlans(plansRes.data);
setSeedMaterials(seedMaterialsRes.data);
} catch (error) { } catch (error) {
console.error('Failed to fetch data:', error); console.error('Failed to fetch data:', error);
} finally { } finally {
@@ -384,20 +381,6 @@ export default function AllocationPage() {
} }
}; };
const handleUpdateVarietySeedMaterial = async (varietyId: number, seedMaterialId: string) => {
try {
const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId);
if (!variety) return;
await api.patch(`/plans/varieties/${varietyId}/`, {
seed_material: seedMaterialId ? parseInt(seedMaterialId, 10) : null,
});
await fetchData(true);
} catch (error) {
console.error('Failed to update variety seed material:', error);
alert('種子在庫の紐付け更新に失敗しました');
}
};
const toggleFieldSelection = (fieldId: number) => { const toggleFieldSelection = (fieldId: number) => {
setSelectedFields((prev) => { setSelectedFields((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -1079,15 +1062,6 @@ export default function AllocationPage() {
initialValue={v.default_seedling_boxes_per_tan} initialValue={v.default_seedling_boxes_per_tan}
onSave={handleUpdateVarietyDefaultBoxes} onSave={handleUpdateVarietyDefaultBoxes}
/> />
<div className="mt-3">
<VarietySeedMaterialForm
varietyId={v.id}
initialValue={v.seed_material ? String(v.seed_material) : ''}
initialLabel={v.seed_material_name}
materials={seedMaterials}
onSave={handleUpdateVarietySeedMaterial}
/>
</div>
</li> </li>
))} ))}
</ul> </ul>
@@ -1196,60 +1170,3 @@ function VarietyDefaultBoxesForm({
</div> </div>
); );
} }
function VarietySeedMaterialForm({
varietyId,
initialValue,
initialLabel,
materials,
onSave,
}: {
varietyId: number;
initialValue: string;
initialLabel: string | null;
materials: Material[];
onSave: (varietyId: number, seedMaterialId: string) => Promise<void>;
}) {
const [value, setValue] = useState(initialValue);
const [saving, setSaving] = useState(false);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const handleSave = async () => {
setSaving(true);
await onSave(varietyId, value);
setSaving(false);
};
return (
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs text-gray-600"></label>
<select
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value=""></option>
{materials.map((material) => (
<option key={material.id} value={material.id}>
{material.name}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
: {initialLabel || '未設定'}
</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
</button>
</div>
);
}

View File

@@ -9,6 +9,7 @@ export type MaterialTab = 'fertilizer' | 'pesticide' | 'seed' | 'misc';
export interface MaterialFormState { export interface MaterialFormState {
name: string; name: string;
material_type: Material['material_type']; material_type: Material['material_type'];
seed_variety_id: string;
maker: string; maker: string;
stock_unit: Material['stock_unit']; stock_unit: Material['stock_unit'];
is_active: boolean; is_active: boolean;
@@ -33,6 +34,7 @@ interface MaterialFormProps {
tab: MaterialTab; tab: MaterialTab;
form: MaterialFormState; form: MaterialFormState;
saving: boolean; saving: boolean;
seedVarietyOptions?: { id: number; label: string }[];
onBaseFieldChange: ( onBaseFieldChange: (
field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>, field: keyof Omit<MaterialFormState, 'fertilizer_profile' | 'pesticide_profile'>,
value: string | boolean value: string | boolean
@@ -56,6 +58,7 @@ export default function MaterialForm({
tab, tab,
form, form,
saving, saving,
seedVarietyOptions = [],
onBaseFieldChange, onBaseFieldChange,
onFertilizerFieldChange, onFertilizerFieldChange,
onPesticideFieldChange, onPesticideFieldChange,
@@ -245,9 +248,18 @@ export default function MaterialForm({
</td> </td>
<td className="px-2 py-2"> <td className="px-2 py-2">
{tab === 'seed' ? ( {tab === 'seed' ? (
<div className="rounded-md border border-green-200 bg-green-100 px-2 py-1 text-sm text-green-800"> <select
className={inputClassName}
</div> 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 <select
className={inputClassName} className={inputClassName}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Clock3, Download, Upload } from 'lucide-react'; import { Clock3, Download, Pencil, Trash2, Upload } from 'lucide-react';
import { StockSummary, StockTransaction } from '@/types'; import { StockSummary, StockTransaction } from '@/types';
@@ -15,6 +15,8 @@ interface StockOverviewProps {
materialId: number, materialId: number,
transactionType: StockTransaction['transaction_type'] transactionType: StockTransaction['transaction_type']
) => void; ) => void;
onEditTransaction: (transaction: StockTransaction) => void;
onDeleteTransaction: (transaction: StockTransaction) => void;
onToggleHistory: (materialId: number) => void; onToggleHistory: (materialId: number) => void;
} }
@@ -25,6 +27,8 @@ export default function StockOverview({
historyLoadingId, historyLoadingId,
histories, histories,
onOpenTransaction, onOpenTransaction,
onEditTransaction,
onDeleteTransaction,
onToggleHistory, onToggleHistory,
}: StockOverviewProps) { }: StockOverviewProps) {
if (loading) { if (loading) {
@@ -149,6 +153,24 @@ export default function StockOverview({
<span className="text-gray-500"> <span className="text-gray-500">
{transaction.note || '備考なし'} {transaction.note || '備考なし'}
</span> </span>
<div className="flex items-center gap-2">
<button
onClick={() => onEditTransaction(transaction)}
disabled={transaction.is_locked}
className="inline-flex items-center gap-1 rounded border border-blue-300 px-2 py-1 text-xs text-blue-700 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={() => onDeleteTransaction(transaction)}
disabled={transaction.is_locked}
className="inline-flex items-center gap-1 rounded border border-red-300 px-2 py-1 text-xs text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -13,6 +13,7 @@ interface StockTransactionFormProps {
materials: Material[]; materials: Material[];
presetMaterialId?: number | null; presetMaterialId?: number | null;
presetTransactionType?: TransactionType | null; presetTransactionType?: TransactionType | null;
editingTransaction?: StockTransaction | null;
onClose: () => void; onClose: () => void;
onSaved: () => Promise<void> | void; onSaved: () => Promise<void> | void;
} }
@@ -32,6 +33,7 @@ export default function StockTransactionForm({
materials, materials,
presetMaterialId = null, presetMaterialId = null,
presetTransactionType = null, presetTransactionType = null,
editingTransaction = null,
onClose, onClose,
onSaved, onSaved,
}: StockTransactionFormProps) { }: StockTransactionFormProps) {
@@ -47,13 +49,21 @@ export default function StockTransactionForm({
if (!isOpen) { if (!isOpen) {
return; return;
} }
setMaterialId(presetMaterialId ? String(presetMaterialId) : ''); if (editingTransaction) {
setTransactionType(presetTransactionType ?? 'purchase'); setMaterialId(String(editingTransaction.material));
setQuantity(''); setTransactionType(editingTransaction.transaction_type);
setOccurredOn(today()); setQuantity(editingTransaction.quantity);
setNote(''); setOccurredOn(editingTransaction.occurred_on);
setNote(editingTransaction.note || '');
} else {
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
setTransactionType(presetTransactionType ?? 'purchase');
setQuantity('');
setOccurredOn(today());
setNote('');
}
setError(null); setError(null);
}, [isOpen, presetMaterialId, presetTransactionType]); }, [isOpen, presetMaterialId, presetTransactionType, editingTransaction]);
if (!isOpen) { if (!isOpen) {
return null; return null;
@@ -73,13 +83,18 @@ export default function StockTransactionForm({
setSaving(true); setSaving(true);
try { try {
await api.post('/materials/stock-transactions/', { const payload = {
material: Number(materialId), material: Number(materialId),
transaction_type: transactionType, transaction_type: transactionType,
quantity, quantity,
occurred_on: occurredOn, occurred_on: occurredOn,
note, note,
}); };
if (editingTransaction) {
await api.put(`/materials/stock-transactions/${editingTransaction.id}/`, payload);
} else {
await api.post('/materials/stock-transactions/', payload);
}
await onSaved(); await onSaved();
onClose(); onClose();
} catch (e: unknown) { } catch (e: unknown) {
@@ -104,7 +119,9 @@ export default function StockTransactionForm({
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4"> <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div> <div>
<h2 className="text-lg font-semibold text-gray-900"></h2> <h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500"></p> <p className="text-sm text-gray-500">
{editingTransaction ? '入出庫履歴を修正します。' : '在庫の増減を記録します。'}
</p>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}

View File

@@ -10,7 +10,7 @@ import MaterialForm, {
} from '../_components/MaterialForm'; } from '../_components/MaterialForm';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Material } from '@/types'; import { Crop, Material } from '@/types';
const tabs: { key: MaterialTab; label: string }[] = [ const tabs: { key: MaterialTab; label: string }[] = [
{ key: 'fertilizer', label: '肥料' }, { key: 'fertilizer', label: '肥料' },
@@ -29,9 +29,16 @@ const emptyForm = (tab: MaterialTab): MaterialFormState => ({
: tab === 'seed' : tab === 'seed'
? 'seed' ? 'seed'
: 'other', : 'other',
seed_variety_id: '',
maker: '', maker: '',
stock_unit: stock_unit:
tab === 'fertilizer' ? 'bag' : tab === 'pesticide' ? 'bottle' : tab === 'seed' ? 'kg' : 'piece', tab === 'fertilizer'
? 'bag'
: tab === 'pesticide'
? 'bottle'
: tab === 'seed'
? 'kg'
: 'piece',
is_active: true, is_active: true,
notes: '', notes: '',
fertilizer_profile: { fertilizer_profile: {
@@ -50,10 +57,13 @@ const emptyForm = (tab: MaterialTab): MaterialFormState => ({
}, },
}); });
type VarietyOption = { id: number; label: string };
export default function MaterialMastersPage() { export default function MaterialMastersPage() {
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<MaterialTab>('fertilizer'); const [tab, setTab] = useState<MaterialTab>('fertilizer');
const [materials, setMaterials] = useState<Material[]>([]); const [materials, setMaterials] = useState<Material[]>([]);
const [crops, setCrops] = useState<Crop[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | 'new' | null>(null); const [editingId, setEditingId] = useState<number | 'new' | null>(null);
const [form, setForm] = useState<MaterialFormState>(emptyForm('fertilizer')); const [form, setForm] = useState<MaterialFormState>(emptyForm('fertilizer'));
@@ -61,7 +71,7 @@ export default function MaterialMastersPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchMaterials(); fetchData();
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -70,11 +80,15 @@ export default function MaterialMastersPage() {
} }
}, [tab, editingId]); }, [tab, editingId]);
const fetchMaterials = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await api.get('/materials/materials/'); const [materialsRes, cropsRes] = await Promise.all([
setMaterials(res.data); api.get('/materials/materials/'),
api.get('/plans/crops/'),
]);
setMaterials(materialsRes.data);
setCrops(cropsRes.data);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError('資材マスタの取得に失敗しました。'); setError('資材マスタの取得に失敗しました。');
@@ -83,6 +97,26 @@ export default function MaterialMastersPage() {
} }
}; };
const allVarieties = crops.flatMap((crop) =>
crop.varieties.map((variety) => ({
...variety,
crop_name: crop.name,
}))
);
const seedVarietyOptions: VarietyOption[] = allVarieties.map((variety) => ({
id: variety.id,
label: `${variety.crop_name} / ${variety.name}`,
}));
const getLinkedVariety = (materialId: number) =>
allVarieties.find((variety) => variety.seed_material === materialId) ?? null;
const getLinkedVarietyLabel = (materialId: number) => {
const variety = getLinkedVariety(materialId);
return variety ? `${variety.crop_name} / ${variety.name}` : '-';
};
const visibleMaterials = materials.filter((material) => { const visibleMaterials = materials.filter((material) => {
if (tab === 'misc') { if (tab === 'misc') {
return material.material_type === 'other' || material.material_type === 'seedling'; return material.material_type === 'other' || material.material_type === 'seedling';
@@ -98,9 +132,11 @@ export default function MaterialMastersPage() {
const startEdit = (material: Material) => { const startEdit = (material: Material) => {
setError(null); setError(null);
const linkedVariety = getLinkedVariety(material.id);
setForm({ setForm({
name: material.name, name: material.name,
material_type: material.material_type, material_type: material.material_type,
seed_variety_id: linkedVariety ? String(linkedVariety.id) : '',
maker: material.maker, maker: material.maker,
stock_unit: material.stock_unit, stock_unit: material.stock_unit,
is_active: material.is_active, is_active: material.is_active,
@@ -128,6 +164,23 @@ export default function MaterialMastersPage() {
setForm(emptyForm(tab)); setForm(emptyForm(tab));
}; };
const syncSeedVariety = async (materialId: number, seedVarietyId: string) => {
const currentlyLinked = getLinkedVariety(materialId);
const selectedVarietyId = seedVarietyId ? parseInt(seedVarietyId, 10) : null;
if (currentlyLinked && currentlyLinked.id !== selectedVarietyId) {
await api.patch(`/plans/varieties/${currentlyLinked.id}/`, {
seed_material: null,
});
}
if (selectedVarietyId) {
await api.patch(`/plans/varieties/${selectedVarietyId}/`, {
seed_material: materialId,
});
}
};
const handleSave = async () => { const handleSave = async () => {
setError(null); setError(null);
@@ -167,13 +220,27 @@ export default function MaterialMastersPage() {
: undefined, : undefined,
}; };
let savedMaterial: Material;
if (editingId === 'new') { if (editingId === 'new') {
await api.post('/materials/materials/', payload); const res = await api.post('/materials/materials/', payload);
savedMaterial = res.data;
} else { } else {
await api.put(`/materials/materials/${editingId}/`, payload); const res = await api.put(`/materials/materials/${editingId}/`, payload);
savedMaterial = res.data;
} }
await fetchMaterials(); if (form.material_type === 'seed') {
await syncSeedVariety(savedMaterial.id, form.seed_variety_id);
} else {
const linkedVariety = getLinkedVariety(savedMaterial.id);
if (linkedVariety) {
await api.patch(`/plans/varieties/${linkedVariety.id}/`, {
seed_material: null,
});
}
}
await fetchData();
setEditingId(null); setEditingId(null);
setForm(emptyForm(tab)); setForm(emptyForm(tab));
} catch (e: unknown) { } catch (e: unknown) {
@@ -197,7 +264,7 @@ export default function MaterialMastersPage() {
setError(null); setError(null);
try { try {
await api.delete(`/materials/materials/${material.id}/`); await api.delete(`/materials/materials/${material.id}/`);
await fetchMaterials(); await fetchData();
} catch (e: unknown) { } catch (e: unknown) {
console.error(e); console.error(e);
const detail = const detail =
@@ -249,6 +316,22 @@ export default function MaterialMastersPage() {
})); }));
}; };
const tableProps = {
materials: visibleMaterials,
editingId,
form,
saving,
seedVarietyOptions,
getLinkedVarietyLabel,
onEdit: startEdit,
onDelete: handleDelete,
onBaseFieldChange: handleBaseFieldChange,
onFertilizerFieldChange: handleFertilizerFieldChange,
onPesticideFieldChange: handlePesticideFieldChange,
onSave: handleSave,
onCancel: cancelEdit,
};
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Navbar /> <Navbar />
@@ -312,66 +395,10 @@ export default function MaterialMastersPage() {
<p className="text-sm text-gray-500">...</p> <p className="text-sm text-gray-500">...</p>
) : ( ) : (
<div className="overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm"> <div className="overflow-x-auto rounded-2xl border border-gray-200 bg-white shadow-sm">
{tab === 'fertilizer' && ( {tab === 'fertilizer' && <FertilizerTable {...tableProps} />}
<FertilizerTable {tab === 'pesticide' && <PesticideTable {...tableProps} />}
materials={visibleMaterials} {tab === 'seed' && <SeedTable {...tableProps} />}
editingId={editingId} {tab === 'misc' && <MiscTable {...tableProps} />}
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 === 'seed' && (
<SeedTable
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> </div>
@@ -384,6 +411,8 @@ interface TableProps {
editingId: number | 'new' | null; editingId: number | 'new' | null;
form: MaterialFormState; form: MaterialFormState;
saving: boolean; saving: boolean;
seedVarietyOptions: VarietyOption[];
getLinkedVarietyLabel: (materialId: number) => string;
onEdit: (material: Material) => void; onEdit: (material: Material) => void;
onDelete: (material: Material) => void; onDelete: (material: Material) => void;
onBaseFieldChange: ( onBaseFieldChange: (
@@ -532,13 +561,13 @@ function PesticideTable(props: TableProps) {
); );
} }
function MiscTable(props: TableProps) { function SeedTable(props: TableProps) {
return ( return (
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr className="border-b border-gray-200"> <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-left font-medium text-gray-700"></th> <th className="px-4 py-3 text-left font-medium text-gray-700"></th>
@@ -547,14 +576,16 @@ function MiscTable(props: TableProps) {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{props.editingId === 'new' && <MaterialForm tab="misc" {...props} />} {props.editingId === 'new' && <MaterialForm tab="seed" {...props} />}
{props.materials.map((material) => {props.materials.map((material) =>
props.editingId === material.id ? ( props.editingId === material.id ? (
<MaterialForm key={material.id} tab="misc" {...props} /> <MaterialForm key={material.id} tab="seed" {...props} />
) : ( ) : (
<tr key={material.id} className="hover:bg-gray-50"> <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 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">
{props.getLinkedVarietyLabel(material.id)}
</td>
<td className="px-4 py-3 text-gray-600">{material.maker || '-'}</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="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="max-w-xs px-4 py-3 text-gray-600">{material.notes || '-'}</td>
@@ -583,7 +614,7 @@ function MiscTable(props: TableProps) {
); );
} }
function SeedTable(props: TableProps) { function MiscTable(props: TableProps) {
return ( return (
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
@@ -598,10 +629,10 @@ function SeedTable(props: TableProps) {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{props.editingId === 'new' && <MaterialForm tab="seed" {...props} />} {props.editingId === 'new' && <MaterialForm tab="misc" {...props} />}
{props.materials.map((material) => {props.materials.map((material) =>
props.editingId === material.id ? ( props.editingId === material.id ? (
<MaterialForm key={material.id} tab="seed" {...props} /> <MaterialForm key={material.id} tab="misc" {...props} />
) : ( ) : (
<tr key={material.id} className="hover:bg-gray-50"> <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 font-medium text-gray-900">{material.name}</td>

View File

@@ -34,6 +34,7 @@ export default function MaterialsPage() {
const [presetMaterialId, setPresetMaterialId] = useState<number | null>(null); const [presetMaterialId, setPresetMaterialId] = useState<number | null>(null);
const [presetTransactionType, setPresetTransactionType] = const [presetTransactionType, setPresetTransactionType] =
useState<StockTransaction['transaction_type'] | null>(null); useState<StockTransaction['transaction_type'] | null>(null);
const [editingTransaction, setEditingTransaction] = useState<StockTransaction | null>(null);
useEffect(() => { useEffect(() => {
fetchInitialData(); fetchInitialData();
@@ -94,11 +95,41 @@ export default function MaterialsPage() {
materialId: number | null, materialId: number | null,
transactionType: StockTransaction['transaction_type'] | null transactionType: StockTransaction['transaction_type'] | null
) => { ) => {
setEditingTransaction(null);
setPresetMaterialId(materialId); setPresetMaterialId(materialId);
setPresetTransactionType(transactionType); setPresetTransactionType(transactionType);
setIsTransactionOpen(true); setIsTransactionOpen(true);
}; };
const handleEditTransaction = (transaction: StockTransaction) => {
setPresetMaterialId(null);
setPresetTransactionType(null);
setEditingTransaction(transaction);
setIsTransactionOpen(true);
};
const handleDeleteTransaction = async (transaction: StockTransaction) => {
if (!confirm(`この入出庫履歴を削除しますか?\n${transaction.transaction_type_display} ${transaction.quantity}${transaction.stock_unit_display}`)) {
return;
}
try {
await api.delete(`/materials/stock-transactions/${transaction.id}/`);
await handleSavedTransaction();
} 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);
}
};
const handleSavedTransaction = async () => { const handleSavedTransaction = async () => {
await fetchSummaryOnly(); await fetchSummaryOnly();
@@ -192,6 +223,8 @@ export default function MaterialsPage() {
historyLoadingId={historyLoadingId} historyLoadingId={historyLoadingId}
histories={histories} histories={histories}
onOpenTransaction={handleOpenTransaction} onOpenTransaction={handleOpenTransaction}
onEditTransaction={handleEditTransaction}
onDeleteTransaction={handleDeleteTransaction}
onToggleHistory={handleToggleHistory} onToggleHistory={handleToggleHistory}
/> />
</div> </div>
@@ -201,7 +234,11 @@ export default function MaterialsPage() {
materials={materials} materials={materials}
presetMaterialId={presetMaterialId} presetMaterialId={presetMaterialId}
presetTransactionType={presetTransactionType} presetTransactionType={presetTransactionType}
onClose={() => setIsTransactionOpen(false)} editingTransaction={editingTransaction}
onClose={() => {
setIsTransactionOpen(false);
setEditingTransaction(null);
}}
onSaved={handleSavedTransaction} onSaved={handleSavedTransaction}
/> />
</div> </div>

View File

@@ -118,6 +118,8 @@ export interface StockTransaction {
occurred_on: string; occurred_on: string;
note: string; note: string;
fertilization_plan: number | null; fertilization_plan: number | null;
spreading_item?: number | null;
is_locked: boolean;
created_at: string; created_at: string;
} }