feat: add saldo balance tracking and item notes support for orders and reports

This commit is contained in:
Firman Ramdhani
2026-09-16 13:13:40 +07:00
parent 44466f1566
commit d5791e759e
16 changed files with 6793 additions and 2538 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
dist
build
public
*.md
.env*
+11
View File
@@ -0,0 +1,11 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"jsxSingleQuote": false,
"bracketSpacing": true,
"arrowParens": "always",
"plugins": ["prettier-plugin-tailwindcss"]
}
+4574 -394
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -7,7 +7,8 @@
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"postinstall": "prisma skills sync || exit 0" "postinstall": "prisma skills sync || exit 0",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css}\""
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
@@ -45,6 +46,8 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.3", "eslint-config-next": "16.3.3",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.23.12", "tsx": "^4.23.12",
+2
View File
@@ -55,6 +55,7 @@ model Submission {
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
paid_amount Int? paid_amount Int?
saldo_used Int @default(0)
order Order @relation(fields: [order_id], references: [id], onDelete: Cascade) order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
items SubmissionItem[] items SubmissionItem[]
@@ -64,6 +65,7 @@ model SubmissionItem {
id String @id @default(uuid()) id String @id @default(uuid())
submission_id String submission_id String
name String name String
note String?
qty Int @default(1) qty Int @default(1)
is_custom Boolean @default(false) is_custom Boolean @default(false)
created_at DateTime @default(now()) created_at DateTime @default(now())
File diff suppressed because it is too large Load Diff
+140 -70
View File
@@ -286,7 +286,9 @@ export default function MyPurchasesPage() {
{sub.items.map((item: any) => ( {sub.items.map((item: any) => (
<li key={item.id} className="flex justify-between items-start font-medium gap-2"> <li key={item.id} className="flex justify-between items-start font-medium gap-2">
<span className="text-slate-700 dark:text-slate-300 mr-1 leading-snug"> <span className="text-slate-700 dark:text-slate-300 mr-1 leading-snug">
{item.name} {item.is_custom && <span className="text-[10px] font-bold text-[#1B2CC1] bg-blue-50 dark:bg-blue-950 px-1 py-0.5 rounded ml-1 whitespace-nowrap">Custom</span>} {item.name}
{item.note && <span className="text-[10px] text-slate-500 italic ml-1 break-words">({item.note})</span>}
{item.is_custom && <span className="text-[10px] font-bold text-[#1B2CC1] bg-blue-50 dark:bg-blue-950 px-1 py-0.5 rounded ml-1 whitespace-nowrap">Custom</span>}
</span> </span>
<span className="font-black text-slate-900 dark:text-white shrink-0 mt-0.5">{item.qty}x</span> <span className="font-black text-slate-900 dark:text-white shrink-0 mt-0.5">{item.qty}x</span>
</li> </li>
@@ -314,6 +316,36 @@ export default function MyPurchasesPage() {
<div className="text-lg font-black text-[#1B2CC1] dark:text-blue-400 leading-none mt-1"> <div className="text-lg font-black text-[#1B2CC1] dark:text-blue-400 leading-none mt-1">
{sub.bill ? formatRupiah(sub.bill) : <span className="text-slate-400 italic text-[13px] font-normal">Belum Dihitung</span>} {sub.bill ? formatRupiah(sub.bill) : <span className="text-slate-400 italic text-[13px] font-normal">Belum Dihitung</span>}
</div> </div>
{(() => {
const bill = sub.bill || 0;
const paid = (sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? bill : 0)) + (sub.saldo_used || 0);
const unpaid = Math.max(0, bill - paid);
if (bill > 0 && paid > 0) {
return (
<div className="flex flex-col gap-0.5 mt-1.5">
<div className="flex justify-between text-[10px] text-slate-400 font-bold">
<span>Dibayar: </span>
<span>{formatRupiah(paid)}</span>
</div>
{(sub.saldo_used || 0) > 0 && (
<div className="flex justify-between text-[10px] text-emerald-600 font-bold">
<span>Dari saldo: </span>
<span>{formatRupiah(sub.saldo_used)}</span>
</div>
)}
{unpaid > 0 && (
<div className="flex justify-between text-[10px] text-rose-600 font-bold">
<span>Kurang Bayar:</span>
<span>{formatRupiah(unpaid)}</span>
</div>
)}
</div>
)
}
return null;
})()}
</div> </div>
{/* Update Order Action Button */} {/* Update Order Action Button */}
@@ -413,8 +445,8 @@ function EditPurchaseModal({
onClose: () => void onClose: () => void
onSuccess: () => void onSuccess: () => void
}) { }) {
const [items, setItems] = useState<Record<string, { selected: boolean, qty: number }>>({}) const [items, setItems] = useState<Record<string, { selected: boolean, qty: number, note?: string }>>({})
const [customItems, setCustomItems] = useState<Array<{ id: string, name: string, qty: number }>>([]) const [customItems, setCustomItems] = useState<Array<{ id: string, name: string, qty: number, note?: string }>>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -426,11 +458,11 @@ function EditPurchaseModal({
const loadExisting = async () => { const loadExisting = async () => {
setLoading(true) setLoading(true)
const sub = await getUserSubmission(order.id, userId) const sub = await getUserSubmission(order.id, userId)
const initialItems: Record<string, { selected: boolean, qty: number }> = {} const initialItems: Record<string, { selected: boolean, qty: number, note?: string }> = {}
const initialCustoms: any[] = [] const initialCustoms: any[] = []
order.available_items?.forEach((ai: any) => { order.available_items?.forEach((ai: any) => {
initialItems[ai.id] = { selected: false, qty: 0 } initialItems[ai.id] = { selected: false, qty: 0, note: '' }
}) })
if (sub) { if (sub) {
@@ -438,10 +470,10 @@ function EditPurchaseModal({
if (!item.is_custom) { if (!item.is_custom) {
const stdItem = order.available_items?.find((ai: any) => ai.name === item.name) const stdItem = order.available_items?.find((ai: any) => ai.name === item.name)
if (stdItem) { if (stdItem) {
initialItems[stdItem.id] = { selected: true, qty: item.qty } initialItems[stdItem.id] = { selected: true, qty: item.qty, note: item.note || '' }
} }
} else { } else {
initialCustoms.push({ id: Math.random().toString(), name: item.name, qty: item.qty }) initialCustoms.push({ id: Math.random().toString(), name: item.name, qty: item.qty, note: item.note || '' })
} }
}) })
} }
@@ -454,7 +486,7 @@ function EditPurchaseModal({
const handleToggle = (itemId: string, checked: boolean) => { const handleToggle = (itemId: string, checked: boolean) => {
setItems(prev => ({ setItems(prev => ({
...prev, ...prev,
[itemId]: { selected: checked, qty: checked ? 1 : 0 } [itemId]: { selected: checked, qty: checked ? 1 : 0, note: prev[itemId]?.note || '' }
})) }))
} }
@@ -465,15 +497,22 @@ function EditPurchaseModal({
} }
setItems(prev => ({ setItems(prev => ({
...prev, ...prev,
[itemId]: { selected: true, qty } [itemId]: { ...prev[itemId], qty }
}))
}
const handleNote = (itemId: string, note: string) => {
setItems(prev => ({
...prev,
[itemId]: { ...prev[itemId], note }
})) }))
} }
const addCustomItem = () => { const addCustomItem = () => {
setCustomItems([...customItems, { id: Math.random().toString(), name: '', qty: 1 }]) setCustomItems([...customItems, { id: Math.random().toString(), name: '', qty: 1, note: '' }])
} }
const updateCustomItem = (id: string, field: 'name' | 'qty', value: any) => { const updateCustomItem = (id: string, field: 'name' | 'qty' | 'note', value: any) => {
setCustomItems(customItems.map(c => c.id === id ? { ...c, [field]: value } : c)) setCustomItems(customItems.map(c => c.id === id ? { ...c, [field]: value } : c))
} }
@@ -491,13 +530,13 @@ function EditPurchaseModal({
order.available_items?.forEach((ai: any) => { order.available_items?.forEach((ai: any) => {
const state = items[ai.id] const state = items[ai.id]
if (state?.selected && state.qty > 0) { if (state?.selected && state.qty > 0) {
payloadItems.push({ name: ai.name, qty: state.qty, is_custom: false }) payloadItems.push({ name: ai.name, qty: state.qty, is_custom: false, note: state.note?.trim() || undefined })
} }
}) })
customItems.forEach(ci => { customItems.forEach(ci => {
if (ci.name.trim() && ci.qty > 0) { if (ci.name.trim() && ci.qty > 0) {
payloadItems.push({ name: ci.name.trim(), qty: ci.qty, is_custom: true }) payloadItems.push({ name: ci.name.trim(), qty: ci.qty, is_custom: true, note: ci.note?.trim() || undefined })
} }
}) })
@@ -556,44 +595,60 @@ function EditPurchaseModal({
<div <div
key={item.id} key={item.id}
className={cn( className={cn(
"flex items-center justify-between p-3.5 rounded-xl border transition-all", "flex flex-col gap-2 p-3.5 rounded-xl border transition-all",
isSelected isSelected
? "border-[#1B2CC1] bg-[#1B2CC1]/5 dark:bg-blue-950/20 shadow-sm" ? "border-[#1B2CC1]/40 bg-blue-50/20 dark:bg-blue-950/10 shadow-sm"
: "border-slate-200/80 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50" : "border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40"
)} )}
> >
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center justify-between">
<Checkbox <div className="flex items-center gap-3 flex-1 min-w-0">
id={`purchase-item-${item.id}`} <Checkbox
checked={isSelected} id={`purchase-item-${item.id}`}
onCheckedChange={(c) => handleToggle(item.id, c as boolean)} checked={isSelected}
className="rounded-lg data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1]" disabled={saving}
/> onCheckedChange={(c) => handleToggle(item.id, c as boolean)}
<Label htmlFor={`purchase-item-${item.id}`} className="text-sm font-bold text-slate-800 dark:text-slate-200 cursor-pointer truncate"> className="rounded-lg data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1]"
{item.name} />
</Label> <Label htmlFor={`purchase-item-${item.id}`} className="text-sm font-bold text-slate-800 dark:text-slate-200 cursor-pointer truncate">
{item.name}
</Label>
</div>
{isSelected && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-0.5 shadow-sm shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
disabled={saving}
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleQty(item.id, qty - 1)}
>
<MinusCircle className="h-4 w-4" />
</Button>
<span className="w-7 text-center text-xs font-extrabold text-[#1B2CC1] dark:text-blue-400">{qty}</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={saving}
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleQty(item.id, qty + 1)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</div>
)}
</div> </div>
{isSelected && ( {isSelected && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-0.5 shadow-sm"> <div className="pl-7 mt-1">
<Button <Input
type="button" placeholder="Catatan (opsional), misal: 1/2 porsi"
variant="ghost" className="h-8 text-xs bg-slate-50/50 dark:bg-slate-900/50 border-slate-200/60 dark:border-slate-700/60"
size="icon" value={items[item.id]?.note || ''}
className="h-7 w-7 text-slate-500 hover:text-slate-800" disabled={saving}
onClick={() => handleQty(item.id, qty - 1)} onChange={(e) => handleNote(item.id, e.target.value)}
> />
<MinusCircle className="h-4 w-4" />
</Button>
<span className="w-7 text-center text-xs font-extrabold text-[#1B2CC1] dark:text-blue-400">{qty}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleQty(item.id, qty + 1)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</div> </div>
)} )}
</div> </div>
@@ -618,6 +673,7 @@ function EditPurchaseModal({
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
disabled={saving}
onClick={addCustomItem} onClick={addCustomItem}
className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/40 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5" className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/40 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5"
> >
@@ -628,30 +684,44 @@ function EditPurchaseModal({
{customItems.length > 0 ? ( {customItems.length > 0 ? (
<div className="space-y-2.5"> <div className="space-y-2.5">
{customItems.map((ci, idx) => ( {customItems.map((ci, idx) => (
<div key={ci.id} className="flex gap-2 items-center p-3 rounded-xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40"> <div key={ci.id} className="flex flex-col gap-2 bg-slate-50 dark:bg-slate-800/40 p-3 rounded-xl border border-slate-200/70 dark:border-slate-700">
<span className="text-xs font-bold text-slate-400 w-4">{idx + 1}.</span> <div className="flex gap-2 items-center">
<Input <span className="text-xs font-bold text-slate-400 w-4">{idx + 1}.</span>
placeholder="Nama Menu / Catatan Khusus" <Input
value={ci.name} placeholder="Nama Menu Kustom"
onChange={(e) => updateCustomItem(ci.id, 'name', e.target.value)} value={ci.name}
className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium" disabled={saving}
/> onChange={(e) => updateCustomItem(ci.id, 'name', e.target.value)}
<Input className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium"
type="number" />
min="1" <Input
value={ci.qty} type="number"
onChange={(e) => updateCustomItem(ci.id, 'qty', parseInt(e.target.value) || 1)} min="1"
className="w-16 h-9 rounded-lg bg-white dark:bg-slate-900 text-center font-bold text-xs" value={ci.qty}
/> disabled={saving}
<Button onChange={(e) => updateCustomItem(ci.id, 'qty', parseInt(e.target.value) || 1)}
type="button" className="w-16 h-9 rounded-lg bg-white dark:bg-slate-900 text-center font-bold text-xs"
variant="ghost" />
size="icon" <Button
onClick={() => removeCustomItem(ci.id)} type="button"
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg" variant="ghost"
> size="icon"
<MinusCircle className="h-4 w-4" /> disabled={saving}
</Button> onClick={() => removeCustomItem(ci.id)}
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg shrink-0"
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
<div className="pl-6">
<Input
placeholder="Catatan (opsional), misal: 1/2 porsi"
value={ci.note || ''}
disabled={saving}
onChange={(e) => updateCustomItem(ci.id, 'note', e.target.value)}
className="h-8 text-xs bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700"
/>
</div>
</div> </div>
))} ))}
</div> </div>
+1 -1
View File
@@ -143,7 +143,7 @@ export default async function PublicOrderDetailPage({ params }: { params: Promis
: 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700' : 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700'
}`} }`}
> >
{item.qty}x {item.name} {item.qty}x {item.name} {item.note && <span className="italic ml-0.5 opacity-70">({item.note})</span>}
</span> </span>
))} ))}
</div> </div>
+6 -3
View File
@@ -100,15 +100,18 @@ export default function ReportsPage() {
new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(n) new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(n)
// ── Summary calcs ────────────────────────────────────── // ── Summary calcs ──────────────────────────────────────
const totalPengeluaran = submittorData.reduce((acc, s) => acc + (s.payment_status === 'LUNAS' ? Number(s.bill) || 0 : 0), 0) const getPaidAmount = (sub: any) => (sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? (Number(sub.bill) || 0) : 0)) + (sub.saldo_used || 0)
const totalHutang = submittorData.reduce((acc, s) => acc + (s.payment_status !== 'LUNAS' ? Number(s.bill) || 0 : 0), 0) const getUnpaidAmount = (sub: any) => Math.max(0, (Number(sub.bill) || 0) - getPaidAmount(sub))
const totalPengeluaran = submittorData.reduce((acc, s) => acc + getPaidAmount(s), 0)
const totalHutang = submittorData.reduce((acc, s) => acc + getUnpaidAmount(s), 0)
let totalOmzet = 0 let totalOmzet = 0
let totalPiutang = 0 let totalPiutang = 0
creatorData.forEach(order => { creatorData.forEach(order => {
order.submissions.forEach((sub: any) => { order.submissions.forEach((sub: any) => {
totalOmzet += Number(sub.bill) || 0 totalOmzet += Number(sub.bill) || 0
if (sub.payment_status !== 'LUNAS') totalPiutang += Number(sub.bill) || 0 totalPiutang += getUnpaidAmount(sub)
}) })
}) })
+58 -21
View File
@@ -307,6 +307,15 @@ export async function updateOrder(order_id: string, data: {
return { success: false, error: 'Order dengan status CLOSE tidak dapat diedit.' } return { success: false, error: 'Order dengan status CLOSE tidak dapat diedit.' }
} }
// Check if order date is today
const orderDate = new Date(order.date)
const today = new Date()
if (orderDate.getDate() !== today.getDate() ||
orderDate.getMonth() !== today.getMonth() ||
orderDate.getFullYear() !== today.getFullYear()) {
return { success: false, error: 'Hanya pesanan hari ini yang dapat diedit.' }
}
// Delete existing available items and insert updated ones // Delete existing available items and insert updated ones
await prisma.availableItem.deleteMany({ await prisma.availableItem.deleteMany({
where: { order_id } where: { order_id }
@@ -387,11 +396,11 @@ export async function getOrderDetail(order_id: string) {
}) })
} }
export async function updateSubmissionPayment(submission_id: string, bill: number | null, payment_status: string, paid_amount: number | null = null) { export async function updateSubmissionPayment(submission_id: string, bill: number | null, payment_status: string, paid_amount: number | null = null, saldo_used: number = 0) {
try { try {
await prisma.submission.update({ await prisma.submission.update({
where: { id: submission_id }, where: { id: submission_id },
data: { bill, payment_status, paid_amount } data: { bill, payment_status, paid_amount, saldo_used }
}) })
revalidatePath(`/my-orders`) revalidatePath(`/my-orders`)
revalidatePath(`/my-purchases`) revalidatePath(`/my-purchases`)
@@ -411,7 +420,8 @@ export async function updateBulkSubmissionPayment(submission_ids: string[], paym
where: { id: sub.id }, where: { id: sub.id },
data: { data: {
payment_status, payment_status,
paid_amount: use_balance ? 0 : (sub.paid_amount != null ? sub.paid_amount : sub.bill) paid_amount: use_balance ? (sub.paid_amount || 0) : (sub.paid_amount != null ? sub.paid_amount : sub.bill),
saldo_used: use_balance ? Math.max(0, (sub.bill || 0) - (sub.paid_amount || 0)) : 0
} }
})) }))
await prisma.$transaction(ops) await prisma.$transaction(ops)
@@ -561,13 +571,24 @@ export async function getMyPurchases(user_id: string) {
export async function submitOrder(data: { export async function submitOrder(data: {
order_id: string; order_id: string;
user_id: string; user_id: string;
items: Array<{ name: string; qty: number; is_custom: boolean }>; items: Array<{ name: string; qty: number; is_custom: boolean; note?: string }>;
}) { }) {
try { try {
if (data.items.length === 0) { if (data.items.length === 0) {
return { success: false, error: 'Pilih minimal 1 item.' } return { success: false, error: 'Pilih minimal 1 item.' }
} }
const order = await prisma.order.findUnique({ where: { id: data.order_id } })
if (!order) return { success: false, error: 'Pesanan tidak ditemukan.' }
if (order.status !== 'OPEN') return { success: false, error: 'Pesanan sudah ditutup atau masih draft.' }
const itemsToCreate = data.items.map(item => ({
name: item.name,
qty: item.qty,
is_custom: item.is_custom,
note: item.note || null
}))
const existing = await prisma.submission.findFirst({ const existing = await prisma.submission.findFirst({
where: { order_id: data.order_id, user_id: data.user_id } where: { order_id: data.order_id, user_id: data.user_id }
}) })
@@ -578,7 +599,7 @@ export async function submitOrder(data: {
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
items: { items: {
create: data.items create: itemsToCreate
} }
} }
}) })
@@ -588,7 +609,7 @@ export async function submitOrder(data: {
order_id: data.order_id, order_id: data.order_id,
user_id: data.user_id, user_id: data.user_id,
items: { items: {
create: data.items create: itemsToCreate
} }
} }
}) })
@@ -650,8 +671,7 @@ export async function getCreatorReport(creator_id: string, startDate?: Date, end
export async function getBalancesAsCreator(creator_id: string) { export async function getBalancesAsCreator(creator_id: string) {
const submissions = await prisma.submission.findMany({ const submissions = await prisma.submission.findMany({
where: { where: {
order: { creator_id }, order: { creator_id }
payment_status: 'LUNAS'
}, },
include: { include: {
user: { select: { id: true, name: true, photo: true } } user: { select: { id: true, name: true, photo: true } }
@@ -661,12 +681,21 @@ export async function getBalancesAsCreator(creator_id: string) {
const balanceMap = new Map<string, { user: any, amount: number }>() const balanceMap = new Map<string, { user: any, amount: number }>()
submissions.forEach(sub => { submissions.forEach(sub => {
if (sub.paid_amount == null || sub.bill == null) return let diff = 0;
const diff = sub.paid_amount - sub.bill if (sub.payment_status === 'LUNAS') {
if (!balanceMap.has(sub.user.id)) { if (sub.bill != null) {
balanceMap.set(sub.user.id, { user: sub.user, amount: diff }) diff = (sub.paid_amount || 0) - sub.bill;
}
} else { } else {
balanceMap.get(sub.user.id)!.amount += diff diff = -(sub.saldo_used || 0);
}
if (diff !== 0) {
if (!balanceMap.has(sub.user.id)) {
balanceMap.set(sub.user.id, { user: sub.user, amount: diff })
} else {
balanceMap.get(sub.user.id)!.amount += diff
}
} }
}) })
@@ -676,8 +705,7 @@ export async function getBalancesAsCreator(creator_id: string) {
export async function getBalancesAsSubmittor(user_id: string) { export async function getBalancesAsSubmittor(user_id: string) {
const submissions = await prisma.submission.findMany({ const submissions = await prisma.submission.findMany({
where: { where: {
user_id, user_id
payment_status: 'LUNAS'
}, },
include: { include: {
order: { order: {
@@ -691,13 +719,22 @@ export async function getBalancesAsSubmittor(user_id: string) {
const balanceMap = new Map<string, { creator: any, amount: number }>() const balanceMap = new Map<string, { creator: any, amount: number }>()
submissions.forEach(sub => { submissions.forEach(sub => {
if (sub.paid_amount == null || sub.bill == null) return let diff = 0;
const diff = sub.paid_amount - sub.bill if (sub.payment_status === 'LUNAS') {
const creator = sub.order.creator if (sub.bill != null) {
if (!balanceMap.has(creator.id)) { diff = (sub.paid_amount || 0) - sub.bill;
balanceMap.set(creator.id, { creator: creator, amount: diff }) }
} else { } else {
balanceMap.get(creator.id)!.amount += diff diff = -(sub.saldo_used || 0);
}
if (diff !== 0) {
const creator = sub.order.creator
if (!balanceMap.has(creator.id)) {
balanceMap.set(creator.id, { creator: creator, amount: diff })
} else {
balanceMap.get(creator.id)!.amount += diff
}
} }
}) })
+109 -60
View File
@@ -13,8 +13,8 @@ import { PlusCircle, MinusCircle, CheckCircle2 } from 'lucide-react'
export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: () => void }) { export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: () => void }) {
const [liveOrder, setLiveOrder] = useState<any>(order) const [liveOrder, setLiveOrder] = useState<any>(order)
const [userId, setUserId] = useState<string>('') const [userId, setUserId] = useState<string>('')
const [items, setItems] = useState<Record<string, { selected: boolean, qty: number }>>({}) const [items, setItems] = useState<Record<string, { selected: boolean, qty: number, note?: string }>>({})
const [customItems, setCustomItems] = useState<Array<{ id: string, name: string, qty: number }>>([]) const [customItems, setCustomItems] = useState<Array<{ id: string, name: string, qty: number, note?: string }>>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -42,10 +42,15 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
if (!item.is_custom) { if (!item.is_custom) {
const stdItem = currentOrder.available_items.find((ai: any) => ai.name === item.name) const stdItem = currentOrder.available_items.find((ai: any) => ai.name === item.name)
if (stdItem) { if (stdItem) {
freshItems[stdItem.id] = { selected: true, qty: item.qty } freshItems[stdItem.id] = { selected: true, qty: item.qty, note: item.note || '' }
} }
} else { } else {
newCustoms.push({ id: Math.random().toString(), name: item.name, qty: item.qty }) newCustoms.push({
id: Math.random().toString(36).substr(2, 9),
name: item.name,
qty: item.qty,
note: item.note || ''
})
} }
}) })
setItems(freshItems) setItems(freshItems)
@@ -53,30 +58,47 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
} }
} }
const handleStandardItemToggle = (itemId: string, checked: boolean) => { const handleStandardItemToggle = (id: string, checked: boolean) => {
setItems(prev => ({ setItems(prev => ({
...prev, ...prev,
[itemId]: { selected: checked, qty: checked ? 1 : 0 } [id]: { selected: checked, qty: checked ? 1 : 0, note: prev[id]?.note || '' }
})) }))
} }
const handleStandardItemQty = (itemId: string, qty: number) => { const handleStandardItemQty = (id: string, qty: number) => {
if (qty < 1) { if (qty < 1) {
handleStandardItemToggle(itemId, false) handleStandardItemToggle(id, false)
return return
} }
setItems(prev => ({ setItems(prev => ({
...prev, ...prev,
[itemId]: { selected: true, qty } [id]: { ...prev[id], qty }
}))
}
const handleStandardItemNote = (id: string, note: string) => {
setItems(prev => ({
...prev,
[id]: { ...prev[id], note }
})) }))
} }
const addCustomItem = () => { const addCustomItem = () => {
setCustomItems([...customItems, { id: Math.random().toString(), name: '', qty: 1 }]) setCustomItems(prev => [...prev, { id: Math.random().toString(36).substr(2, 9), name: '', qty: 1, note: '' }])
} }
const updateCustomItem = (id: string, field: 'name' | 'qty', value: any) => { const handleCustomItemChange = (id: string, field: 'name' | 'qty' | 'note', value: string | number) => {
setCustomItems(customItems.map(c => c.id === id ? { ...c, [field]: value } : c)) setCustomItems(prev => prev.map(item => {
if (item.id === id) {
return { ...item, [field]: value }
}
return item
}))
}
const handleCustomItemQty = (id: string, qty: number) => {
if (qty < 1) return
handleCustomItemChange(id, 'qty', qty)
} }
const removeCustomItem = (id: string) => { const removeCustomItem = (id: string) => {
@@ -91,15 +113,25 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
const payloadItems: any[] = [] const payloadItems: any[] = []
liveOrder.available_items.forEach((ai: any) => { liveOrder.available_items.forEach((ai: any) => {
const state = items[ai.id] const selectedItem = items[ai.id]
if (state?.selected && state.qty > 0) { if (selectedItem?.selected && selectedItem.qty > 0) {
payloadItems.push({ name: ai.name, qty: state.qty, is_custom: false }) payloadItems.push({
name: ai.name,
qty: selectedItem.qty,
is_custom: false,
note: selectedItem.note?.trim() || undefined
})
} }
}) })
customItems.forEach(ci => { customItems.forEach(ci => {
if (ci.name.trim() && ci.qty > 0) { if (ci.name.trim() && ci.qty > 0) {
payloadItems.push({ name: ci.name.trim(), qty: ci.qty, is_custom: true }) payloadItems.push({
name: ci.name.trim(),
qty: ci.qty,
is_custom: true,
note: ci.note?.trim() || undefined
})
} }
}) })
@@ -149,50 +181,63 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
<div <div
key={item.id} key={item.id}
className={cn( className={cn(
"flex items-center justify-between p-3.5 rounded-xl border transition-all", "flex flex-col gap-2 p-3.5 rounded-xl border transition-all",
isSelected isSelected ? "border-[#1B2CC1]/40 bg-blue-50/20 dark:bg-blue-950/10 shadow-sm" : "border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40"
? "border-[#1B2CC1] bg-[#1B2CC1]/5 dark:bg-blue-950/20 shadow-sm"
: "border-slate-200/80 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50"
)} )}
> >
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center justify-between">
<Checkbox <div className="flex items-center gap-3 flex-1 min-w-0">
id={`item-${item.id}`} <Checkbox
checked={isSelected && !item.is_sold_out} id={`item-${item.id}`}
disabled={item.is_sold_out} checked={isSelected && !item.is_sold_out}
onCheckedChange={(c) => !item.is_sold_out && handleStandardItemToggle(item.id, c as boolean)} disabled={item.is_sold_out || loading}
className="rounded-lg data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1] disabled:opacity-50" onCheckedChange={(c) => !item.is_sold_out && handleStandardItemToggle(item.id, c as boolean)}
/> className="rounded-lg data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1] disabled:opacity-50"
<Label htmlFor={`item-${item.id}`} className={cn("text-sm font-bold truncate flex items-center gap-2", item.is_sold_out ? "text-slate-400 dark:text-slate-500 cursor-not-allowed" : "text-slate-800 dark:text-slate-200 cursor-pointer")}> />
<span className={item.is_sold_out ? "line-through" : ""}>{item.name}</span> <Label htmlFor={`item-${item.id}`} className={cn("text-sm font-bold truncate flex items-center gap-2", item.is_sold_out ? "text-slate-400 dark:text-slate-500 cursor-not-allowed" : "text-slate-800 dark:text-slate-200 cursor-pointer")}>
{item.is_sold_out && ( <span className={item.is_sold_out ? "line-through" : ""}>{item.name}</span>
<span className="text-[9px] bg-rose-100 dark:bg-rose-950/30 text-rose-600 dark:text-rose-400 px-1.5 py-0.5 rounded font-black uppercase tracking-wider border border-rose-200 dark:border-rose-900/50"> {item.is_sold_out && (
Sold Out <span className="text-[9px] bg-rose-100 dark:bg-rose-950/30 text-rose-600 dark:text-rose-400 px-1.5 py-0.5 rounded font-black uppercase tracking-wider border border-rose-200 dark:border-rose-900/50">
</span> Sold Out
)} </span>
</Label> )}
</Label>
</div>
{isSelected && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-0.5 shadow-sm shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
disabled={loading}
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty - 1)}
>
<MinusCircle className="h-4 w-4" />
</Button>
<span className="w-7 text-center text-xs font-extrabold text-[#1B2CC1] dark:text-blue-400">{qty}</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={loading}
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty + 1)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</div>
)}
</div> </div>
{isSelected && ( {isSelected && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-0.5 shadow-sm"> <div className="pl-7 mt-1">
<Button <Input
type="button" placeholder="Catatan (opsional), misal: 1/2 porsi"
variant="ghost" className="h-8 text-xs bg-slate-50/50 dark:bg-slate-900/50 border-slate-200/60 dark:border-slate-700/60"
size="icon" value={items[item.id]?.note || ''}
className="h-7 w-7 text-slate-500 hover:text-slate-800" disabled={loading}
onClick={() => handleStandardItemQty(item.id, qty - 1)} onChange={(e) => handleStandardItemNote(item.id, e.target.value)}
> />
<MinusCircle className="h-4 w-4" />
</Button>
<span className="w-7 text-center text-xs font-extrabold text-[#1B2CC1] dark:text-blue-400">{qty}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty + 1)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</div> </div>
)} )}
</div> </div>
@@ -217,6 +262,7 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
disabled={loading}
onClick={addCustomItem} onClick={addCustomItem}
className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/40 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5" className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/40 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5"
> >
@@ -230,24 +276,27 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
<div key={ci.id} className="flex gap-2 items-center p-3 rounded-xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40"> <div key={ci.id} className="flex gap-2 items-center p-3 rounded-xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40">
<span className="text-xs font-bold text-slate-400 w-4">{idx + 1}.</span> <span className="text-xs font-bold text-slate-400 w-4">{idx + 1}.</span>
<Input <Input
placeholder="Nama Menu / Catatan Khusus" placeholder="Nama Menu Kustom"
value={ci.name} value={ci.name}
onChange={(e) => updateCustomItem(ci.id, 'name', e.target.value)} disabled={loading}
onChange={(e) => handleCustomItemChange(ci.id, 'name', e.target.value)}
className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium" className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium"
/> />
<Input <Input
type="number" type="number"
min="1" min="1"
value={ci.qty} value={ci.qty}
onChange={(e) => updateCustomItem(ci.id, 'qty', parseInt(e.target.value) || 1)} disabled={loading}
onChange={(e) => handleCustomItemChange(ci.id, 'qty', parseInt(e.target.value) || 1)}
className="w-16 h-9 rounded-lg bg-white dark:bg-slate-900 text-center font-bold text-xs" className="w-16 h-9 rounded-lg bg-white dark:bg-slate-900 text-center font-bold text-xs"
/> />
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
disabled={loading}
onClick={() => removeCustomItem(ci.id)} onClick={() => removeCustomItem(ci.id)}
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg" className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg shrink-0"
> >
<MinusCircle className="h-4 w-4" /> <MinusCircle className="h-4 w-4" />
</Button> </Button>
+16 -8
View File
@@ -51,10 +51,12 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
}) })
} }
const obj = usersMap.get(sub.user.id) const obj = usersMap.get(sub.user.id)
const amount = Number(sub.bill) || 0 const bill = Number(sub.bill) || 0
if (sub.payment_status !== 'LUNAS') obj.totalPiutang += amount const paid = (sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? bill : 0)) + (sub.saldo_used || 0)
else obj.totalPaid += amount const unpaid = Math.max(0, bill - paid)
obj.submissions.push({ ...sub, orderTitle: order.title, orderDate: order.date }) obj.totalPiutang += unpaid
obj.totalPaid += paid
obj.submissions.push({ ...sub, orderTitle: order.title, orderDate: order.date, paid, unpaid })
}) })
}) })
@@ -150,7 +152,7 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
<tbody className="divide-y divide-slate-100 dark:divide-slate-800"> <tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{paginatedList.map((userObj) => { {paginatedList.map((userObj) => {
const isExpanded = !!expandedRows[userObj.user.id] const isExpanded = !!expandedRows[userObj.user.id]
const unpaid = userObj.submissions.filter((s: any) => s.payment_status !== 'LUNAS') const unpaid = userObj.submissions.filter((s: any) => s.unpaid > 0)
return ( return (
<React.Fragment key={userObj.user.id}> <React.Fragment key={userObj.user.id}>
@@ -224,7 +226,7 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
</thead> </thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700/50"> <tbody className="divide-y divide-slate-100 dark:divide-slate-700/50">
{userObj.submissions.map((sub: any) => { {userObj.submissions.map((sub: any) => {
const isUnpaid = sub.payment_status !== 'LUNAS' const isUnpaid = sub.unpaid > 0
return ( return (
<tr key={sub.id}> <tr key={sub.id}>
<td className="px-4 py-2.5 font-bold text-slate-800 dark:text-slate-200">{sub.orderTitle}</td> <td className="px-4 py-2.5 font-bold text-slate-800 dark:text-slate-200">{sub.orderTitle}</td>
@@ -241,8 +243,14 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
{isUnpaid ? 'Belum' : 'Lunas'} {isUnpaid ? 'Belum' : 'Lunas'}
</span> </span>
</td> </td>
<td className={cn('px-4 py-2.5 text-right font-black', isUnpaid ? 'text-rose-600 dark:text-rose-400' : 'text-emerald-600 dark:text-emerald-400')}> <td className={cn('px-4 py-2.5 text-right flex flex-col', isUnpaid ? 'text-rose-600 dark:text-rose-400' : 'text-emerald-600 dark:text-emerald-400')}>
{sub.bill ? formatRupiah(sub.bill) : 'Rp0'} <span className="font-black">{sub.bill ? formatRupiah(sub.bill) : 'Rp0'}</span>
{sub.unpaid > 0 && sub.paid > 0 && (
<div className="text-[10px] text-slate-500 mt-0.5 font-medium leading-tight">
Masuk: {formatRupiah(sub.paid)}<br/>
Sisa: {formatRupiah(sub.unpaid)}
</div>
)}
</td> </td>
{/* <td className="px-4 py-2.5 text-center"> {/* <td className="px-4 py-2.5 text-center">
{isUnpaid ? ( {isUnpaid ? (
+31 -20
View File
@@ -16,12 +16,19 @@ export default function ReportCharts({ type, data }: { type: 'SUBMITTOR' | 'CREA
chartData = [...data] chartData = [...data]
.filter(sub => sub.order.status === 'CLOSE') .filter(sub => sub.order.status === 'CLOSE')
.sort((a, b) => new Date(a.order.date).getTime() - new Date(b.order.date).getTime()) // oldest → newest .sort((a, b) => new Date(a.order.date).getTime() - new Date(b.order.date).getTime()) // oldest → newest
.map(sub => ({ .map(sub => {
name: sub.order.title.length > 15 ? sub.order.title.substring(0, 15) + '...' : sub.order.title, const bill = sub.bill || 0
fullTitle: sub.order.title, const paid = (sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? bill : 0)) + (sub.saldo_used || 0)
Total: sub.bill || 0, const unpaid = Math.max(0, bill - paid)
status: sub.payment_status return {
})) name: sub.order.title.length > 15 ? sub.order.title.substring(0, 15) + '...' : sub.order.title,
fullTitle: sub.order.title,
Total: bill,
Paid: paid,
Unpaid: unpaid,
status: unpaid > 0 ? (paid > 0 ? 'SEBAGIAN' : 'BELUM BAYAR') : 'LUNAS'
}
})
} else { } else {
chartData = [...data] chartData = [...data]
.filter(order => order.status === 'CLOSE') .filter(order => order.status === 'CLOSE')
@@ -50,14 +57,21 @@ export default function ReportCharts({ type, data }: { type: 'SUBMITTOR' | 'CREA
const CustomTooltip = ({ active, payload, label }: any) => { const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const data = payload[0].payload
return ( return (
<div className="bg-white dark:bg-slate-900 p-3 border border-slate-200 dark:border-slate-800 rounded-xl shadow-lg"> <div className="bg-white dark:bg-slate-900 p-3 border border-slate-200 dark:border-slate-800 rounded-xl shadow-lg">
<p className="font-bold text-xs text-slate-800 dark:text-slate-200 mb-1">{payload[0].payload.fullTitle}</p> <p className="font-bold text-xs text-slate-800 dark:text-slate-200 mb-1">{data.fullTitle}</p>
<p className="text-sm font-black text-[#1B2CC1]"> <p className="text-sm font-black text-[#1B2CC1]">
{formatRupiah(payload[0].value)} Total: {formatRupiah(data.Total)}
</p> </p>
{type === 'SUBMITTOR' && (
<>
<p className="text-[11px] text-emerald-600 font-bold mt-1">Dibayar: {formatRupiah(data.Paid)}</p>
{data.Unpaid > 0 && <p className="text-[11px] text-rose-600 font-bold">Kurang: {formatRupiah(data.Unpaid)}</p>}
</>
)}
<p className="text-[10px] text-slate-500 mt-1 uppercase font-bold tracking-wider"> <p className="text-[10px] text-slate-500 mt-1 uppercase font-bold tracking-wider">
Status: {payload[0].payload.status} Status: {data.status}
</p> </p>
</div> </div>
) )
@@ -90,17 +104,14 @@ export default function ReportCharts({ type, data }: { type: 'SUBMITTOR' | 'CREA
tickFormatter={(value) => `Rp${value / 1000}k`} tickFormatter={(value) => `Rp${value / 1000}k`}
/> />
<Tooltip cursor={{ fill: 'rgba(27, 44, 193, 0.05)' }} content={<CustomTooltip />} /> <Tooltip cursor={{ fill: 'rgba(27, 44, 193, 0.05)' }} content={<CustomTooltip />} />
<Bar dataKey="Total" radius={[4, 4, 0, 0]}> {type === 'SUBMITTOR' ? (
{chartData.map((entry, index) => ( <>
<Cell <Bar dataKey="Paid" stackId="a" fill="#10b981" />
key={`cell-${index}`} <Bar dataKey="Unpaid" stackId="a" fill="#f43f5e" radius={[4, 4, 0, 0]} />
fill={type === 'SUBMITTOR' </>
? (entry.status === 'LUNAS' ? '#10b981' : '#f43f5e') // Emerald or Rose ) : (
: '#1B2CC1' // Primary for creator <Bar dataKey="Total" radius={[4, 4, 0, 0]} fill="#1B2CC1" />
} )}
/>
))}
</Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</CardContent> </CardContent>
+37 -5
View File
@@ -160,8 +160,22 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
{sub.payment_status === 'LUNAS' ? 'LUNAS' : 'BELUM BAYAR'} {sub.payment_status === 'LUNAS' ? 'LUNAS' : 'BELUM BAYAR'}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-right font-black text-slate-900 dark:text-white"> <td className="px-6 py-4 text-right font-black text-slate-900 dark:text-white flex flex-col">
{sub.bill ? formatRupiah(sub.bill) : '-'} <span>{sub.bill ? formatRupiah(sub.bill) : '-'}</span>
{(() => {
const bill = Number(sub.bill) || 0
const paid = (sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? bill : 0)) + (sub.saldo_used || 0)
const unpaid = Math.max(0, bill - paid)
if (unpaid > 0 && paid > 0) {
return (
<div className="text-[10px] text-slate-500 font-medium mt-1 leading-tight">
Dibayar: {formatRupiah(paid)}<br/>
Sisa: {formatRupiah(unpaid)}
</div>
)
}
return null
})()}
</td> </td>
</tr> </tr>
@@ -238,7 +252,11 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
{paginatedData.map((order: any) => { {paginatedData.map((order: any) => {
const isExpanded = !!expandedRows[order.id] const isExpanded = !!expandedRows[order.id]
const totalOmzet = order.submissions.reduce((acc: number, sub: any) => acc + (Number(sub.bill) || 0), 0) const totalOmzet = order.submissions.reduce((acc: number, sub: any) => acc + (Number(sub.bill) || 0), 0)
const totalPiutang = order.submissions.reduce((acc: number, sub: any) => acc + (sub.payment_status !== 'LUNAS' ? (Number(sub.bill) || 0) : 0), 0) const totalPiutang = order.submissions.reduce((acc: number, sub: any) => {
const bill = Number(sub.bill) || 0
const paid = (sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? bill : 0)) + (sub.saldo_used || 0)
return acc + Math.max(0, bill - paid)
}, 0)
return ( return (
<React.Fragment key={order.id}> <React.Fragment key={order.id}>
@@ -312,8 +330,22 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
{sub.payment_status === 'LUNAS' ? 'Lunas' : 'Belum'} {sub.payment_status === 'LUNAS' ? 'Lunas' : 'Belum'}
</span> </span>
</td> </td>
<td className="px-4 py-2.5 text-right font-black text-slate-700 dark:text-slate-300"> <td className="px-4 py-2.5 text-right font-black text-slate-700 dark:text-slate-300 flex flex-col">
{sub.bill ? formatRupiah(sub.bill) : '-'} <span>{sub.bill ? formatRupiah(sub.bill) : '-'}</span>
{(() => {
const bill = Number(sub.bill) || 0
const paid = (sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? bill : 0)) + (sub.saldo_used || 0)
const unpaid = Math.max(0, bill - paid)
if (unpaid > 0 && paid > 0) {
return (
<div className="text-[10px] text-slate-500 font-medium mt-0.5 leading-tight">
Masuk: {formatRupiah(paid)}<br/>
Sisa: {formatRupiah(unpaid)}
</div>
)
}
return null
})()}
</td> </td>
</tr> </tr>
))} ))}
+3 -2
View File
@@ -14,11 +14,12 @@ export function SummaryGenerator({ order }: { order: any }) {
const itemCounts: Record<string, number> = {} const itemCounts: Record<string, number> = {}
order.submissions?.forEach((sub: any) => { order.submissions?.forEach((sub: any) => {
const itemStrings = sub.items.map((i: any) => `${i.name} ${i.qty}x`) const itemStrings = sub.items.map((i: any) => `${i.name} ${i.qty}x${i.note ? ` (${i.note})` : ''}`)
summaryByPerson += `- ${sub.user?.name || 'Unknown'} : ${itemStrings.join(', ')}\n` summaryByPerson += `- ${sub.user?.name || 'Unknown'} : ${itemStrings.join(', ')}\n`
sub.items.forEach((i: any) => { sub.items.forEach((i: any) => {
itemCounts[i.name] = (itemCounts[i.name] || 0) + i.qty const key = i.note ? `${i.name} (${i.note})` : i.name
itemCounts[key] = (itemCounts[key] || 0) + i.qty
}) })
}) })
+1264 -1671
View File
File diff suppressed because it is too large Load Diff