Send Payment #8

Merged
aefril merged 1 commits from efril into main 2025-09-13 05:32:16 +00:00
4 changed files with 132 additions and 227 deletions
Showing only changes of commit 7384b38df5 - Show all commits

View File

@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-toastify'
import { api } from '../api'
import { PurchaseOrderRequest } from '@/types/services/purchaseOrder'
import { PurchaseOrderRequest, SendPaymentPurchaseOrderRequest } from '@/types/services/purchaseOrder'
export const usePurchaseOrdersMutation = () => {
const queryClient = useQueryClient()
@ -20,5 +20,19 @@ export const usePurchaseOrdersMutation = () => {
}
})
return { createPurchaseOrder }
const sendPaymentPurchaseOrder = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: SendPaymentPurchaseOrderRequest }) => {
const response = await api.put(`/purchase-orders/${id}`, payload)
return response.data
},
onSuccess: () => {
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, sendPaymentPurchaseOrder }
}

View File

@ -118,3 +118,9 @@ export interface PurchaseOrderFormItem {
amount: number
total: number // calculated field for UI
}
export interface SendPaymentPurchaseOrderRequest {
reference?: string
status?: 'received'
attachment_file_ids?: string[] // uuid.UUID[]
}

View File

@ -12,6 +12,7 @@ import Loading from '@/components/layout/shared/Loading'
const PurchaseDetailContent = () => {
const params = useParams()
const { data, isLoading, error, isFetching } = usePurchaseOrderById(params.id as string)
const total = (data?.items ?? []).reduce((sum, item) => sum + (item?.amount ?? 0) * item?.quantity, 0)
return (
<>
{isLoading ? (
@ -23,7 +24,7 @@ const PurchaseDetailContent = () => {
</Grid>
{data?.status == 'sent' && (
<Grid size={{ xs: 12 }}>
<PurchaseDetailSendPayment />
<PurchaseDetailSendPayment id={data?.id} totalAmount={total} purchaseOrderNumber={data?.po_number} />
</Grid>
)}
{/* <Grid size={{ xs: 12 }}>

View File

@ -16,104 +16,130 @@ import {
} from '@mui/material'
import Grid from '@mui/material/Grid2'
import CustomTextField from '@/@core/components/mui/TextField'
import CustomAutocomplete from '@/@core/components/mui/Autocomplete'
import ImageUpload from '@/components/ImageUpload'
import { useFilesMutation } from '@/services/mutations/files'
import { usePurchaseOrdersMutation } from '@/services/mutations/purchaseOrder'
// API Interface
export interface SendPaymentPurchaseOrderRequest {
reference?: string
status?: 'received'
attachment_file_ids?: string[] // uuid.UUID[]
}
interface PaymentFormData {
totalDibayar: string
tglTransaksi: string
referensi: string
nomor: string
dibayarDari: string
}
interface PemotonganItem {
id: string
dipotong: string
persentase: string
nominal: string
tipe: 'persen' | 'rupiah'
interface PurchaseDetailSendPaymentProps {
id?: string
totalAmount?: number
purchaseOrderNumber?: string
onSubmit?: (data: SendPaymentPurchaseOrderRequest) => Promise<void>
loading?: boolean
}
const PurchaseDetailSendPayment: React.FC = () => {
const PurchaseDetailSendPayment: React.FC<PurchaseDetailSendPaymentProps> = ({
id,
totalAmount = 849000,
purchaseOrderNumber = 'PP/00025',
onSubmit,
loading = false
}) => {
const [formData, setFormData] = useState<PaymentFormData>({
totalDibayar: '849.000',
tglTransaksi: '10/09/2025',
totalDibayar: totalAmount.toString(),
referensi: '',
nomor: 'PP/00025',
dibayarDari: '1-10001 Kas'
nomor: purchaseOrderNumber
})
const [expanded, setExpanded] = useState<boolean>(false)
const [pemotonganItems, setPemotonganItems] = useState<PemotonganItem[]>([])
const [uploadedFileIds, setUploadedFileIds] = useState<string[]>([])
const [imageUrl, setImageUrl] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
const dibayarDariOptions = [
{ label: '1-10001 Kas', value: '1-10001 Kas' },
{ label: '1-10002 Bank BCA', value: '1-10002 Bank BCA' },
{ label: '1-10003 Bank Mandiri', value: '1-10003 Bank Mandiri' },
{ label: '1-10004 Petty Cash', value: '1-10004 Petty Cash' }
]
const pemotonganOptions = [
{ label: 'PPN 11%', value: 'ppn' },
{ label: 'PPh 21', value: 'pph21' },
{ label: 'PPh 23', value: 'pph23' },
{ label: 'Biaya Admin', value: 'admin' }
]
const { mutate, isPending } = useFilesMutation().uploadFile
const { sendPaymentPurchaseOrder } = usePurchaseOrdersMutation()
const handleChange =
(field: keyof PaymentFormData) => (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | any) => {
(field: keyof PaymentFormData) => (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData(prev => ({
...prev,
[field]: event.target.value
}))
}
const handleDibayarDariChange = (value: { label: string; value: string } | null) => {
setFormData(prev => ({
...prev,
dibayarDari: value?.value || ''
}))
}
const addPemotongan = () => {
const newItem: PemotonganItem = {
id: Date.now().toString(),
dipotong: '',
persentase: '0',
nominal: '',
tipe: 'persen'
}
setPemotonganItems(prev => [...prev, newItem])
}
const removePemotongan = (id: string) => {
setPemotonganItems(prev => prev.filter(item => item.id !== id))
}
const updatePemotongan = (id: string, field: keyof PemotonganItem, value: string) => {
setPemotonganItems(prev => prev.map(item => (item.id === id ? { ...item, [field]: value } : item)))
}
const handleAccordionChange = () => {
setExpanded(!expanded)
}
const calculatePemotongan = (item: PemotonganItem): number => {
const totalDibayar = parseInt(formData.totalDibayar.replace(/\D/g, '')) || 0
const nilai = parseFloat(item.persentase) || 0
if (item.tipe === 'persen') {
return (totalDibayar * nilai) / 100
} else {
return nilai
}
}
const formatCurrency = (amount: string | number): string => {
const numAmount = typeof amount === 'string' ? parseInt(amount.replace(/\D/g, '')) : amount
return new Intl.NumberFormat('id-ID').format(numAmount)
}
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<string> => {
return new Promise((resolve, reject) => {
const formData = new FormData()
formData.append('file', file)
formData.append('file_type', 'image')
formData.append('description', 'image purchase order payment')
mutate(formData, {
onSuccess: r => {
setUploadedFileIds(prev => upsertAttachment(prev, r.id))
setImageUrl(r.file_url)
resolve(r.id)
},
onError: er => {
reject(er)
}
})
})
}
const handleSubmit = async () => {
setIsSubmitting(true)
try {
const requestData: SendPaymentPurchaseOrderRequest = {
reference: formData.referensi || undefined,
status: 'received',
attachment_file_ids: uploadedFileIds.length > 0 ? uploadedFileIds : undefined
}
sendPaymentPurchaseOrder.mutate(
{
id: id as string,
payload: requestData
},
{
onSuccess: () => {
// Reset form after successful submission
setFormData(prev => ({
...prev,
referensi: ''
}))
setUploadedFileIds([])
setExpanded(false)
}
}
)
} catch (error) {
console.error('Error submitting payment:', error)
// Handle error (you might want to show a toast or error message)
} finally {
setIsSubmitting(false)
}
}
return (
<Card>
<CardHeader
@ -129,7 +155,7 @@ const PurchaseDetailSendPayment: React.FC = () => {
<Grid container spacing={3}>
{/* Left Column */}
<Grid size={{ xs: 12, md: 6 }}>
{/* Total Dibayar */}
{/* Total Dibayar - DISABLED */}
<Box sx={{ mb: 3 }}>
<CustomTextField
fullWidth
@ -140,6 +166,7 @@ const PurchaseDetailSendPayment: React.FC = () => {
}
value={formData.totalDibayar}
onChange={handleChange('totalDibayar')}
disabled
sx={{
'& .MuiInputBase-root': {
textAlign: 'right'
@ -148,26 +175,6 @@ const PurchaseDetailSendPayment: React.FC = () => {
/>
</Box>
{/* Tgl. Transaksi */}
<Box sx={{ mb: 3 }}>
<CustomTextField
fullWidth
label={
<span>
<span style={{ color: 'red' }}>*</span> Tgl. Transaksi
</span>
}
type='date'
value={formData.tglTransaksi.split('/').reverse().join('-')}
onChange={handleChange('tglTransaksi')}
slotProps={{
input: {
endAdornment: <i className='tabler-calendar' style={{ color: '#666' }} />
}
}}
/>
</Box>
{/* Referensi */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
@ -185,6 +192,7 @@ const PurchaseDetailSendPayment: React.FC = () => {
placeholder='Referensi'
value={formData.referensi}
onChange={handleChange('referensi')}
disabled={loading || isSubmitting}
/>
</Box>
@ -192,6 +200,7 @@ const PurchaseDetailSendPayment: React.FC = () => {
<Accordion
expanded={expanded}
onChange={handleAccordionChange}
disabled={loading || isSubmitting}
sx={{
boxShadow: 'none',
border: '1px solid #e0e0e0',
@ -219,20 +228,24 @@ const PurchaseDetailSendPayment: React.FC = () => {
}}
>
<Typography variant='body2' sx={{ fontWeight: 'medium' }}>
Attachment
Attachment {uploadedFileIds.length > 0 && `(${uploadedFileIds.length})`}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 2 }}>
<Typography variant='body2' color='text.secondary'>
Drag and drop files here or click to upload
</Typography>
<ImageUpload
onUpload={handleUpload}
maxFileSize={1 * 1024 * 1024} // 1MB
currentImageUrl={imageUrl}
dragDropText='Drop your image here'
browseButtonText='Choose Image'
/>
</AccordionDetails>
</Accordion>
</Grid>
{/* Right Column */}
<Grid size={{ xs: 12, md: 6 }}>
{/* Nomor */}
{/* Nomor - DISABLED */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant='body2' sx={{ color: 'text.secondary' }}>
@ -246,140 +259,9 @@ const PurchaseDetailSendPayment: React.FC = () => {
</Box>
<CustomTextField fullWidth value={formData.nomor} onChange={handleChange('nomor')} disabled />
</Box>
{/* Dibayar Dari */}
<Box sx={{ mb: 3 }}>
<Typography variant='body2' sx={{ color: 'text.secondary', mb: 1 }}>
<span style={{ color: 'red' }}>*</span> Dibayar Dari
</Typography>
<CustomAutocomplete
fullWidth
options={dibayarDariOptions}
getOptionLabel={(option: { label: string; value: string }) => option.label || ''}
value={dibayarDariOptions.find(option => option.value === formData.dibayarDari) || null}
onChange={(_, value: { label: string; value: string } | null) => handleDibayarDariChange(value)}
renderInput={(params: any) => <CustomTextField {...params} placeholder='Pilih akun pembayaran' />}
noOptionsText='Tidak ada pilihan'
/>
</Box>
{/* Empty space to match Referensi height */}
<Box sx={{ mb: 3, height: '74px' }}>{/* Empty space */}</Box>
{/* Pemotongan Button - aligned with Attachment */}
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'flex-start',
minHeight: '48px'
}}
>
<Button
startIcon={<i className='tabler-plus' />}
variant='text'
color='primary'
sx={{ textTransform: 'none' }}
onClick={addPemotongan}
>
Pemotongan
</Button>
</Box>
</Grid>
</Grid>
{/* Pemotongan Items */}
{pemotonganItems.length > 0 && (
<Box sx={{ mt: 3 }}>
{pemotonganItems.map((item, index) => (
<Box
key={item.id}
sx={{
mb: 2,
p: 2,
border: '1px solid #e0e0e0',
borderRadius: 1,
position: 'relative'
}}
>
<Grid container spacing={2} alignItems='center'>
<Grid size={{ xs: 12, md: 1 }}>
<IconButton
color='error'
size='small'
onClick={() => removePemotongan(item.id)}
sx={{
backgroundColor: '#fff',
border: '1px solid #f44336',
'&:hover': { backgroundColor: '#ffebee' }
}}
>
<i className='tabler-minus' style={{ fontSize: '16px' }} />
</IconButton>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<CustomAutocomplete
fullWidth
options={pemotonganOptions}
getOptionLabel={(option: { label: string; value: string }) => option.label || ''}
value={pemotonganOptions.find(option => option.value === item.dipotong) || null}
onChange={(_, value: { label: string; value: string } | null) =>
updatePemotongan(item.id, 'dipotong', value?.value || '')
}
renderInput={(params: any) => <CustomTextField {...params} placeholder='Pilih dipotong...' />}
noOptionsText='Tidak ada pilihan'
/>
</Grid>
<Grid size={{ xs: 12, md: 2 }}>
<CustomTextField
fullWidth
value={item.persentase}
onChange={e => updatePemotongan(item.id, 'persentase', e.target.value)}
placeholder='0'
sx={{
'& .MuiInputBase-root': {
textAlign: 'center'
}
}}
/>
</Grid>
<Grid size={{ xs: 12, md: 1 }}>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Button
variant={item.tipe === 'persen' ? 'contained' : 'outlined'}
size='small'
onClick={() => updatePemotongan(item.id, 'tipe', 'persen')}
sx={{ minWidth: '40px', px: 1 }}
>
%
</Button>
<Button
variant={item.tipe === 'rupiah' ? 'contained' : 'outlined'}
size='small'
onClick={() => updatePemotongan(item.id, 'tipe', 'rupiah')}
sx={{ minWidth: '40px', px: 1 }}
>
Rp
</Button>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
<Typography variant='h6' sx={{ fontWeight: 'bold' }}>
{formatCurrency(calculatePemotongan(item))}
</Typography>
</Box>
</Grid>
</Grid>
</Box>
))}
</Box>
)}
{/* Bottom Section */}
<Box sx={{ mt: 4, pt: 3, borderTop: '1px solid #e0e0e0' }}>
<Grid container spacing={2} alignItems='center'>
@ -398,13 +280,15 @@ const PurchaseDetailSendPayment: React.FC = () => {
variant='contained'
startIcon={<i className='tabler-plus' />}
fullWidth
onClick={handleSubmit}
disabled={loading || isSubmitting}
sx={{
py: 1.5,
textTransform: 'none',
fontWeight: 'medium'
}}
>
Tambah Pembayaran
{isSubmitting ? 'Mengirim...' : 'Tambah Pembayaran'}
</Button>
</Grid>
</Grid>