Purchase create

This commit is contained in:
efrilm 2025-09-13 03:03:08 +07:00
parent 98d6446b0c
commit 222beb5043
3 changed files with 278 additions and 146 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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<string, unknown>
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 (
<Popover
open={open}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
PaperProps={{
sx: {
minWidth: 300,
maxWidth: 350,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
borderRadius: 2
}
}}
>
<Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant='body2' color='text.secondary'>
Harga beli terakhir
</Typography>
<Typography variant='h6' color='primary' fontWeight={600}>
{new Intl.NumberFormat('id-ID').format(lastPrice)}
</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Button
variant='text'
size='small'
sx={{
color: 'primary.main',
textTransform: 'none',
p: 0,
minWidth: 'auto'
}}
onClick={() => {
console.log('Navigate to purchase history')
onClose()
}}
>
Riwayat harga beli
</Button>
</Box>
</Popover>
)
}
const PurchaseAddForm: React.FC = () => {
const [imageUrl, setImageUrl] = useState<string>('')
const [errors, setErrors] = useState<ValidationErrors>({})
const [popoverState, setPopoverState] = useState<PopoverState>({
isOpen: false,
anchorEl: null,
itemIndex: null
})
const [formData, setFormData] = useState<PurchaseOrderFormData>({
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<HTMLElement>, 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 (
<Card>
<CardContent>
{errors.general && (
<Alert severity='error' sx={{ mb: 3 }}>
{errors.general}
</Alert>
)}
<Grid container spacing={3}>
{/* BASIC INFO SECTION */}
{/* Row 1 - Vendor and PO Number */}
<Grid size={{ xs: 12, sm: 6, md: 6 }}>
<CustomAutocomplete
fullWidth
@ -348,16 +463,18 @@ const PurchaseAddForm: React.FC = () => {
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 => (
<CustomTextField
{...params}
label='Vendor'
placeholder={isLoadingVendors ? 'Memuat vendor...' : 'Pilih kontak'}
label='Vendor *'
placeholder={isLoadingVendors ? 'Memuat vendor...' : 'Pilih vendor'}
fullWidth
error={!!errors.vendor}
helperText={errors.vendor}
/>
)}
/>
@ -387,9 +504,11 @@ const PurchaseAddForm: React.FC = () => {
<Grid size={{ xs: 12, sm: 6, md: 6 }}>
<CustomTextField
fullWidth
label='PO Number'
label='Nomor PO *'
value={formData.po_number}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleInputChange('po_number', e.target.value)}
error={!!errors.po_number}
helperText={errors.po_number}
/>
</Grid>
@ -397,7 +516,7 @@ const PurchaseAddForm: React.FC = () => {
<Grid size={{ xs: 12, sm: 4, md: 4 }}>
<CustomTextField
fullWidth
label='Transaction Date'
label='Tanggal Transaksi *'
type='date'
value={formData.transaction_date}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@ -406,25 +525,29 @@ const PurchaseAddForm: React.FC = () => {
InputLabelProps={{
shrink: true
}}
error={!!errors.transaction_date}
helperText={errors.transaction_date}
/>
</Grid>
<Grid size={{ xs: 12, sm: 4, md: 4 }}>
<CustomTextField
fullWidth
label='Due Date'
label='Tanggal Jatuh Tempo *'
type='date'
value={formData.due_date}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleInputChange('due_date', e.target.value)}
InputLabelProps={{
shrink: true
}}
error={!!errors.due_date}
helperText={errors.due_date}
/>
</Grid>
<Grid size={{ xs: 12, sm: 4, md: 4 }}>
<CustomTextField
fullWidth
label='Reference'
placeholder='Reference'
label='Referensi'
placeholder='Referensi'
value={formData.reference}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleInputChange('reference', e.target.value)}
/>
@ -433,18 +556,24 @@ const PurchaseAddForm: React.FC = () => {
{/* ITEMS TABLE SECTION */}
<Grid size={{ xs: 12 }} sx={{ mt: 4 }}>
<Typography variant='h6' sx={{ mb: 2, fontWeight: 600 }}>
Purchase Order Items
Item Purchase Order
</Typography>
{errors.items && (
<Alert severity='error' sx={{ mb: 2 }}>
{errors.items}
</Alert>
)}
<TableContainer component={Paper} variant='outlined'>
<Table>
<TableHead>
<TableRow sx={{ backgroundColor: 'grey.50' }}>
<TableCell sx={{ fontWeight: 'bold', minWidth: 180 }}>Ingredient</TableCell>
<TableCell sx={{ fontWeight: 'bold', minWidth: 150 }}>Description</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: 100 }}>Quantity</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>Unit</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>Amount</TableCell>
<TableCell sx={{ fontWeight: 'bold', minWidth: 180 }}>Bahan</TableCell>
<TableCell sx={{ fontWeight: 'bold', minWidth: 150 }}>Deskripsi</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: 100 }}>Kuantitas</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>Satuan</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: 120 }}>Harga</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: 100, textAlign: 'right' }}>Total</TableCell>
<TableCell sx={{ width: 50 }}></TableCell>
</TableRow>
@ -472,7 +601,7 @@ const PurchaseAddForm: React.FC = () => {
renderInput={params => (
<CustomTextField
{...params}
placeholder={isLoadingIngredients ? 'Loading ingredients...' : 'Select Ingredient'}
placeholder={isLoadingIngredients ? 'Memuat bahan...' : 'Pilih Bahan'}
InputProps={{
...params.InputProps,
endAdornment: (
@ -495,7 +624,7 @@ const PurchaseAddForm: React.FC = () => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleItemChange(index, 'description', e.target.value)
}
placeholder='Description'
placeholder='Deskripsi'
/>
</TableCell>
<TableCell>
@ -524,7 +653,7 @@ const PurchaseAddForm: React.FC = () => {
renderInput={params => (
<CustomTextField
{...params}
placeholder={isLoadingUnits ? 'Loading units...' : 'Select...'}
placeholder={isLoadingUnits ? 'Memuat satuan...' : 'Pilih satuan...'}
InputProps={{
...params.InputProps,
endAdornment: (
@ -554,8 +683,10 @@ const PurchaseAddForm: React.FC = () => {
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' }}
/>
</TableCell>
<TableCell>
@ -596,7 +727,7 @@ const PurchaseAddForm: React.FC = () => {
sx={{ mt: 1 }}
disabled={isLoadingIngredients || isLoadingUnits}
>
Add Item
Tambah Item
</Button>
</Grid>
@ -636,7 +767,7 @@ const PurchaseAddForm: React.FC = () => {
<i className='tabler-chevron-right w-4 h-4' />
)}
</Box>
Message
Pesan
</Button>
{formData.showPesan && (
<Box sx={{ mt: 2 }}>
@ -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<HTMLInputElement>) =>
handleInputChange('message', e.target.value)
@ -685,7 +816,7 @@ const PurchaseAddForm: React.FC = () => {
<i className='tabler-chevron-right w-4 h-4' />
)}
</Box>
Attachment
Lampiran
</Button>
{formData.showAttachment && (
<ImageUpload
@ -693,8 +824,8 @@ const PurchaseAddForm: React.FC = () => {
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'
/>
)}
</Box>
@ -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 ? (
<>
<CircularProgress size={16} sx={{ mr: 1 }} />
Menyimpan...
</>
) : (
'Simpan'
)}
</Button>
</Box>
</Grid>
</Grid>
</Grid>
</Grid>
{/* Price Popover */}
<PricePopover
anchorEl={popoverState.anchorEl}
open={popoverState.isOpen}
onClose={handleClosePopover}
ingredientData={getCurrentIngredientData()}
/>
</CardContent>
</Card>
)