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

今回入れたのは、在庫履歴の編集・削除対応と、種子資材を資材マスタ側で品種に直接結び付ける流れです。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',
read_only=True,
)
is_locked = serializers.SerializerMethodField()
class Meta:
model = StockTransaction
@@ -199,10 +200,15 @@ class StockTransactionSerializer(serializers.ModelSerializer):
'occurred_on',
'note',
'fertilization_plan',
'spreading_item',
'is_locked',
'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):
material_id = serializers.IntegerField()

View File

@@ -54,7 +54,7 @@ class StockTransactionViewSet(viewsets.ModelViewSet):
serializer_class = StockTransactionSerializer
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):
queryset = StockTransaction.objects.select_related('material')
@@ -77,6 +77,33 @@ class StockTransactionViewSet(viewsets.ModelViewSet):
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):
"""在庫集計一覧"""

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api';
import { Field, Crop, Material, Plan } from '@/types';
import { Field, Crop, Plan } from '@/types';
import Navbar from '@/components/Navbar';
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 [crops, setCrops] = useState<Crop[]>([]);
const [plans, setPlans] = useState<Plan[]>([]);
const [seedMaterials, setSeedMaterials] = useState<Material[]>([]);
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('allocationYear');
@@ -61,16 +60,14 @@ export default function AllocationPage() {
const fetchData = async (background = false) => {
if (!background) setLoading(true);
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('/plans/crops/'),
api.get(`/plans/?year=${year}`),
api.get('/materials/materials/?material_type=seed'),
]);
setFields(fieldsRes.data);
setCrops(cropsRes.data);
setPlans(plansRes.data);
setSeedMaterials(seedMaterialsRes.data);
} catch (error) {
console.error('Failed to fetch data:', error);
} 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) => {
setSelectedFields((prev) => {
const next = new Set(prev);
@@ -1079,15 +1062,6 @@ export default function AllocationPage() {
initialValue={v.default_seedling_boxes_per_tan}
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>
))}
</ul>
@@ -1196,60 +1170,3 @@ function VarietyDefaultBoxesForm({
</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 {
name: string;
material_type: Material['material_type'];
seed_variety_id: string;
maker: string;
stock_unit: Material['stock_unit'];
is_active: boolean;
@@ -33,6 +34,7 @@ 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
@@ -56,6 +58,7 @@ export default function MaterialForm({
tab,
form,
saving,
seedVarietyOptions = [],
onBaseFieldChange,
onFertilizerFieldChange,
onPesticideFieldChange,
@@ -245,9 +248,18 @@ export default function MaterialForm({
</td>
<td className="px-2 py-2">
{tab === 'seed' ? (
<div className="rounded-md border border-green-200 bg-green-100 px-2 py-1 text-sm text-green-800">
</div>
<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}

View File

@@ -1,7 +1,7 @@
'use client';
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';
@@ -15,6 +15,8 @@ interface StockOverviewProps {
materialId: number,
transactionType: StockTransaction['transaction_type']
) => void;
onEditTransaction: (transaction: StockTransaction) => void;
onDeleteTransaction: (transaction: StockTransaction) => void;
onToggleHistory: (materialId: number) => void;
}
@@ -25,6 +27,8 @@ export default function StockOverview({
historyLoadingId,
histories,
onOpenTransaction,
onEditTransaction,
onDeleteTransaction,
onToggleHistory,
}: StockOverviewProps) {
if (loading) {
@@ -149,6 +153,24 @@ export default function StockOverview({
<span className="text-gray-500">
{transaction.note || '備考なし'}
</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>

View File

@@ -13,6 +13,7 @@ interface StockTransactionFormProps {
materials: Material[];
presetMaterialId?: number | null;
presetTransactionType?: TransactionType | null;
editingTransaction?: StockTransaction | null;
onClose: () => void;
onSaved: () => Promise<void> | void;
}
@@ -32,6 +33,7 @@ export default function StockTransactionForm({
materials,
presetMaterialId = null,
presetTransactionType = null,
editingTransaction = null,
onClose,
onSaved,
}: StockTransactionFormProps) {
@@ -47,13 +49,21 @@ export default function StockTransactionForm({
if (!isOpen) {
return;
}
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
setTransactionType(presetTransactionType ?? 'purchase');
setQuantity('');
setOccurredOn(today());
setNote('');
if (editingTransaction) {
setMaterialId(String(editingTransaction.material));
setTransactionType(editingTransaction.transaction_type);
setQuantity(editingTransaction.quantity);
setOccurredOn(editingTransaction.occurred_on);
setNote(editingTransaction.note || '');
} else {
setMaterialId(presetMaterialId ? String(presetMaterialId) : '');
setTransactionType(presetTransactionType ?? 'purchase');
setQuantity('');
setOccurredOn(today());
setNote('');
}
setError(null);
}, [isOpen, presetMaterialId, presetTransactionType]);
}, [isOpen, presetMaterialId, presetTransactionType, editingTransaction]);
if (!isOpen) {
return null;
@@ -73,13 +83,18 @@ export default function StockTransactionForm({
setSaving(true);
try {
await api.post('/materials/stock-transactions/', {
const payload = {
material: Number(materialId),
transaction_type: transactionType,
quantity,
occurred_on: occurredOn,
note,
});
};
if (editingTransaction) {
await api.put(`/materials/stock-transactions/${editingTransaction.id}/`, payload);
} else {
await api.post('/materials/stock-transactions/', payload);
}
await onSaved();
onClose();
} 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>
<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>
<button
onClick={onClose}

View File

@@ -10,7 +10,7 @@ import MaterialForm, {
} from '../_components/MaterialForm';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { Material } from '@/types';
import { Crop, Material } from '@/types';
const tabs: { key: MaterialTab; label: string }[] = [
{ key: 'fertilizer', label: '肥料' },
@@ -29,9 +29,16 @@ const emptyForm = (tab: MaterialTab): MaterialFormState => ({
: tab === 'seed'
? 'seed'
: 'other',
seed_variety_id: '',
maker: '',
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,
notes: '',
fertilizer_profile: {
@@ -50,10 +57,13 @@ const emptyForm = (tab: MaterialTab): MaterialFormState => ({
},
});
type VarietyOption = { id: number; label: string };
export default function MaterialMastersPage() {
const router = useRouter();
const [tab, setTab] = useState<MaterialTab>('fertilizer');
const [materials, setMaterials] = useState<Material[]>([]);
const [crops, setCrops] = useState<Crop[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | 'new' | null>(null);
const [form, setForm] = useState<MaterialFormState>(emptyForm('fertilizer'));
@@ -61,7 +71,7 @@ export default function MaterialMastersPage() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchMaterials();
fetchData();
}, []);
useEffect(() => {
@@ -70,11 +80,15 @@ export default function MaterialMastersPage() {
}
}, [tab, editingId]);
const fetchMaterials = async () => {
const fetchData = async () => {
setLoading(true);
try {
const res = await api.get('/materials/materials/');
setMaterials(res.data);
const [materialsRes, cropsRes] = await Promise.all([
api.get('/materials/materials/'),
api.get('/plans/crops/'),
]);
setMaterials(materialsRes.data);
setCrops(cropsRes.data);
} catch (e) {
console.error(e);
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) => {
if (tab === 'misc') {
return material.material_type === 'other' || material.material_type === 'seedling';
@@ -98,9 +132,11 @@ export default function MaterialMastersPage() {
const startEdit = (material: Material) => {
setError(null);
const linkedVariety = getLinkedVariety(material.id);
setForm({
name: material.name,
material_type: material.material_type,
seed_variety_id: linkedVariety ? String(linkedVariety.id) : '',
maker: material.maker,
stock_unit: material.stock_unit,
is_active: material.is_active,
@@ -128,6 +164,23 @@ export default function MaterialMastersPage() {
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 () => {
setError(null);
@@ -167,13 +220,27 @@ export default function MaterialMastersPage() {
: undefined,
};
let savedMaterial: Material;
if (editingId === 'new') {
await api.post('/materials/materials/', payload);
const res = await api.post('/materials/materials/', payload);
savedMaterial = res.data;
} 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);
setForm(emptyForm(tab));
} catch (e: unknown) {
@@ -197,7 +264,7 @@ export default function MaterialMastersPage() {
setError(null);
try {
await api.delete(`/materials/materials/${material.id}/`);
await fetchMaterials();
await fetchData();
} catch (e: unknown) {
console.error(e);
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 (
<div className="min-h-screen bg-gray-50">
<Navbar />
@@ -312,66 +395,10 @@ export default function MaterialMastersPage() {
<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 === '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}
/>
)}
{tab === 'fertilizer' && <FertilizerTable {...tableProps} />}
{tab === 'pesticide' && <PesticideTable {...tableProps} />}
{tab === 'seed' && <SeedTable {...tableProps} />}
{tab === 'misc' && <MiscTable {...tableProps} />}
</div>
)}
</div>
@@ -384,6 +411,8 @@ interface TableProps {
editingId: number | 'new' | null;
form: MaterialFormState;
saving: boolean;
seedVarietyOptions: VarietyOption[];
getLinkedVarietyLabel: (materialId: number) => string;
onEdit: (material: Material) => void;
onDelete: (material: Material) => void;
onBaseFieldChange: (
@@ -532,13 +561,13 @@ function PesticideTable(props: TableProps) {
);
}
function MiscTable(props: TableProps) {
function SeedTable(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>
@@ -547,14 +576,16 @@ function MiscTable(props: TableProps) {
</tr>
</thead>
<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.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">
<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.stock_unit_display}</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 (
<table className="min-w-full text-sm">
<thead className="bg-gray-50">
@@ -598,10 +629,10 @@ function SeedTable(props: TableProps) {
</tr>
</thead>
<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.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">
<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 [presetTransactionType, setPresetTransactionType] =
useState<StockTransaction['transaction_type'] | null>(null);
const [editingTransaction, setEditingTransaction] = useState<StockTransaction | null>(null);
useEffect(() => {
fetchInitialData();
@@ -94,11 +95,41 @@ export default function MaterialsPage() {
materialId: number | null,
transactionType: StockTransaction['transaction_type'] | null
) => {
setEditingTransaction(null);
setPresetMaterialId(materialId);
setPresetTransactionType(transactionType);
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 () => {
await fetchSummaryOnly();
@@ -192,6 +223,8 @@ export default function MaterialsPage() {
historyLoadingId={historyLoadingId}
histories={histories}
onOpenTransaction={handleOpenTransaction}
onEditTransaction={handleEditTransaction}
onDeleteTransaction={handleDeleteTransaction}
onToggleHistory={handleToggleHistory}
/>
</div>
@@ -201,7 +234,11 @@ export default function MaterialsPage() {
materials={materials}
presetMaterialId={presetMaterialId}
presetTransactionType={presetTransactionType}
onClose={() => setIsTransactionOpen(false)}
editingTransaction={editingTransaction}
onClose={() => {
setIsTransactionOpen(false);
setEditingTransaction(null);
}}
onSaved={handleSavedTransaction}
/>
</div>

View File

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