feat: implement flexible payment processing with partial cash payments and automatic balance deduction in reports

This commit is contained in:
Firman Ramdhani
2026-09-03 14:26:45 +07:00
parent 83a09a4c03
commit d32e7eda91
9 changed files with 693 additions and 128 deletions
+1
View File
@@ -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' },
]
+84 -13
View File
@@ -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>
+3 -1
View File
@@ -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' },
]