diff --git a/AGENTS.md b/AGENTS.md index 62a5340..432a665 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,3 +182,13 @@ Buatkan file `README.md` yang mendokumentasikan panduan lengkap langkah demi lan - Pengaturan Environment: Cara setup konfigurasi environment variables `.env` berdasarkan struktur yang sudah dijelaskan di atas. - Eksekusi Migrasi Database: Perintah terminal yang wajib dijalankan secara berurutan untuk sinkronisasi database dan mengaktifkan Prisma Client (contoh: `npx prisma generate` lalu `npx prisma db push`). - Menjalankan Aplikasi: Cara menjalankan server lokal (development mode) dan URL default yang bisa diakses di browser. + + + +# This is NOT the Next.js you know + +This version has breaking changes โ€” APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` โ€” verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8fc5b62..1b97687 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,17 +8,19 @@ datasource db { } model User { - id String @id @default(uuid()) - username String @unique - name String - password String - photo String? - role String @default("user") - is_active Boolean @default(true) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - orders Order[] @relation("CreatedOrders") - purchases Submission[] + id String @id @default(uuid()) + username String @unique + name String + password String + photo String? + role String @default("user") + is_active Boolean @default(true) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + orders Order[] @relation("CreatedOrders") + purchases Submission[] + creator_withdrawals BalanceWithdrawal[] @relation("CreatorWithdrawals") + user_withdrawals BalanceWithdrawal[] @relation("UserWithdrawals") } model Order { @@ -78,3 +80,14 @@ model Setting { value String updated_at DateTime @updatedAt } + +model BalanceWithdrawal { + id String @id @default(uuid()) + creator_id String + user_id String + amount Int + note String? + created_at DateTime @default(now()) + creator User @relation("CreatorWithdrawals", fields: [creator_id], references: [id], onDelete: Cascade) + user User @relation("UserWithdrawals", fields: [user_id], references: [id], onDelete: Cascade) +} diff --git a/src/app/(app)/balances/page.tsx b/src/app/(app)/balances/page.tsx index 6119ce7..5a02675 100644 --- a/src/app/(app)/balances/page.tsx +++ b/src/app/(app)/balances/page.tsx @@ -1,10 +1,36 @@ 'use client' import { useEffect, useState } from 'react' -import { getBalancesAsCreator, getBalancesAsSubmittor, getSessionUser } from '@/app/actions' +import { + getBalancesAsCreator, + getBalancesAsSubmittor, + getSessionUser, + createWithdrawal, + getWithdrawalHistory, + deleteWithdrawal, +} from '@/app/actions' import { Card, CardContent } from '@/components/ui/card' -import { Wallet, ArrowDownRight, ArrowUpRight, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { + Wallet, + ArrowDownRight, + ArrowUpRight, + Loader2, + Banknote, + History, + Trash2, + ChevronDown, + ChevronUp, + AlertTriangle, + CheckCircle2, + FileText, +} from 'lucide-react' import { cn } from '@/lib/utils' +import { format } from 'date-fns' +import { id as idLocale } from 'date-fns/locale' export default function BalancesPage() { const [activeTab, setActiveTab] = useState<'SUBMITTOR' | 'CREATOR'>('SUBMITTOR') @@ -13,6 +39,24 @@ export default function BalancesPage() { const [creatorBalances, setCreatorBalances] = useState([]) const [loading, setLoading] = useState(true) + // Modal state + const [withdrawTarget, setWithdrawTarget] = useState(null) + const [modalOpen, setModalOpen] = useState(false) + const [withdrawAmount, setWithdrawAmount] = useState('') + const [withdrawNote, setWithdrawNote] = useState('') + const [saving, setSaving] = useState(false) + const [modalError, setModalError] = useState('') + + // History state per user + const [historyMap, setHistoryMap] = useState>({}) + const [expandedHistory, setExpandedHistory] = useState>({}) + const [loadingHistory, setLoadingHistory] = useState>({}) + const [deletingId, setDeletingId] = useState(null) + + // Delete confirm + const [deleteTarget, setDeleteTarget] = useState<{ id: string; amount: number } | null>(null) + const [deleteModalOpen, setDeleteModalOpen] = useState(false) + useEffect(() => { const init = async () => { const user = await getSessionUser() @@ -42,6 +86,83 @@ export default function BalancesPage() { maximumFractionDigits: 0, }).format(n) + const openWithdrawModal = (e: React.MouseEvent, balance: any) => { + e.stopPropagation() + setWithdrawTarget(balance) + setWithdrawAmount(String(balance.amount)) + setWithdrawNote('') + setModalError('') + setModalOpen(true) + } + + const handleWithdraw = async () => { + if (!withdrawTarget || !userId) return + const amount = parseInt(withdrawAmount.replace(/\D/g, '')) || 0 + if (amount <= 0) { + setModalError('Nominal harus lebih dari 0') + return + } + if (amount > withdrawTarget.amount) { + setModalError(`Tidak boleh melebihi saldo tersedia (${formatRupiah(withdrawTarget.amount)})`) + return + } + setSaving(true) + setModalError('') + const res = await createWithdrawal({ + creator_id: userId, + user_id: withdrawTarget.user.id, + amount, + note: withdrawNote.trim() || undefined, + }) + setSaving(false) + if (res.success) { + setModalOpen(false) + loadData(userId) + if (expandedHistory[withdrawTarget.user.id]) { + loadHistory(withdrawTarget.user.id) + } + } else { + setModalError(res.error || 'Gagal mencatat pengembalian') + } + } + + const loadHistory = async (targetUserId: string) => { + if (!userId) return + setLoadingHistory((prev) => ({ ...prev, [targetUserId]: true })) + const history = await getWithdrawalHistory(userId, targetUserId) + setHistoryMap((prev) => ({ ...prev, [targetUserId]: history })) + setLoadingHistory((prev) => ({ ...prev, [targetUserId]: false })) + } + + const toggleHistory = (targetUserId: string) => { + const willOpen = !expandedHistory[targetUserId] + setExpandedHistory((prev) => ({ ...prev, [targetUserId]: willOpen })) + if (willOpen && !historyMap[targetUserId]) { + loadHistory(targetUserId) + } + } + + const confirmDelete = (id: string, amount: number) => { + setDeleteTarget({ id, amount }) + setDeleteModalOpen(true) + } + + const handleDelete = async () => { + if (!deleteTarget || !userId) return + setDeletingId(deleteTarget.id) + const res = await deleteWithdrawal(deleteTarget.id) + setDeletingId(null) + setDeleteModalOpen(false) + if (res.success) { + loadData(userId) + Object.keys(expandedHistory).forEach((uid) => { + if (expandedHistory[uid]) loadHistory(uid) + }) + } else { + alert(res.error || 'Gagal menghapus') + } + } + return (
@@ -86,149 +207,581 @@ export default function BalancesPage() { Memuat saldo...
) : activeTab === 'SUBMITTOR' ? ( -
-
+
+

Saldo Anda di Kreator Lain

Jika saldo positif (hijau), Anda memiliki deposit yang bisa digunakan - untuk pesanan berikutnya di kreator tersebut. Jika negatif (merah), - Anda berhutang. + untuk pesanan berikutnya. Jika negatif (merah), Anda berhutang.

{submittorBalances.length === 0 ? ( -
-

Belum ada catatan saldo.

+
+
+ +
+

Belum ada catatan saldo

+

+ Saldo muncul saat ada sisa atau kekurangan bayar dari PO. +

) : ( -
- {submittorBalances.map((b, i) => ( - -
- {b.creator.photo ? ( - {b.creator.name} - ) : ( -
- {b.creator.name.charAt(0).toUpperCase()} -
+
+ {submittorBalances.map((b, i) => { + const isPositive = b.amount > 0 + + return ( +
-

- {b.creator.name} -

-

- Kreator -

-
-
- -
- - Total Saldo - + > +
+ {/* Left accent strip */}
0 ? 'text-emerald-600' : 'text-rose-600' + 'w-1 shrink-0', + isPositive ? 'bg-emerald-400' : 'bg-rose-400' )} - > - {b.amount > 0 ? ( - - ) : ( - - )} - {formatRupiah(b.amount)} + /> + +
+ {/* Main info row */} +
+ {/* Avatar + name */} +
+
+ {b.creator.photo ? ( + {b.creator.name} + ) : ( +
+ {b.creator.name.charAt(0).toUpperCase()} +
+ )} + {/* Status dot */} + +
+
+

+ {b.creator.name} +

+

Kreator

+
+
+ + {/* Amount */} +
+

+ Total Saldo +

+

+ {formatRupiah(Math.abs(b.amount))} +

+
+
+ + {/* Footer */} +
+ + {isPositive ? ( + + ) : ( + + )} + {isPositive ? 'Deposit Tersedia' : 'Anda Berhutang'} + + + {isPositive && ( +

+ โœ“ Bisa dipakai untuk PO +

+ )} +
- - - ))} +
+ ) + })}
)}
) : ( -
-
+ /* TAB CREATOR */ +
+

Saldo Orang Lain di Anda

- Jika saldo positif (merah bagi Anda), artinya Anda memegang uang - lebih milik penitip (Hutang Anda ke mereka). Jika negatif (hijau), - mereka berhutang ke Anda. + Saldo positif = Anda memegang uang lebih milik penitip. Klik{' '} + Kembalikan untuk mencatatnya. Saldo negatif = + penitip masih berhutang ke Anda.

{creatorBalances.length === 0 ? ( -
-

- Belum ada penitip yang memiliki catatan saldo dengan Anda. +

+
+ +
+

Tidak ada catatan saldo

+

+ Saldo muncul saat ada kelebihan atau kekurangan bayar.

) : ( -
- {creatorBalances.map((b, i) => ( - -
- {b.user.photo ? ( - {b.user.name} - ) : ( -
- {b.user.name.charAt(0).toUpperCase()} -
+
+ {creatorBalances.map((b, i) => { + const uid = b.user.id + const isExpanded = expandedHistory[uid] + const history = historyMap[uid] || [] + const isLoadingHist = loadingHistory[uid] + const isPositive = b.amount > 0 + + return ( +
-

- {b.user.name} -

-

- Penitip -

-
-
- -
- - Saldo Penitip - - {/* From Creator perspective: positive balance of submittor is bad for creator (creator holds their money) */} + > +
+ {/* Left accent strip */}
0 ? 'text-rose-600' : 'text-emerald-600' + 'w-1 shrink-0', + isPositive ? 'bg-rose-400' : 'bg-emerald-400' )} - > - {b.amount > 0 ? ( - - ) : ( - + /> + +
+ {/* Main info row */} +
+ {/* Avatar + name */} +
+
+ {b.user.photo ? ( + {b.user.name} + ) : ( +
+ {b.user.name.charAt(0).toUpperCase()} +
+ )} + +
+
+

+ {b.user.name} +

+

Penitip

+
+
+ + {/* Amount */} +
+

+ {isPositive ? 'Anda Hutang' : 'Piutang Anda'} +

+

+ {formatRupiah(Math.abs(b.amount))} +

+
+
+ + {/* Footer row */} +
+ + {isPositive ? ( + + ) : ( + + )} + {isPositive ? 'Perlu dikembalikan' : 'Menunggu pelunasan'} + + +
+ {isPositive && ( + + )} + +
+
+ + {/* Collapsible History */} + {isExpanded && ( +
+

+ Riwayat Pengembalian +

+ {isLoadingHist ? ( +
+ + Memuat riwayat... +
+ ) : history.length === 0 ? ( +
+ + + Belum ada riwayat pengembalian. + +
+ ) : ( +
+ {history.map((h: any) => ( +
+
+ +
+
+

+ {formatRupiah(h.amount)} +

+
+

+ {format(new Date(h.created_at), 'dd MMM yyyy, HH:mm', { + locale: idLocale, + })} +

+ {h.note && ( + <> + ยท +

+ "{h.note}" +

+ + )} +
+
+ +
+ ))} +
+ )} +
)} - {formatRupiah(b.amount)}
- - - ))} +
+ ) + })}
)}
)} + + {/* MODAL PENGEMBALIAN */} + !o && setModalOpen(false)}> + +
+
+ +
+
+ + Kembalikan Saldo + +

+ Catat pengembalian uang ke {withdrawTarget?.user?.name} +

+
+
+
+ {withdrawTarget && ( +
+ {withdrawTarget.user.photo ? ( + {withdrawTarget.user.name} + ) : ( +
+ {withdrawTarget.user.name.charAt(0).toUpperCase()} +
+ )} +
+

+ {withdrawTarget.user.name} +

+

+ Saldo tersedia:{' '} + + {formatRupiah(withdrawTarget?.amount || 0)} + +

+
+
+ )} + +
+ +
+ + Rp + + { + setWithdrawAmount(e.target.value) + setModalError('') + }} + className="h-11 pl-8 text-sm font-bold" + min={1} + max={withdrawTarget?.amount} + /> +
+ {withdrawTarget && ( +
+ + {withdrawTarget.amount >= 2 && ( + + )} +
+ )} +
+ +
+ + setWithdrawNote(e.target.value)} + className="h-10 text-sm" + maxLength={100} + /> +
+ + {modalError && ( +
+ +

+ {modalError} +

+
+ )} + +
+ + +
+
+
+
+ + {/* MODAL DELETE CONFIRM */} + !o && setDeleteModalOpen(false)}> + +
+
+ +
+
+ + Hapus Riwayat? + +

Saldo akan kembali bertambah

+
+
+
+
+

Pengembalian sebesar

+

+ {formatRupiah(deleteTarget?.amount || 0)} +

+

akan dihapus dari riwayat.

+
+
+ + +
+
+
+
) } diff --git a/src/app/actions.ts b/src/app/actions.ts index 01a73b1..9f351ee 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -661,14 +661,16 @@ export async function getCreatorReport(creator_id: string, startDate?: Date, end // === BALANCE ACTIONS === export async function getBalancesAsCreator(creator_id: string) { - const submissions = await prisma.submission.findMany({ - where: { - order: { creator_id }, - }, - include: { - user: { select: { id: true, name: true, photo: true } }, - }, - }) + const [submissions, withdrawals] = await Promise.all([ + prisma.submission.findMany({ + where: { order: { creator_id } }, + include: { user: { select: { id: true, name: true, photo: true } } }, + }), + prisma.balanceWithdrawal.findMany({ + where: { creator_id }, + include: { user: { select: { id: true, name: true, photo: true } } }, + }), + ]) const balanceMap = new Map() @@ -691,22 +693,35 @@ export async function getBalancesAsCreator(creator_id: string) { } }) + // Kurangi saldo dengan total withdrawal yang sudah dilakukan + withdrawals.forEach((w) => { + if (!balanceMap.has(w.user_id)) { + balanceMap.set(w.user_id, { user: w.user, amount: -w.amount }) + } else { + balanceMap.get(w.user_id)!.amount -= w.amount + } + }) + return Array.from(balanceMap.values()).filter((b) => b.amount !== 0) } export async function getBalancesAsSubmittor(user_id: string) { - const submissions = await prisma.submission.findMany({ - where: { - user_id, - }, - include: { - order: { - include: { - creator: { select: { id: true, name: true, photo: true } }, + const [submissions, withdrawals] = await Promise.all([ + prisma.submission.findMany({ + where: { user_id }, + include: { + order: { + include: { + creator: { select: { id: true, name: true, photo: true } }, + }, }, }, - }, - }) + }), + prisma.balanceWithdrawal.findMany({ + where: { user_id }, + include: { creator: { select: { id: true, name: true, photo: true } } }, + }), + ]) const balanceMap = new Map() @@ -730,9 +745,99 @@ export async function getBalancesAsSubmittor(user_id: string) { } }) + // Kurangi saldo dengan total withdrawal yang dilakukan kreator + withdrawals.forEach((w) => { + if (!balanceMap.has(w.creator_id)) { + balanceMap.set(w.creator_id, { creator: w.creator, amount: -w.amount }) + } else { + balanceMap.get(w.creator_id)!.amount -= w.amount + } + }) + return Array.from(balanceMap.values()).filter((b) => b.amount !== 0) } +// === WITHDRAWAL ACTIONS === +export async function createWithdrawal(data: { + creator_id: string + user_id: string + amount: number + note?: string +}) { + try { + const me = await getSessionUser() + if (!me || me.id !== data.creator_id) { + return { success: false, error: 'Unauthorized' } + } + if (!data.amount || data.amount <= 0) { + return { success: false, error: 'Nominal harus lebih dari 0' } + } + + // Hitung saldo tersedia saat ini untuk user ini + const balances = await getBalancesAsCreator(data.creator_id) + const userBalance = balances.find((b) => b.user.id === data.user_id) + const available = userBalance ? userBalance.amount : 0 + + if (data.amount > available) { + return { + success: false, + error: `Nominal melebihi saldo tersedia (${new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(available)})`, + } + } + + await prisma.balanceWithdrawal.create({ + data: { + creator_id: data.creator_id, + user_id: data.user_id, + amount: data.amount, + note: data.note || null, + }, + }) + + revalidatePath('/balances') + revalidatePath('/my-orders') + revalidatePath('/reports') + return { success: true } + } catch (e) { + console.error(e) + return { success: false, error: 'Gagal mencatat penarikan saldo.' } + } +} + +export async function getWithdrawalHistory(creator_id: string, user_id?: string) { + return await prisma.balanceWithdrawal.findMany({ + where: { + creator_id, + ...(user_id ? { user_id } : {}), + }, + include: { + user: { select: { id: true, name: true, photo: true } }, + }, + orderBy: { created_at: 'desc' }, + }) +} + +export async function deleteWithdrawal(id: string) { + try { + const me = await getSessionUser() + const record = await prisma.balanceWithdrawal.findUnique({ where: { id } }) + if (!record) return { success: false, error: 'Data tidak ditemukan' } + if (!me || me.id !== record.creator_id) { + return { success: false, error: 'Unauthorized' } + } + + await prisma.balanceWithdrawal.delete({ where: { id } }) + + revalidatePath('/balances') + revalidatePath('/my-orders') + revalidatePath('/reports') + return { success: true } + } catch (e) { + console.error(e) + return { success: false, error: 'Gagal menghapus riwayat penarikan.' } + } +} + export async function deleteSubmission(submission_id: string, user_id: string) { try { const submission = await prisma.submission.findUnique({