From e57adc164613030b378dcd0a88098f762e8e12b7 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:11:33 +0700 Subject: [PATCH] feat: add PiutangManager component for handling receivables and bulk payments --- src/app/(app)/my-orders/page.tsx | 38 ++- src/app/(app)/my-purchases/page.tsx | 17 +- src/app/actions.ts | 122 +++++++--- src/components/PiutangManager.tsx | 366 ++++++++++++++++++++++++++++ 4 files changed, 503 insertions(+), 40 deletions(-) create mode 100644 src/components/PiutangManager.tsx diff --git a/src/app/(app)/my-orders/page.tsx b/src/app/(app)/my-orders/page.tsx index 8696d13..b68ca2a 100644 --- a/src/app/(app)/my-orders/page.tsx +++ b/src/app/(app)/my-orders/page.tsx @@ -53,6 +53,7 @@ import { ChevronLeft, ChevronRight, } from 'lucide-react' +import PiutangManager from '@/components/PiutangManager' export default function MyOrdersPage() { const [userId, setUserId] = useState(null) @@ -63,6 +64,7 @@ export default function MyOrdersPage() { const [orderToDelete, setOrderToDelete] = useState(null) const [orderToDuplicate, setOrderToDuplicate] = useState(null) const [deleting, setDeleting] = useState(false) + const [mainTab, setMainTab] = useState<'PO' | 'PIUTANG'>('PO') const [activeTab, setActiveTab] = useState<'ALL' | 'OPEN' | 'DRAFT' | 'CLOSE'>('ALL') const [currentPage, setCurrentPage] = useState(1) const [searchQuery, setSearchQuery] = useState('') @@ -154,8 +156,36 @@ export default function MyOrdersPage() { return (
- {/* Top Controls & Segmented Control */} -
+ {/* Main Tab Switcher */} +
+ + +
+ + {mainTab === 'PO' ? ( + <> + {/* Top Controls & Segmented Control */} +
{/* Segmented Control / Tabs */}
@@ -618,6 +648,10 @@ export default function MyOrdersPage() { )}
)} + + ) : ( + + )}
) } diff --git a/src/app/(app)/my-purchases/page.tsx b/src/app/(app)/my-purchases/page.tsx index 04310da..f063c3f 100644 --- a/src/app/(app)/my-purchases/page.tsx +++ b/src/app/(app)/my-purchases/page.tsx @@ -436,14 +436,15 @@ export default function MyPurchasesPage() {
) : ( -
- - - PO ditutup -
- (pesanan terkunci) -
-
+ //
+ // + // + // PO ditutup + //
+ // (pesanan terkunci) + //
+ //
+
)}
diff --git a/src/app/actions.ts b/src/app/actions.ts index 3c64348..5374e7a 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -490,6 +490,11 @@ export async function processBulkPayment(data: { user_id: string }) { try { + const me = await getSessionUser() + if (!me || me.id !== data.creator_id) { + return { success: false, error: 'Unauthorized' } + } + let currentBalance = 0 if (data.use_balance) { const balanceData = await getBalancesAsCreator(data.creator_id) @@ -499,7 +504,7 @@ export async function processBulkPayment(data: { const submissions = await prisma.submission.findMany({ where: { id: { in: data.submission_ids } }, - include: { order: { select: { date: true } } }, + include: { order: { select: { date: true, status: true } } }, }) // Sort by order date ascending (oldest first) @@ -515,11 +520,31 @@ export async function processBulkPayment(data: { const sub = submissions[i] const isLast = i === submissions.length - 1 + // Don't process if order isn't CLOSE + if (sub.order.status !== 'CLOSE') continue + const subBill = sub.bill || 0 const prevPaid = sub.paid_amount || 0 - const amountToCover = Math.max(0, subBill - prevPaid) + const prevSaldoUsed = sub.saldo_used || 0 + const amountToCover = Math.max(0, subBill - prevPaid - prevSaldoUsed) - if (totalAvailable >= amountToCover && amountToCover > 0) { + if (amountToCover === 0) { + // It's already fully paid somehow, just mark LUNAS. Give excess cash if last. + let finalPaid = prevPaid + if (isLast && remainingCash > 0) { + finalPaid += remainingCash + remainingCash = 0 + } + ops.push( + prisma.submission.update({ + where: { id: sub.id }, + data: { payment_status: 'LUNAS', paid_amount: finalPaid }, + }) + ) + continue + } + + if (totalAvailable >= amountToCover) { // Fully covered -> LUNAS let balanceToUse = Math.min(remainingBalance, amountToCover) remainingBalance -= balanceToUse @@ -533,44 +558,31 @@ export async function processBulkPayment(data: { finalPaid += remainingCash remainingCash = 0 } + let finalSaldoUsed = prevSaldoUsed + balanceToUse ops.push( prisma.submission.update({ where: { id: sub.id }, - data: { payment_status: 'LUNAS', paid_amount: finalPaid }, - }) - ) - } else if (totalAvailable >= amountToCover && amountToCover === 0) { - // It's already fully paid somehow, just mark LUNAS. Give excess cash if last. - let finalPaid = prevPaid - if (isLast && remainingCash > 0) { - finalPaid += remainingCash - remainingCash = 0 - } - ops.push( - prisma.submission.update({ - where: { id: sub.id }, - data: { payment_status: 'LUNAS', paid_amount: finalPaid }, + data: { payment_status: 'LUNAS', paid_amount: finalPaid, saldo_used: finalSaldoUsed }, }) ) } else if (totalAvailable > 0) { - // Partially covered -> BELUM_BAYAR. Only use cash. - let finalPaid = prevPaid + remainingCash - remainingCash = 0 - totalAvailable = remainingBalance // only balance left, which can't be used + // Partially covered -> BELUM_BAYAR + let balanceToUse = Math.min(remainingBalance, totalAvailable) + remainingBalance -= balanceToUse + + let cashToUse = totalAvailable - balanceToUse + remainingCash -= cashToUse + + let finalPaid = prevPaid + cashToUse + let finalSaldoUsed = prevSaldoUsed + balanceToUse + + totalAvailable = 0 ops.push( prisma.submission.update({ where: { id: sub.id }, - data: { payment_status: 'BELUM_BAYAR', paid_amount: finalPaid }, - }) - ) - } else { - // totalAvailable == 0. No more money. Just leave it as is, or update to BELUM_BAYAR. - ops.push( - prisma.submission.update({ - where: { id: sub.id }, - data: { payment_status: 'BELUM_BAYAR' }, + data: { payment_status: 'BELUM_BAYAR', paid_amount: finalPaid, saldo_used: finalSaldoUsed }, }) ) } @@ -873,3 +885,53 @@ export async function broadcastToMattermost(orderId: string, orderUrl: string) { return { success: false, error: 'Gagal mengirim ke Mattermost' } } } + +export async function getPiutangSummary(creator_id: string) { + const unpaidSubmissions = await prisma.submission.findMany({ + where: { + order: { + creator_id, + status: 'CLOSE', + }, + payment_status: 'BELUM_BAYAR', + }, + include: { + user: { + select: { id: true, name: true, photo: true }, + }, + order: { + select: { id: true, title: true, date: true }, + }, + }, + orderBy: { + order: { date: 'asc' }, + }, + }) + + const grouped = new Map() + + unpaidSubmissions.forEach((sub) => { + if (!grouped.has(sub.user_id)) { + grouped.set(sub.user_id, { + user: sub.user, + totalPiutang: 0, + submissions: [], + }) + } + + const u = grouped.get(sub.user_id) + + const paidAmount = (sub.paid_amount || 0) + (sub.saldo_used || 0) + const unpaid = Math.max(0, (sub.bill || 0) - paidAmount) + + if (unpaid > 0) { + u.totalPiutang += unpaid + u.submissions.push({ + ...sub, + unpaidAmount: unpaid + }) + } + }) + + return Array.from(grouped.values()).filter(g => g.totalPiutang > 0) +} diff --git a/src/components/PiutangManager.tsx b/src/components/PiutangManager.tsx new file mode 100644 index 0000000..7a75df2 --- /dev/null +++ b/src/components/PiutangManager.tsx @@ -0,0 +1,366 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { format } from 'date-fns' +import { getPiutangSummary, processBulkPayment, getBalancesAsCreator } from '@/app/actions' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { CheckCircle2, Package, Search, ChevronDown, ChevronUp, Users, Loader2 } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog' +import { cn } from '@/lib/utils' + +const formatRupiah = (value: number) => + new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + maximumFractionDigits: 0, + }).format(value) + +export default function PiutangManager({ creatorId }: { creatorId: string }) { + const [data, setData] = useState([]) + const [balancesData, setBalancesData] = useState([]) + const [loading, setLoading] = useState(true) + const [searchQuery, setSearchQuery] = useState('') + const [expandedRows, setExpandedRows] = useState>({}) + const [modalOpen, setModalOpen] = useState(false) + const [saving, setSaving] = useState(false) + + const [target, setTarget] = useState<{ + ids: string[] + label: string + amount: number + userId: string + }>({ ids: [], label: '', amount: 0, userId: '' }) + const [cashInput, setCashInput] = useState('') + + const loadData = async () => { + setLoading(true) + const [piutang, balances] = await Promise.all([ + getPiutangSummary(creatorId), + getBalancesAsCreator(creatorId), + ]) + setData(piutang) + setBalancesData(balances) + setLoading(false) + } + + useEffect(() => { + loadData() + }, [creatorId]) + + const toggleRow = (userId: string) => { + setExpandedRows((prev) => ({ ...prev, [userId]: !prev[userId] })) + } + + const openModal = ( + e: React.MouseEvent, + ids: string[], + label: string, + amount: number, + userId: string + ) => { + e.stopPropagation() + setTarget({ ids, label, amount, userId }) + setCashInput('') + setModalOpen(true) + } + + const handleConfirm = async () => { + if (!target.ids.length) return + setSaving(true) + 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) + loadData() + } else { + alert(res.error || 'Terjadi kesalahan') + } + } + + const filteredData = data.filter((u) => + u.user.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const targetUserBalance = balancesData.find((b) => b.user.id === target.userId)?.amount || 0 + + if (loading) { + return ( +
+ + Memuat data piutang... +
+ ) + } + + return ( +
+
+ + setSearchQuery(e.target.value)} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-slate-400 dark:text-white" + /> +
+ + {filteredData.length === 0 ? ( +
+

+ {searchQuery + ? 'Tidak ada penitip yang cocok dengan pencarian.' + : 'Tidak ada penitip yang memiliki hutang pada PO yang sudah CLOSE.'} +

+
+ ) : ( + +
+ + + + + + + + + + {filteredData.map((userObj) => { + const isExpanded = !!expandedRows[userObj.user.id] + return ( + + toggleRow(userObj.user.id)} + className="group cursor-pointer bg-white transition-colors hover:bg-slate-50/70 dark:bg-slate-900 dark:hover:bg-slate-800/50" + > + + + + + + {isExpanded && ( + + + + )} + + ) + })} + +
PenitipTotal PiutangAksi
+
+ {userObj.user.photo ? ( + {userObj.user.name} + ) : ( +
+ {userObj.user.name.charAt(0).toUpperCase()} +
+ )} +
+ + {userObj.user.name} + + + {userObj.submissions.length} Pesanan Belum Lunas + +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+
+
+ + {formatRupiah(userObj.totalPiutang)} + + + +
+
+

+ Rincian Piutang per PO +

+
+ {userObj.submissions.map((sub: any) => ( +
+
+ + {sub.order.title} + + + PO: {format(new Date(sub.order.date), 'dd MMM yyyy')} + +
+
+ + {formatRupiah(sub.unpaidAmount)} + +
+
+ ))} +
+
+
+
+
+ )} + + {/* Modal Konfirmasi Pelunasan */} + + +
+
+ +
+ + Pelunasan Tagihan + + + Terima uang dari {target.label.replace('Semua tagihan ', '')} + +
+ +
+
+ + Total Tagihan Saat Ini + +

+ {formatRupiah(target.amount)} +

+
+ +
+ +
+ Rp + { + const val = e.target.value.replace(/\D/g, '') + setCashInput(val ? new Intl.NumberFormat('id-ID').format(Number(val)) : '') + }} + placeholder="0" + className="h-14 w-full rounded-2xl border border-slate-200 bg-white pr-4 pl-11 text-xl font-black tracking-wide text-slate-900 shadow-sm transition-all focus:border-[#1B2CC1] focus:ring-4 focus:ring-[#1B2CC1]/10 focus:outline-none dark:border-slate-800 dark:bg-slate-900 dark:text-white" + /> +
+ + {targetUserBalance > 0 && ( +
+ + + Pakai Saldo Penitip + + + {formatRupiah(targetUserBalance)} + +
+ )} +
+ + {(() => { + const totalBayar = (Number(cashInput.replace(/\D/g, '')) || 0) + targetUserBalance + const kurang = target.amount - totalBayar + + if (totalBayar === 0) return null + + return ( +
+
+

+ Kalkulasi Akhir +

+
+
+ {kurang > 0 ? ( +
+ Sisa Hutang Nanti: + + {formatRupiah(kurang)} + +
+ ) : kurang < 0 ? ( +
+ + Kelebihan (Jadi Saldo): + + + {formatRupiah(Math.abs(kurang))} + +
+ ) : ( +
+ LUNAS SEMPURNA +
+ )} +
+
+ ) + })()} +
+ +
+ + +
+
+
+
+ ) +}