feat: implement flexible payment processing with partial cash payments and automatic balance deduction in reports
This commit is contained in:
@@ -10,6 +10,7 @@ const navItems = [
|
||||
{ name: 'Jasa Order Saya', href: '/my-orders' },
|
||||
{ name: 'Pesanan Saya', href: '/my-purchases' },
|
||||
{ name: 'Laporan', href: '/reports' },
|
||||
{ name: 'Buku Saldo', href: '/balances' },
|
||||
{ name: 'Profile', href: '/profile' },
|
||||
]
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ChevronDown, ChevronUp, Users, CheckCircle2, Package, InboxIcon, Chevro
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
|
||||
import { updateBulkSubmissionPayment } from '@/app/actions'
|
||||
import { processBulkPayment } from '@/app/actions'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
const ITEMS_PER_PAGE = 5
|
||||
@@ -14,13 +14,14 @@ const ITEMS_PER_PAGE = 5
|
||||
const formatRupiah = (value: number) =>
|
||||
new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(value)
|
||||
|
||||
export default function ReportByPersonGrid({ data, onUpdate }: { data: any[]; onUpdate: () => void }) {
|
||||
export default function ReportByPersonGrid({ data, balancesData = [], creatorId, onUpdate }: { data: any[]; balancesData?: any[]; creatorId: string; onUpdate: () => void }) {
|
||||
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({})
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [target, setTarget] = useState<{ ids: string[]; label: string; amount: number }>({ ids: [], label: '', amount: 0 })
|
||||
const [target, setTarget] = useState<{ ids: string[]; label: string; amount: number; userId: string }>({ ids: [], label: '', amount: 0, userId: '' })
|
||||
const [cashInput, setCashInput] = useState<string>('')
|
||||
|
||||
// Reset page + search when data changes (e.g. filter changed)
|
||||
useEffect(() => {
|
||||
@@ -75,16 +76,24 @@ export default function ReportByPersonGrid({ data, onUpdate }: { data: any[]; on
|
||||
const totalPages = Math.ceil(filteredUserList.length / ITEMS_PER_PAGE)
|
||||
const paginatedList = filteredUserList.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE)
|
||||
|
||||
const openModal = (e: React.MouseEvent, ids: string[], label: string, amount: number) => {
|
||||
const openModal = (e: React.MouseEvent, ids: string[], label: string, amount: number, userId: string) => {
|
||||
e.stopPropagation()
|
||||
setTarget({ ids, label, amount })
|
||||
setTarget({ ids, label, amount, userId })
|
||||
setCashInput('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!target.ids.length) return
|
||||
setSaving(true)
|
||||
const res = await updateBulkSubmissionPayment(target.ids, 'LUNAS')
|
||||
const cash = Number(cashInput.replace(/\D/g, '')) || 0
|
||||
const res = await processBulkPayment({
|
||||
submission_ids: target.ids,
|
||||
cash_amount: cash,
|
||||
use_balance: true,
|
||||
creator_id: creatorId,
|
||||
user_id: target.userId
|
||||
})
|
||||
setSaving(false)
|
||||
if (res.success) {
|
||||
setModalOpen(false)
|
||||
@@ -92,6 +101,9 @@ export default function ReportByPersonGrid({ data, onUpdate }: { data: any[]; on
|
||||
}
|
||||
}
|
||||
|
||||
const targetUserBalanceObj = balancesData.find(b => b.user.id === target.userId)
|
||||
const targetUserBalance = targetUserBalanceObj ? targetUserBalanceObj.amount : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden">
|
||||
@@ -182,7 +194,7 @@ export default function ReportByPersonGrid({ data, onUpdate }: { data: any[]; on
|
||||
<td className="px-6 py-4 text-center">
|
||||
{unpaid.length > 0 ? (
|
||||
<Button
|
||||
onClick={(e) => openModal(e, unpaid.map((s: any) => s.id), `Semua tagihan ${userObj.user.name}`, userObj.totalPiutang)}
|
||||
onClick={(e) => openModal(e, unpaid.map((s: any) => s.id), `Semua tagihan ${userObj.user.name}`, userObj.totalPiutang, userObj.user.id)}
|
||||
size="sm"
|
||||
className="h-8 text-[11px] font-bold bg-[#1B2CC1] text-white hover:bg-[#121E85] shadow-sm shadow-[#1B2CC1]/30 gap-1 px-3"
|
||||
>
|
||||
@@ -235,7 +247,7 @@ export default function ReportByPersonGrid({ data, onUpdate }: { data: any[]; on
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
{isUnpaid ? (
|
||||
<Button
|
||||
onClick={(e) => openModal(e, [sub.id], `PO: ${sub.orderTitle}`, Number(sub.bill) || 0)}
|
||||
onClick={(e) => openModal(e, [sub.id], `PO: ${sub.orderTitle}`, Number(sub.bill) || 0, sub.user.id)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 font-bold border-[#1B2CC1]/40 text-[#1B2CC1] hover:bg-[#1B2CC1] hover:text-white transition-colors"
|
||||
@@ -297,15 +309,74 @@ export default function ReportByPersonGrid({ data, onUpdate }: { data: any[]; on
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="bg-slate-50 dark:bg-slate-900/50 p-4 rounded-xl border border-slate-100 dark:border-slate-800 my-2">
|
||||
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Total Nominal</p>
|
||||
<p className="text-2xl font-black text-emerald-600 dark:text-emerald-400">{formatRupiah(target.amount)}</p>
|
||||
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Total Tagihan</p>
|
||||
<p className="text-2xl font-black text-rose-600 dark:text-rose-400">{formatRupiah(target.amount)}</p>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-slate-500">Nominal Dibayar (Cash/Transfer)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-bold text-slate-500">Rp</span>
|
||||
<input
|
||||
type="text"
|
||||
value={cashInput}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/\D/g, '')
|
||||
setCashInput(val ? new Intl.NumberFormat('id-ID').format(Number(val)) : '')
|
||||
}}
|
||||
placeholder="0"
|
||||
className="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg h-10 pl-9 pr-3 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-[#1B2CC1]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{targetUserBalance > 0 && (
|
||||
<div className="flex justify-between items-center p-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-emerald-50/50 dark:bg-emerald-900/20">
|
||||
<span className="text-sm font-bold text-slate-600 dark:text-slate-300">Dipotong dari Saldo (Otomatis)</span>
|
||||
<span className="text-sm font-black text-emerald-600 dark:text-emerald-400">{formatRupiah(targetUserBalance)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-slate-800 p-3 rounded-lg border border-slate-200 dark:border-slate-700 mt-1 flex justify-between items-center shadow-sm">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-slate-500">Total Pembayaran</span>
|
||||
{targetUserBalance > 0 && (
|
||||
<span className="text-[10px] font-medium text-slate-400 leading-none mt-0.5">(Nominal Dibayar + Saldo)</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-lg font-black text-[#1B2CC1] dark:text-blue-400">
|
||||
{formatRupiah((Number(cashInput.replace(/\D/g, '')) || 0) + targetUserBalance)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const totalBayar = (Number(cashInput.replace(/\D/g, '')) || 0) + targetUserBalance
|
||||
const kurang = target.amount - totalBayar
|
||||
if (kurang > 0) {
|
||||
return (
|
||||
<div className="flex justify-between items-center px-2 py-1">
|
||||
<span className="text-xs font-bold text-rose-500">Masih Kurang (Sisa Hutang)</span>
|
||||
<span className="text-xs font-black text-rose-500">{formatRupiah(kurang)}</span>
|
||||
</div>
|
||||
)
|
||||
} else if (target.amount > 0) {
|
||||
return (
|
||||
<div className="flex justify-between items-center px-2 py-1">
|
||||
<span className="text-xs font-bold text-emerald-500">Status</span>
|
||||
<span className="text-xs font-black text-emerald-500">Akan Lunas Sepenuhnya</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0 mt-2">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)} disabled={saving} className="rounded-xl font-bold">
|
||||
Batal
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={saving} className="rounded-xl font-bold bg-emerald-600 hover:bg-emerald-700 text-white shadow-md shadow-emerald-600/20">
|
||||
{saving ? 'Menyimpan...' : 'Ya, Simpan Pelunasan'}
|
||||
<Button onClick={handleConfirm} disabled={saving} className="rounded-xl font-bold bg-[#1B2CC1] hover:bg-[#121E85] text-white shadow-md shadow-[#1B2CC1]/20">
|
||||
{saving ? 'Memproses...' : 'Proses Pelunasan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -19,13 +19,15 @@ import {
|
||||
X,
|
||||
ArrowUpRight,
|
||||
Info,
|
||||
BarChart2
|
||||
BarChart2,
|
||||
Wallet
|
||||
} from 'lucide-react'
|
||||
|
||||
const menuItems = [
|
||||
{ name: 'Open Order', href: '/', icon: Store, desc: 'Daftar PO live hari ini' },
|
||||
{ name: 'Jasa Order Saya', href: '/my-orders', icon: ClipboardList, desc: 'Kelola PO buatan Anda' },
|
||||
{ name: 'Pesanan Saya', href: '/my-purchases', icon: ShoppingBag, desc: 'Riwayat titipan Anda' },
|
||||
{ name: 'Buku Saldo', href: '/balances', icon: Wallet, desc: 'Pantau riwayat saldo' },
|
||||
{ name: 'Laporan', href: '/reports', icon: BarChart2, desc: 'Ringkasan transaksi' },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user