From d54d623d4cea850c35515490129e28eb016832b2 Mon Sep 17 00:00:00 2001 From: efrilm Date: Sat, 13 Sep 2025 01:34:43 +0700 Subject: [PATCH] Create Purchase --- src/services/mutations/purchaseOrder.ts | 14 +- src/types/apps/purchaseOrderTypes.ts | 3 - .../purchase-form/PurchaseAddForm.tsx | 786 ++++++++++++++++-- 3 files changed, 733 insertions(+), 70 deletions(-) diff --git a/src/services/mutations/purchaseOrder.ts b/src/services/mutations/purchaseOrder.ts index 6fc2d3b..7ec250f 100644 --- a/src/services/mutations/purchaseOrder.ts +++ b/src/services/mutations/purchaseOrder.ts @@ -3,20 +3,22 @@ import { toast } from 'react-toastify' import { api } from '../api' import { PurchaseOrderRequest } from '@/types/services/purchaseOrder' -export const useVendorsMutation = () => { +export const usePurchaseOrdersMutation = () => { const queryClient = useQueryClient() - const createVendor = useMutation({ - mutationFn: async (newVendor: PurchaseOrderRequest) => { - const response = await api.post('/vendors', newVendor) + const createPurchaseOrder = useMutation({ + mutationFn: async (newPurchaseOrder: PurchaseOrderRequest) => { + const response = await api.post('/purchase-orders', newPurchaseOrder) return response.data }, onSuccess: () => { - toast.success('Vendor created successfully!') - queryClient.invalidateQueries({ queryKey: ['vendors'] }) + toast.success('Purchase Order created successfully!') + queryClient.invalidateQueries({ queryKey: ['purchase-orders'] }) }, onError: (error: any) => { toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed') } }) + + return { createPurchaseOrder } } diff --git a/src/types/apps/purchaseOrderTypes.ts b/src/types/apps/purchaseOrderTypes.ts index 987560f..1072bd8 100644 --- a/src/types/apps/purchaseOrderTypes.ts +++ b/src/types/apps/purchaseOrderTypes.ts @@ -16,10 +16,7 @@ export interface IngredientItem { deskripsi: string kuantitas: number satuan: { label: string; value: string } | null - discount: string harga: number - pajak: { label: string; value: string } | null - waste: { label: string; value: string } | null total: number } diff --git a/src/views/apps/purchase/purchase-form/PurchaseAddForm.tsx b/src/views/apps/purchase/purchase-form/PurchaseAddForm.tsx index 7a158d9..a6feab3 100644 --- a/src/views/apps/purchase/purchase-form/PurchaseAddForm.tsx +++ b/src/views/apps/purchase/purchase-form/PurchaseAddForm.tsx @@ -1,52 +1,178 @@ 'use client' -import React, { useState } from 'react' -import { Card, CardContent } from '@mui/material' +import React, { useState, useMemo } from 'react' +import { + Card, + CardContent, + Button, + Box, + Typography, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + CircularProgress +} from '@mui/material' import Grid from '@mui/material/Grid2' -import { IngredientItem, PurchaseOrderFormData } from '@/types/apps/purchaseOrderTypes' -import PurchaseBasicInfo from './PurchaseBasicInfo' -import PurchaseIngredientsTable from './PurchaseIngredientsTable' -import PurchaseSummary from './PurchaseSummary' +import CustomAutocomplete from '@/@core/components/mui/Autocomplete' +import CustomTextField from '@/@core/components/mui/TextField' +import ImageUpload from '@/components/ImageUpload' +import { DropdownOption } from '@/types/apps/purchaseOrderTypes' +import { useVendorActive } from '@/services/queries/vendor' +import { useIngredients } from '@/services/queries/ingredients' +import { useUnits } from '@/services/queries/units' +import { useFilesMutation } from '@/services/mutations/files' +import { usePurchaseOrdersMutation } from '@/services/mutations/purchaseOrder' + +export interface PurchaseOrderRequest { + vendor_id: string // uuid.UUID + po_number: string + transaction_date: string // ISO date string + due_date: string // ISO date string + reference?: string + status?: 'draft' | 'sent' | 'approved' | 'received' | 'cancelled' + message?: string + items: PurchaseOrderItemRequest[] + attachment_file_ids?: string[] // uuid.UUID[] +} + +export interface PurchaseOrderItemRequest { + ingredient_id: string // uuid.UUID + description?: string + quantity: number + unit_id: string // uuid.UUID + amount: number +} + +export type IngredientItem = { + id: string + organization_id: string + outlet_id: string + name: string + unit_id: string + cost: number + stock: number + is_semi_finished: boolean + is_active: boolean + metadata: Record + created_at: string + updated_at: string + unit: Unit +} + +export type Unit = { + id: string + name: string + // Add other unit properties as needed +} + +// Internal form state interface for UI management +interface PurchaseOrderFormData { + vendor: { label: string; value: string } | null + po_number: string + transaction_date: string + due_date: string + reference: string + status: 'draft' | 'sent' | 'approved' | 'received' | 'cancelled' + showPesan: boolean + showAttachment: boolean + message: string + items: PurchaseOrderFormItem[] + attachment_file_ids: string[] +} + +interface PurchaseOrderFormItem { + id: number // for UI tracking + ingredient: { label: string; value: string; originalData?: IngredientItem } | null + description: string + quantity: number + unit: { label: string; value: string } | null + amount: number + total: number // calculated field for UI +} const PurchaseAddForm: React.FC = () => { + const [imageUrl, setImageUrl] = useState('') const [formData, setFormData] = useState({ vendor: null, - nomor: 'PO/00043', - tglTransaksi: '2025-09-09', - tglJatuhTempo: '2025-09-10', - referensi: '', - termin: null, - hargaTermasukPajak: true, - // Shipping info - showShippingInfo: false, - tanggalPengiriman: '', - ekspedisi: null, - noResi: '', + po_number: '', + transaction_date: '', + due_date: '', + reference: '', + status: 'draft', // Bottom section toggles showPesan: false, showAttachment: false, - showTambahDiskon: false, - showBiayaPengiriman: false, - showBiayaTransaksi: false, - showUangMuka: false, - pesan: '', - // Ingredient items (updated from productItems) - ingredientItems: [ + message: '', + // Items + items: [ { id: 1, ingredient: null, - deskripsi: '', - kuantitas: 1, - satuan: null, - discount: '0', - harga: 0, - pajak: null, - waste: null, + description: '', + quantity: 1, + unit: null, + amount: 0, total: 0 } - ] + ], + attachment_file_ids: [] }) + // API Hooks + const { data: vendors, isLoading: isLoadingVendors } = useVendorActive() + const { data: ingredients, isLoading: isLoadingIngredients } = useIngredients() + const { data: units, isLoading: isLoadingUnits } = useUnits({ + page: 1, + limit: 50 + }) + + const { mutate, isPending } = useFilesMutation().uploadFile + const { createPurchaseOrder } = usePurchaseOrdersMutation() + + // Transform vendors data to dropdown options + const vendorOptions: DropdownOption[] = useMemo(() => { + return ( + vendors?.map(vendor => ({ + label: vendor.name, + value: vendor.id + })) || [] + ) + }, [vendors]) + + // Transform ingredients data to autocomplete options format + const ingredientOptions = useMemo(() => { + if (!ingredients || isLoadingIngredients) { + return [] + } + + return ingredients?.data.map((ingredient: IngredientItem) => ({ + label: ingredient.name, + value: ingredient.id, + id: ingredient.id, + originalData: ingredient // This includes the full IngredientItem with unit, cost, etc. + })) + }, [ingredients, isLoadingIngredients]) + + // Transform units data to dropdown options + const unitOptions = useMemo(() => { + if (!units || isLoadingUnits) { + return [] + } + + return ( + units?.data?.map((unit: any) => ({ + label: unit.name || unit.nama || unit.unit_name, + value: unit.id || unit.code || unit.value + })) || [] + ) + }, [units, isLoadingUnits]) + + // Handler Functions const handleInputChange = (field: keyof PurchaseOrderFormData, value: any): void => { setFormData(prev => ({ ...prev, @@ -54,64 +180,602 @@ const PurchaseAddForm: React.FC = () => { })) } - const handleIngredientChange = (index: number, field: keyof IngredientItem, value: any): void => { + const handleItemChange = (index: number, field: keyof PurchaseOrderFormItem, value: any): void => { setFormData(prev => { - const newItems = [...prev.ingredientItems] + const newItems = [...prev.items] newItems[index] = { ...newItems[index], [field]: value } - // Auto-calculate total if price or quantity changes - if (field === 'harga' || field === 'kuantitas') { + // Auto-calculate total if amount or quantity changes + if (field === 'amount' || field === 'quantity') { const item = newItems[index] - item.total = item.harga * item.kuantitas + item.total = item.amount * item.quantity } - return { ...prev, ingredientItems: newItems } + return { ...prev, items: newItems } }) } - const addIngredientItem = (): void => { - const newItem: IngredientItem = { + const handleIngredientSelection = (index: number, selectedIngredient: any) => { + handleItemChange(index, 'ingredient', selectedIngredient) + + // Auto-populate related fields if available in the ingredient data + if (selectedIngredient) { + const ingredientData: IngredientItem = selectedIngredient.originalData || selectedIngredient + + // Auto-fill unit based on IngredientItem structure + if (ingredientData.unit_id || ingredientData.unit) { + let unitToFind = null + + // If ingredient has unit object (populated relation) + if (ingredientData.unit && typeof ingredientData.unit === 'object') { + unitToFind = ingredientData.unit + } + // If ingredient has unit_id, find the unit from unitOptions + else if (ingredientData.unit_id) { + unitToFind = unitOptions.find(option => option.value === ingredientData.unit_id) + } + + if (unitToFind) { + // Create unit option object + const unitOption = { + label: (unitToFind as any).label || (unitToFind as any).name || (unitToFind as any).unit_name, + value: (unitToFind as any).value || ingredientData.unit_id + } + + handleItemChange(index, 'unit', unitOption) + } + } + + // Auto-fill amount with cost from IngredientItem + if (ingredientData.cost !== undefined && ingredientData.cost !== null) { + handleItemChange(index, 'amount', ingredientData.cost) + } + + // Auto-fill description with ingredient name + if (ingredientData.name) { + handleItemChange(index, 'description', ingredientData.name) + } + } + } + + const addItem = (): void => { + const newItem: PurchaseOrderFormItem = { id: Date.now(), ingredient: null, - deskripsi: '', - kuantitas: 1, - satuan: null, - discount: '0%', - harga: 0, - pajak: null, - waste: null, + description: '', + quantity: 1, + unit: null, + amount: 0, total: 0 } setFormData(prev => ({ ...prev, - ingredientItems: [...prev.ingredientItems, newItem] + items: [...prev.items, newItem] })) } - const removeIngredientItem = (index: number): void => { + const removeItem = (index: number): void => { setFormData(prev => ({ ...prev, - ingredientItems: prev.ingredientItems.filter((_, i) => i !== index) + items: prev.items.filter((_, i) => i !== index) })) } + // Function to get selected vendor data + const getSelectedVendorData = () => { + if (!formData.vendor?.value || !vendors) return null + const selectedVendor = vendors.find(vendor => vendor.id === (formData?.vendor?.value ?? '')) + return selectedVendor + } + + const upsertAttachment = (attachments: string[], newId: string, index = 0) => { + if (attachments.length === 0) { + return [newId] + } + return attachments.map((id, i) => (i === index ? newId : id)) + } + + const handleUpload = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const formData = new FormData() + formData.append('file', file) + formData.append('file_type', 'image') + formData.append('description', 'Purchase image') + + mutate(formData, { + onSuccess: data => { + // pemakaian: + setFormData(prev => ({ + ...prev, + attachment_file_ids: upsertAttachment(prev.attachment_file_ids, data.id) + })) + setImageUrl(data.file_url) + resolve(data.id) // <-- balikin id file yang berhasil diupload + }, + onError: error => { + reject(error) // biar async/await bisa tangkep error + } + }) + }) + } + + // Calculate subtotal from items + const subtotal = formData.items.reduce((sum, item) => sum + (item.total || 0), 0) + + // Convert form data to API request format + const convertToApiRequest = (): PurchaseOrderRequest => { + return { + vendor_id: formData.vendor?.value || '', + po_number: formData.po_number, + transaction_date: formData.transaction_date, + due_date: formData.due_date, + reference: formData.reference || undefined, + status: formData.status, + message: formData.message || undefined, + items: formData.items + .filter(item => item.ingredient && item.unit) // Only include valid items + .map(item => ({ + ingredient_id: item.ingredient!.value, + description: item.description || undefined, + quantity: item.quantity, + unit_id: item.unit!.value, + amount: item.amount + })), + attachment_file_ids: formData.attachment_file_ids.length > 0 ? formData.attachment_file_ids : undefined + } + } + + const handleSave = () => { + createPurchaseOrder.mutate(convertToApiRequest(), { + onSuccess: () => { + window.history.back() + } + }) + } + return ( - {/* Basic Info Section */} - + {/* BASIC INFO SECTION */} + {/* Row 1 - Vendor and PO Number */} + + { + handleInputChange('vendor', newValue) + if (newValue?.value) { + const selectedVendorData = vendors?.find(vendor => vendor.id === newValue.value) + console.log('Vendor selected:', selectedVendorData) + } + }} + loading={isLoadingVendors} + renderInput={params => ( + + )} + /> + {getSelectedVendorData() && ( + + + + + {getSelectedVendorData()?.contact_person ?? ''} + + + + + + {getSelectedVendorData()?.address ?? '-'} + + + + + + {getSelectedVendorData()?.phone_number ?? '-'} + + + + )} + + + ) => handleInputChange('po_number', e.target.value)} + /> + - {/* Ingredients Table Section */} - + {/* Row 2 - Transaction Date, Due Date, Status */} + + ) => + handleInputChange('transaction_date', e.target.value) + } + InputLabelProps={{ + shrink: true + }} + /> + + + ) => handleInputChange('due_date', e.target.value)} + InputLabelProps={{ + shrink: true + }} + /> + + + ) => handleInputChange('reference', e.target.value)} + /> + - {/* Summary Section */} - + {/* ITEMS TABLE SECTION */} + + + Purchase Order Items + + + + + + + Ingredient + Description + Quantity + Unit + Amount + Total + + + + + {formData.items.map((item: PurchaseOrderFormItem, index: number) => ( + + + handleIngredientSelection(index, newValue)} + loading={isLoadingIngredients} + getOptionLabel={(option: any) => { + if (!option) return '' + return option.label || option.name || option.nama || '' + }} + isOptionEqualToValue={(option: any, value: any) => { + if (!option || !value) return false + const optionId = option.value || option.id + const valueId = value.value || value.id + return optionId === valueId + }} + renderInput={params => ( + + {isLoadingIngredients ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + disabled={isLoadingIngredients} + /> + + + ) => + handleItemChange(index, 'description', e.target.value) + } + placeholder='Description' + /> + + + ) => + handleItemChange(index, 'quantity', parseInt(e.target.value) || 1) + } + inputProps={{ min: 1 }} + /> + + + handleItemChange(index, 'unit', newValue)} + loading={isLoadingUnits} + getOptionLabel={(option: any) => { + if (!option) return '' + return option.label || option.name || option.nama || '' + }} + renderInput={params => ( + + {isLoadingUnits ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + disabled={isLoadingUnits} + /> + + + ) => { + const value = e.target.value + if (value === '') { + handleItemChange(index, 'amount', 0) + return + } + const numericValue = parseFloat(value) + handleItemChange(index, 'amount', isNaN(numericValue) ? 0 : numericValue) + }} + inputProps={{ min: 0, step: 'any' }} + placeholder='0' + /> + + + + + + removeItem(index)} + disabled={formData.items.length === 1} + > + + + + + ))} + +
+
+ + {/* Add New Item Button */} + +
+ + {/* SUMMARY SECTION */} + + + {/* Left Side - Message and Attachment */} + + {/* Message Section */} + + + {formData.showPesan && ( + + ) => + handleInputChange('message', e.target.value) + } + /> + + )} + + + {/* Attachment Section */} + + + {formData.showAttachment && ( + + )} + + + + {/* Right Side - Totals */} + + + {/* Sub Total */} + + + Sub Total + + + {new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0 + }).format(subtotal)} + + + + {/* Total */} + + + Total + + + {new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0 + }).format(subtotal)} + + + + {/* Save Button */} + + + + +