その判断で進めました。在庫管理を先に固めるように切り替えて、手元の実装もそちらを優先して直しています。
今回入れたのは、在庫履歴の編集・削除対応と、種子資材を資材マスタ側で品種に直接結び付ける流れです。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:
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
"""在庫集計一覧"""
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user