From 222beb5043315979267f7ae93b31165c51fdce94 Mon Sep 17 00:00:00 2001 From: efrilm Date: Sat, 13 Sep 2025 03:03:08 +0700 Subject: [PATCH] Purchase create --- src/types/apps/purchaseOrderTypes.ts | 40 -- src/types/services/purchaseOrder.ts | 25 ++ .../purchase-form/PurchaseAddForm.tsx | 359 ++++++++++++------ 3 files changed, 278 insertions(+), 146 deletions(-) diff --git a/src/types/apps/purchaseOrderTypes.ts b/src/types/apps/purchaseOrderTypes.ts index 1072bd8..3fece1c 100644 --- a/src/types/apps/purchaseOrderTypes.ts +++ b/src/types/apps/purchaseOrderTypes.ts @@ -10,46 +10,6 @@ export type PurchaseOrderType = { total: number } -export interface IngredientItem { - id: number - ingredient: { label: string; value: string } | null - deskripsi: string - kuantitas: number - satuan: { label: string; value: string } | null - harga: number - total: number -} - -export interface PurchaseOrderFormData { - vendor: { label: string; value: string } | null - nomor: string - tglTransaksi: string - tglJatuhTempo: string - referensi: string - termin: { label: string; value: string } | null - hargaTermasukPajak: boolean - showShippingInfo: boolean - tanggalPengiriman: string - ekspedisi: { label: string; value: string } | null - noResi: string - showPesan: boolean - showAttachment: boolean - showTambahDiskon: boolean - showBiayaPengiriman: boolean - showBiayaTransaksi: boolean - showUangMuka: boolean - pesan: string - ingredientItems: IngredientItem[] - transactionCosts?: TransactionCost[] - subtotal?: number - discountType?: 'percentage' | 'fixed' - downPaymentType?: 'percentage' | 'fixed' - discountValue?: string - shippingCost?: string - transactionCost?: string - downPayment?: string -} - export interface TransactionCost { id: string type: string diff --git a/src/types/services/purchaseOrder.ts b/src/types/services/purchaseOrder.ts index ee9153b..92a3def 100644 --- a/src/types/services/purchaseOrder.ts +++ b/src/types/services/purchaseOrder.ts @@ -1,3 +1,4 @@ +import { IngredientItem } from './ingredient' import { Vendor } from './vendor' export interface PurchaseOrderRequest { @@ -93,3 +94,27 @@ export interface PurchaseOrderFile { created_at: string updated_at: string } + +export 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[] +} + +export 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 +} diff --git a/src/views/apps/purchase/purchase-form/PurchaseAddForm.tsx b/src/views/apps/purchase/purchase-form/PurchaseAddForm.tsx index a6feab3..89a74e2 100644 --- a/src/views/apps/purchase/purchase-form/PurchaseAddForm.tsx +++ b/src/views/apps/purchase/purchase-form/PurchaseAddForm.tsx @@ -15,7 +15,10 @@ import { TableHead, TableRow, Paper, - CircularProgress + CircularProgress, + Alert, + Popover, + Divider } from '@mui/material' import Grid from '@mui/material/Grid2' import CustomAutocomplete from '@/@core/components/mui/Autocomplete' @@ -27,88 +30,113 @@ 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 -} +import { PurchaseOrderFormData, PurchaseOrderFormItem, PurchaseOrderRequest } from '@/types/services/purchaseOrder' +import { IngredientItem } from '@/types/services/ingredient' 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 ValidationErrors { + vendor?: string + po_number?: string + transaction_date?: string + due_date?: string + items?: string + general?: 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 +interface PopoverState { + isOpen: boolean + anchorEl: HTMLElement | null + itemIndex: number | null +} + +// Komponen PricePopover +const PricePopover: React.FC<{ + anchorEl: HTMLElement | null + open: boolean + onClose: () => void + ingredientData: any +}> = ({ anchorEl, open, onClose, ingredientData }) => { + if (!ingredientData) return null + + const lastPrice = ingredientData.originalData?.cost || 0 + + return ( + + + + + Harga beli terakhir + + + {new Intl.NumberFormat('id-ID').format(lastPrice)} + + + + + + + + + ) } const PurchaseAddForm: React.FC = () => { const [imageUrl, setImageUrl] = useState('') + const [errors, setErrors] = useState({}) + const [popoverState, setPopoverState] = useState({ + isOpen: false, + anchorEl: null, + itemIndex: null + }) const [formData, setFormData] = useState({ vendor: null, po_number: '', transaction_date: '', due_date: '', reference: '', - status: 'draft', - // Bottom section toggles + status: 'sent', showPesan: false, showAttachment: false, message: '', - // Items items: [ { id: 1, @@ -154,7 +182,7 @@ const PurchaseAddForm: React.FC = () => { label: ingredient.name, value: ingredient.id, id: ingredient.id, - originalData: ingredient // This includes the full IngredientItem with unit, cost, etc. + originalData: ingredient })) }, [ingredients, isLoadingIngredients]) @@ -172,12 +200,78 @@ const PurchaseAddForm: React.FC = () => { ) }, [units, isLoadingUnits]) + // Handle price field click untuk menampilkan popover + const handlePriceFieldClick = (event: React.MouseEvent, itemIndex: number) => { + const item = formData.items[itemIndex] + if (item.ingredient) { + setPopoverState({ + isOpen: true, + anchorEl: event.currentTarget, + itemIndex: itemIndex + }) + } + } + + // Close popover + const handleClosePopover = () => { + setPopoverState({ + isOpen: false, + anchorEl: null, + itemIndex: null + }) + } + + // Fungsi validasi + const validateForm = (): boolean => { + const newErrors: ValidationErrors = {} + + if (!formData.vendor || !formData.vendor.value) { + newErrors.vendor = 'Vendor wajib dipilih' + } + + if (!formData.po_number.trim()) { + newErrors.po_number = 'Nomor PO wajib diisi' + } + + if (!formData.transaction_date) { + newErrors.transaction_date = 'Tanggal transaksi wajib diisi' + } + + if (!formData.due_date) { + newErrors.due_date = 'Tanggal jatuh tempo wajib diisi' + } + + if (formData.transaction_date && formData.due_date) { + if (new Date(formData.due_date) < new Date(formData.transaction_date)) { + newErrors.due_date = 'Tanggal jatuh tempo tidak boleh sebelum tanggal transaksi' + } + } + + const validItems = formData.items.filter( + item => item.ingredient && item.unit && item.quantity > 0 && item.amount > 0 + ) + + if (validItems.length === 0) { + newErrors.items = 'Minimal harus ada 1 item yang valid dengan bahan, satuan, kuantitas dan harga yang terisi' + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + // Handler Functions const handleInputChange = (field: keyof PurchaseOrderFormData, value: any): void => { setFormData(prev => ({ ...prev, [field]: value })) + + if (errors[field as keyof ValidationErrors]) { + setErrors(prev => ({ + ...prev, + [field]: undefined + })) + } } const handleItemChange = (index: number, field: keyof PurchaseOrderFormItem, value: any): void => { @@ -185,7 +279,6 @@ const PurchaseAddForm: React.FC = () => { const newItems = [...prev.items] newItems[index] = { ...newItems[index], [field]: value } - // Auto-calculate total if amount or quantity changes if (field === 'amount' || field === 'quantity') { const item = newItems[index] item.total = item.amount * item.quantity @@ -193,30 +286,31 @@ const PurchaseAddForm: React.FC = () => { return { ...prev, items: newItems } }) + + if (errors.items) { + setErrors(prev => ({ + ...prev, + items: undefined + })) + } } 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) { + } 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 @@ -226,12 +320,10 @@ const PurchaseAddForm: React.FC = () => { } } - // 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) } @@ -261,7 +353,6 @@ const PurchaseAddForm: React.FC = () => { })) } - // 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 ?? '')) @@ -280,29 +371,26 @@ const PurchaseAddForm: React.FC = () => { const formData = new FormData() formData.append('file', file) formData.append('file_type', 'image') - formData.append('description', 'Purchase image') + formData.append('description', 'Gambar Purchase Order') 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 + resolve(data.id) }, onError: error => { - reject(error) // biar async/await bisa tangkep error + reject(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 || '', @@ -313,7 +401,7 @@ const PurchaseAddForm: React.FC = () => { status: formData.status, message: formData.message || undefined, items: formData.items - .filter(item => item.ingredient && item.unit) // Only include valid items + .filter(item => item.ingredient && item.unit) .map(item => ({ ingredient_id: item.ingredient!.value, description: item.description || undefined, @@ -326,19 +414,46 @@ const PurchaseAddForm: React.FC = () => { } const handleSave = () => { + if (!validateForm()) { + setErrors(prev => ({ + ...prev, + general: 'Mohon lengkapi semua field yang wajib diisi' + })) + return + } + createPurchaseOrder.mutate(convertToApiRequest(), { onSuccess: () => { window.history.back() + }, + onError: error => { + setErrors(prev => ({ + ...prev, + general: 'Terjadi kesalahan saat menyimpan data. Silakan coba lagi.' + })) } }) } + // Get current ingredient data for popover + const getCurrentIngredientData = () => { + if (popoverState.itemIndex !== null) { + return formData.items[popoverState.itemIndex]?.ingredient + } + return null + } + return ( + {errors.general && ( + + {errors.general} + + )} + {/* 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) + console.log('Vendor terpilih:', selectedVendorData) } }} loading={isLoadingVendors} renderInput={params => ( )} /> @@ -387,9 +504,11 @@ const PurchaseAddForm: React.FC = () => { ) => handleInputChange('po_number', e.target.value)} + error={!!errors.po_number} + helperText={errors.po_number} /> @@ -397,7 +516,7 @@ const PurchaseAddForm: React.FC = () => { ) => @@ -406,25 +525,29 @@ const PurchaseAddForm: React.FC = () => { InputLabelProps={{ shrink: true }} + error={!!errors.transaction_date} + helperText={errors.transaction_date} /> ) => handleInputChange('due_date', e.target.value)} InputLabelProps={{ shrink: true }} + error={!!errors.due_date} + helperText={errors.due_date} /> ) => handleInputChange('reference', e.target.value)} /> @@ -433,18 +556,24 @@ const PurchaseAddForm: React.FC = () => { {/* ITEMS TABLE SECTION */} - Purchase Order Items + Item Purchase Order + {errors.items && ( + + {errors.items} + + )} + - Ingredient - Description - Quantity - Unit - Amount + Bahan + Deskripsi + Kuantitas + Satuan + Harga Total @@ -472,7 +601,7 @@ const PurchaseAddForm: React.FC = () => { renderInput={params => ( { onChange={(e: React.ChangeEvent) => handleItemChange(index, 'description', e.target.value) } - placeholder='Description' + placeholder='Deskripsi' /> @@ -524,7 +653,7 @@ const PurchaseAddForm: React.FC = () => { renderInput={params => ( { const numericValue = parseFloat(value) handleItemChange(index, 'amount', isNaN(numericValue) ? 0 : numericValue) }} + onClick={e => handlePriceFieldClick(e, index)} inputProps={{ min: 0, step: 'any' }} placeholder='0' + sx={{ cursor: item.ingredient ? 'pointer' : 'text' }} /> @@ -596,7 +727,7 @@ const PurchaseAddForm: React.FC = () => { sx={{ mt: 1 }} disabled={isLoadingIngredients || isLoadingUnits} > - Add Item + Tambah Item @@ -636,7 +767,7 @@ const PurchaseAddForm: React.FC = () => { )} - Message + Pesan {formData.showPesan && ( @@ -644,7 +775,7 @@ const PurchaseAddForm: React.FC = () => { fullWidth multiline rows={3} - placeholder='Add message...' + placeholder='Tambahkan pesan...' value={formData.message || ''} onChange={(e: React.ChangeEvent) => handleInputChange('message', e.target.value) @@ -685,7 +816,7 @@ const PurchaseAddForm: React.FC = () => { )} - Attachment + Lampiran {formData.showAttachment && ( { maxFileSize={1 * 1024 * 1024} showUrlOption={false} currentImageUrl={imageUrl} - dragDropText='Drop your image here' - browseButtonText='Choose Image' + dragDropText='Letakkan gambar Anda di sini' + browseButtonText='Pilih Gambar' /> )} @@ -759,6 +890,7 @@ const PurchaseAddForm: React.FC = () => { color='primary' fullWidth onClick={handleSave} + disabled={createPurchaseOrder.isPending} sx={{ textTransform: 'none', fontWeight: 600, @@ -770,13 +902,28 @@ const PurchaseAddForm: React.FC = () => { } }} > - Save + {createPurchaseOrder.isPending ? ( + <> + + Menyimpan... + + ) : ( + 'Simpan' + )} + + {/* Price Popover */} + )