4 Commits
10 changed files with 2110 additions and 707 deletions
+10
View File
@@ -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. - 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`). - 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. - Menjalankan Aplikasi: Cara menjalankan server lokal (development mode) dan URL default yang bisa diakses di browser.
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->
+26
View File
@@ -19,6 +19,9 @@ model User {
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
orders Order[] @relation("CreatedOrders") orders Order[] @relation("CreatedOrders")
purchases Submission[] purchases Submission[]
creator_withdrawals BalanceWithdrawal[] @relation("CreatorWithdrawals")
user_withdrawals BalanceWithdrawal[] @relation("UserWithdrawals")
notification_config UserNotificationConfig?
} }
model Order { model Order {
@@ -78,3 +81,26 @@ model Setting {
value String value String
updated_at DateTime @updatedAt 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)
}
model UserNotificationConfig {
id String @id @default(uuid())
user_id String @unique
mattermost_channel_id String?
telegram_chat_id String?
whatsapp_number String?
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
}
+620 -67
View File
@@ -1,10 +1,36 @@
'use client' 'use client'
import { useEffect, useState } from 'react' 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 { 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 { cn } from '@/lib/utils'
import { format } from 'date-fns'
import { id as idLocale } from 'date-fns/locale'
export default function BalancesPage() { export default function BalancesPage() {
const [activeTab, setActiveTab] = useState<'SUBMITTOR' | 'CREATOR'>('SUBMITTOR') const [activeTab, setActiveTab] = useState<'SUBMITTOR' | 'CREATOR'>('SUBMITTOR')
@@ -13,6 +39,24 @@ export default function BalancesPage() {
const [creatorBalances, setCreatorBalances] = useState<any[]>([]) const [creatorBalances, setCreatorBalances] = useState<any[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Modal state
const [withdrawTarget, setWithdrawTarget] = useState<any | null>(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<Record<string, any[]>>({})
const [expandedHistory, setExpandedHistory] = useState<Record<string, boolean>>({})
const [loadingHistory, setLoadingHistory] = useState<Record<string, boolean>>({})
const [deletingId, setDeletingId] = useState<string | null>(null)
// Delete confirm
const [deleteTarget, setDeleteTarget] = useState<{ id: string; amount: number } | null>(null)
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
const user = await getSessionUser() const user = await getSessionUser()
@@ -42,6 +86,83 @@ export default function BalancesPage() {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(n) }).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 ( return (
<div className="animate-in fade-in space-y-6 pb-12 duration-500"> <div className="animate-in fade-in space-y-6 pb-12 duration-500">
<div> <div>
@@ -86,149 +207,581 @@ export default function BalancesPage() {
<span className="text-xs font-semibold text-slate-500">Memuat saldo...</span> <span className="text-xs font-semibold text-slate-500">Memuat saldo...</span>
</div> </div>
) : activeTab === 'SUBMITTOR' ? ( ) : activeTab === 'SUBMITTOR' ? (
<div className="space-y-4"> <div className="space-y-5">
<div className="mb-6 rounded-xl border border-blue-100 bg-blue-50 p-4 dark:border-blue-900/50 dark:bg-blue-950/30"> <div className="rounded-xl border border-blue-100 bg-blue-50 p-4 dark:border-blue-900/50 dark:bg-blue-950/30">
<h3 className="mb-1 text-sm font-bold text-blue-900 dark:text-blue-100"> <h3 className="mb-1 text-sm font-bold text-blue-900 dark:text-blue-100">
Saldo Anda di Kreator Lain Saldo Anda di Kreator Lain
</h3> </h3>
<p className="text-xs leading-relaxed text-blue-700 dark:text-blue-300"> <p className="text-xs leading-relaxed text-blue-700 dark:text-blue-300">
Jika saldo <strong>positif</strong> (hijau), Anda memiliki deposit yang bisa digunakan Jika saldo <strong>positif</strong> (hijau), Anda memiliki deposit yang bisa digunakan
untuk pesanan berikutnya di kreator tersebut. Jika <strong>negatif</strong> (merah), untuk pesanan berikutnya. Jika <strong>negatif</strong> (merah), Anda berhutang.
Anda berhutang.
</p> </p>
</div> </div>
{submittorBalances.length === 0 ? ( {submittorBalances.length === 0 ? (
<div className="rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900"> <div className="rounded-2xl border-2 border-dashed border-slate-200 bg-white py-16 text-center dark:border-slate-800 dark:bg-slate-900">
<p className="text-xs font-medium text-slate-500">Belum ada catatan saldo.</p> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
<Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
</div>
<p className="text-sm font-semibold text-slate-500">Belum ada catatan saldo</p>
<p className="mt-1 text-xs text-slate-400">
Saldo muncul saat ada sisa atau kekurangan bayar dari PO.
</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3"> <div className="space-y-3">
{submittorBalances.map((b, i) => ( {submittorBalances.map((b, i) => {
<Card const isPositive = b.amount > 0
return (
<div
key={i} key={i}
className="overflow-hidden rounded-2xl border-slate-200/90 shadow-sm transition-all hover:border-[#1B2CC1]/30 dark:border-slate-800" className={cn(
'overflow-hidden rounded-2xl border bg-white shadow-sm dark:bg-slate-900',
isPositive
? 'border-emerald-100 dark:border-emerald-900/40'
: 'border-rose-100 dark:border-rose-900/40'
)}
> >
<div className="flex items-center gap-3 border-b border-slate-100 bg-slate-50/50 p-4 dark:border-slate-800 dark:bg-slate-800/40"> <div className="flex items-stretch">
{/* Left accent strip */}
<div
className={cn(
'w-1 shrink-0',
isPositive ? 'bg-emerald-400' : 'bg-rose-400'
)}
/>
<div className="flex flex-1 flex-col gap-0">
{/* Main info row */}
<div className="flex items-center justify-between gap-4 px-4 py-4">
{/* Avatar + name */}
<div className="flex min-w-0 items-center gap-3">
<div className="relative shrink-0">
{b.creator.photo ? ( {b.creator.photo ? (
<img <img
src={b.creator.photo} src={b.creator.photo}
alt={b.creator.name} alt={b.creator.name}
className="h-10 w-10 rounded-full object-cover shadow-sm ring-2 ring-white dark:ring-slate-900" className="h-10 w-10 rounded-full object-cover"
/> />
) : ( ) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#1B2CC1]/10 text-sm font-bold text-[#1B2CC1] shadow-sm ring-2 ring-white dark:ring-slate-900"> <div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full text-sm font-black',
isPositive
? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400'
: 'bg-rose-100 text-rose-600 dark:bg-rose-950/60 dark:text-rose-400'
)}
>
{b.creator.name.charAt(0).toUpperCase()} {b.creator.name.charAt(0).toUpperCase()}
</div> </div>
)} )}
<div> {/* Status dot */}
<p className="text-sm font-bold text-slate-900 dark:text-white"> <span
className={cn(
'absolute -right-0.5 -bottom-0.5 h-3 w-3 rounded-full border-2 border-white dark:border-slate-900',
isPositive ? 'bg-emerald-400' : 'bg-rose-400'
)}
/>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-bold text-slate-900 dark:text-white">
{b.creator.name} {b.creator.name}
</p> </p>
<p className="text-[10px] font-semibold tracking-wider text-slate-500 uppercase"> <p className="text-[10px] font-medium text-slate-400">Kreator</p>
Kreator </div>
</div>
{/* Amount */}
<div className="shrink-0 text-right">
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
Total Saldo
</p>
<p
className={cn(
'text-xl font-black tabular-nums',
isPositive
? 'text-emerald-600 dark:text-emerald-400'
: 'text-rose-600 dark:text-rose-400'
)}
>
{formatRupiah(Math.abs(b.amount))}
</p> </p>
</div> </div>
</div> </div>
<CardContent className="bg-white p-4 dark:bg-slate-900">
<div className="flex flex-col gap-1"> {/* Footer */}
<span className="text-[11px] font-bold tracking-wider text-slate-400 uppercase">
Total Saldo
</span>
<div <div
className={cn( className={cn(
'flex items-center gap-1.5 text-xl font-black', 'flex items-center justify-between gap-2 border-t px-4 py-2.5',
b.amount > 0 ? 'text-emerald-600' : 'text-rose-600' isPositive
? 'border-emerald-50 bg-emerald-50/60 dark:border-emerald-900/30 dark:bg-emerald-950/20'
: 'border-rose-50 bg-rose-50/60 dark:border-rose-900/30 dark:bg-rose-950/20'
)} )}
> >
{b.amount > 0 ? ( <span
<ArrowUpRight className="h-5 w-5" /> className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-bold',
isPositive
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-400'
: 'bg-rose-100 text-rose-700 dark:bg-rose-950/60 dark:text-rose-400'
)}
>
{isPositive ? (
<ArrowUpRight className="h-3 w-3" />
) : ( ) : (
<ArrowDownRight className="h-5 w-5" /> <ArrowDownRight className="h-3 w-3" />
)}
{isPositive ? 'Deposit Tersedia' : 'Anda Berhutang'}
</span>
{isPositive && (
<p className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
âś“ Bisa dipakai untuk PO
</p>
)} )}
{formatRupiah(b.amount)}
</div> </div>
</div> </div>
</CardContent> </div>
</Card> </div>
))} )
})}
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="space-y-4"> /* TAB CREATOR */
<div className="mb-6 rounded-xl border border-amber-100 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30"> <div className="space-y-5">
<div className="rounded-xl border border-amber-100 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
<h3 className="mb-1 text-sm font-bold text-amber-900 dark:text-amber-100"> <h3 className="mb-1 text-sm font-bold text-amber-900 dark:text-amber-100">
Saldo Orang Lain di Anda Saldo Orang Lain di Anda
</h3> </h3>
<p className="text-xs leading-relaxed text-amber-700 dark:text-amber-300"> <p className="text-xs leading-relaxed text-amber-700 dark:text-amber-300">
Jika saldo <strong>positif</strong> (merah bagi Anda), artinya Anda memegang uang Saldo <strong>positif</strong> = Anda memegang uang lebih milik penitip. Klik{' '}
lebih milik penitip (Hutang Anda ke mereka). Jika <strong>negatif</strong> (hijau), <strong>Kembalikan</strong> untuk mencatatnya. Saldo <strong>negatif</strong> =
mereka berhutang ke Anda. penitip masih berhutang ke Anda.
</p> </p>
</div> </div>
{creatorBalances.length === 0 ? ( {creatorBalances.length === 0 ? (
<div className="rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900"> <div className="rounded-2xl border-2 border-dashed border-slate-200 bg-white py-16 text-center dark:border-slate-800 dark:bg-slate-900">
<p className="text-xs font-medium text-slate-500"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
Belum ada penitip yang memiliki catatan saldo dengan Anda. <Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
</div>
<p className="text-sm font-semibold text-slate-500">Tidak ada catatan saldo</p>
<p className="mt-1 text-xs text-slate-400">
Saldo muncul saat ada kelebihan atau kekurangan bayar.
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3"> <div className="space-y-3">
{creatorBalances.map((b, i) => ( {creatorBalances.map((b, i) => {
<Card const uid = b.user.id
const isExpanded = expandedHistory[uid]
const history = historyMap[uid] || []
const isLoadingHist = loadingHistory[uid]
const isPositive = b.amount > 0
return (
<div
key={i} key={i}
className="overflow-hidden rounded-2xl border-slate-200/90 shadow-sm transition-all hover:border-[#1B2CC1]/30 dark:border-slate-800" className={cn(
'overflow-hidden rounded-2xl border bg-white shadow-sm dark:bg-slate-900',
isPositive
? 'border-rose-100 dark:border-rose-900/40'
: 'border-emerald-100 dark:border-emerald-900/40'
)}
> >
<div className="flex items-center gap-3 border-b border-slate-100 bg-slate-50/50 p-4 dark:border-slate-800 dark:bg-slate-800/40"> <div className="flex items-stretch">
{/* Left accent strip */}
<div
className={cn(
'w-1 shrink-0',
isPositive ? 'bg-rose-400' : 'bg-emerald-400'
)}
/>
<div className="flex flex-1 flex-col">
{/* Main info row */}
<div className="flex items-center justify-between gap-4 px-4 py-4">
{/* Avatar + name */}
<div className="flex min-w-0 items-center gap-3">
<div className="relative shrink-0">
{b.user.photo ? ( {b.user.photo ? (
<img <img
src={b.user.photo} src={b.user.photo}
alt={b.user.name} alt={b.user.name}
className="h-10 w-10 rounded-full object-cover shadow-sm ring-2 ring-white dark:ring-slate-900" className="h-10 w-10 rounded-full object-cover"
/> />
) : ( ) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-200 text-sm font-bold text-slate-600 shadow-sm ring-2 ring-white dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-900"> <div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full text-sm font-black',
isPositive
? 'bg-rose-100 text-rose-600 dark:bg-rose-950/60 dark:text-rose-400'
: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400'
)}
>
{b.user.name.charAt(0).toUpperCase()} {b.user.name.charAt(0).toUpperCase()}
</div> </div>
)} )}
<div> <span
<p className="text-sm font-bold text-slate-900 dark:text-white"> className={cn(
'absolute -right-0.5 -bottom-0.5 h-3 w-3 rounded-full border-2 border-white dark:border-slate-900',
isPositive ? 'bg-rose-400' : 'bg-emerald-400'
)}
/>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-bold text-slate-900 dark:text-white">
{b.user.name} {b.user.name}
</p> </p>
<p className="text-[10px] font-semibold tracking-wider text-slate-500 uppercase"> <p className="text-[10px] font-medium text-slate-400">Penitip</p>
Penitip </div>
</div>
{/* Amount */}
<div className="shrink-0 text-right">
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
{isPositive ? 'Anda Hutang' : 'Piutang Anda'}
</p>
<p
className={cn(
'text-xl font-black tabular-nums',
isPositive
? 'text-rose-600 dark:text-rose-400'
: 'text-emerald-600 dark:text-emerald-400'
)}
>
{formatRupiah(Math.abs(b.amount))}
</p> </p>
</div> </div>
</div> </div>
<CardContent className="bg-white p-4 dark:bg-slate-900">
<div className="flex flex-col gap-1"> {/* Footer row */}
<span className="text-[11px] font-bold tracking-wider text-slate-400 uppercase">
Saldo Penitip
</span>
{/* From Creator perspective: positive balance of submittor is bad for creator (creator holds their money) */}
<div <div
className={cn( className={cn(
'flex items-center gap-1.5 text-xl font-black', 'flex items-center justify-between gap-2 border-t px-4 py-2.5',
b.amount > 0 ? 'text-rose-600' : 'text-emerald-600' isPositive
? 'border-rose-50 bg-rose-50/60 dark:border-rose-900/30 dark:bg-rose-950/20'
: 'border-emerald-50 bg-emerald-50/60 dark:border-emerald-900/30 dark:bg-emerald-950/20'
)} )}
> >
{b.amount > 0 ? ( <span
<ArrowDownRight className="h-5 w-5" /> className={cn(
) : ( 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-bold',
<ArrowUpRight className="h-5 w-5" /> isPositive
? 'bg-rose-100 text-rose-700 dark:bg-rose-950/60 dark:text-rose-400'
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-400'
)} )}
{formatRupiah(b.amount)} >
{isPositive ? (
<ArrowDownRight className="h-3 w-3" />
) : (
<ArrowUpRight className="h-3 w-3" />
)}
{isPositive ? 'Perlu dikembalikan' : 'Menunggu pelunasan'}
</span>
<div className="flex items-center gap-1.5">
{isPositive && (
<Button
size="sm"
onClick={(e) => openWithdrawModal(e, b)}
className="h-7 gap-1 rounded-lg bg-[#1B2CC1] px-2.5 text-[11px] font-bold text-white hover:bg-[#15229E]"
>
<Banknote className="h-3 w-3" />
Kembalikan
</Button>
)}
<button
onClick={() => toggleHistory(uid)}
className={cn(
'flex h-7 items-center gap-1 rounded-lg border px-2.5 text-[11px] font-bold transition-all',
isExpanded
? 'border-slate-300 bg-slate-200 text-slate-700 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200'
: 'border-slate-200 bg-white text-slate-500 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700'
)}
>
<History className="h-3 w-3" />
Riwayat
{isExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
</div> </div>
</div> </div>
</CardContent>
</Card> {/* Collapsible History */}
{isExpanded && (
<div className="border-t border-slate-100 px-4 py-3 dark:border-slate-800">
<p className="mb-2.5 text-[10px] font-bold tracking-widest text-slate-400 uppercase">
Riwayat Pengembalian
</p>
{isLoadingHist ? (
<div className="flex items-center gap-2 py-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-slate-400" />
<span className="text-xs text-slate-400">Memuat riwayat...</span>
</div>
) : history.length === 0 ? (
<div className="flex items-center gap-2.5 rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-3 dark:border-slate-700 dark:bg-slate-800/50">
<FileText className="h-4 w-4 shrink-0 text-slate-300 dark:text-slate-600" />
<span className="text-xs text-slate-400">
Belum ada riwayat pengembalian.
</span>
</div>
) : (
<div className="space-y-1.5">
{history.map((h: any) => (
<div
key={h.id}
className="flex items-center gap-3 rounded-xl border border-slate-100 bg-slate-50/80 px-3 py-2.5 dark:border-slate-700/60 dark:bg-slate-800/40"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-400">
{formatRupiah(h.amount)}
</p>
<div className="mt-0.5 flex items-center gap-1.5">
<p className="text-[10px] text-slate-400">
{format(new Date(h.created_at), 'dd MMM yyyy, HH:mm', {
locale: idLocale,
})}
</p>
{h.note && (
<>
<span className="text-[10px] text-slate-300">·</span>
<p className="truncate text-[10px] text-slate-500 italic">
"{h.note}"
</p>
</>
)}
</div>
</div>
<button
onClick={() => confirmDelete(h.id, h.amount)}
disabled={deletingId === h.id}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-rose-50 hover:text-rose-500 disabled:opacity-40 dark:text-slate-600 dark:hover:bg-rose-950/40 dark:hover:text-rose-400"
title="Hapus riwayat ini"
>
{deletingId === h.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
))} ))}
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* MODAL PENGEMBALIAN */}
<Dialog open={modalOpen} onOpenChange={(o) => !o && setModalOpen(false)}>
<DialogContent className="overflow-hidden rounded-3xl border-slate-200/90 p-0 shadow-2xl sm:max-w-[440px] dark:border-slate-800">
<div className="flex items-center gap-3 border-b border-[#1B2CC1]/10 bg-[#1B2CC1]/5 p-5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#1B2CC1] text-white shadow-md shadow-[#1B2CC1]/20">
<Banknote className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base font-black text-slate-900 dark:text-white">
Kembalikan Saldo
</DialogTitle>
<p className="text-xs text-slate-500">
Catat pengembalian uang ke {withdrawTarget?.user?.name}
</p>
</div>
</div>
<div className="space-y-4 bg-white p-5 dark:bg-slate-900">
{withdrawTarget && (
<div className="flex items-center gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-800/50">
{withdrawTarget.user.photo ? (
<img
src={withdrawTarget.user.photo}
alt={withdrawTarget.user.name}
className="h-9 w-9 rounded-full object-cover ring-2 ring-white dark:ring-slate-900"
/>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-200 text-sm font-bold text-slate-600 dark:bg-slate-700 dark:text-slate-300">
{withdrawTarget.user.name.charAt(0).toUpperCase()}
</div>
)}
<div>
<p className="text-sm font-bold text-slate-900 dark:text-white">
{withdrawTarget.user.name}
</p>
<p className="text-xs text-slate-500">
Saldo tersedia:{' '}
<span className="font-bold text-rose-600">
{formatRupiah(withdrawTarget?.amount || 0)}
</span>
</p>
</div>
</div>
)}
<div className="space-y-1.5">
<Label className="text-[11px] font-bold tracking-wider text-slate-500 uppercase">
Nominal Pengembalian
</Label>
<div className="relative">
<span className="absolute top-1/2 left-3 -translate-y-1/2 text-xs font-bold text-slate-400">
Rp
</span>
<Input
type="number"
placeholder="0"
value={withdrawAmount}
onChange={(e) => {
setWithdrawAmount(e.target.value)
setModalError('')
}}
className="h-11 pl-8 text-sm font-bold"
min={1}
max={withdrawTarget?.amount}
/>
</div>
{withdrawTarget && (
<div className="flex gap-2 pt-1">
<button
onClick={() => setWithdrawAmount(String(withdrawTarget.amount))}
className="rounded-lg border border-[#1B2CC1]/20 bg-[#1B2CC1]/5 px-2.5 py-1 text-[10px] font-bold text-[#1B2CC1] hover:bg-[#1B2CC1]/10 dark:border-[#1B2CC1]/30"
>
Semua ({formatRupiah(withdrawTarget.amount)})
</button>
{withdrawTarget.amount >= 2 && (
<button
onClick={() =>
setWithdrawAmount(String(Math.floor(withdrawTarget.amount / 2)))
}
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1 text-[10px] font-bold text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400"
>
Setengah ({formatRupiah(Math.floor(withdrawTarget.amount / 2))})
</button>
)}
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-bold tracking-wider text-slate-500 uppercase">
Catatan <span className="font-normal text-slate-400 normal-case">(opsional)</span>
</Label>
<Input
placeholder="Contoh: Transfer BCA, Cash langsung, dll"
value={withdrawNote}
onChange={(e) => setWithdrawNote(e.target.value)}
className="h-10 text-sm"
maxLength={100}
/>
</div>
{modalError && (
<div className="flex items-start gap-2 rounded-xl border border-rose-200 bg-rose-50 p-3 dark:border-rose-800 dark:bg-rose-950/30">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-rose-500" />
<p className="text-xs font-semibold text-rose-600 dark:text-rose-400">
{modalError}
</p>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
onClick={() => setModalOpen(false)}
disabled={saving}
className="h-10 flex-1 rounded-xl font-bold"
>
Batal
</Button>
<Button
onClick={handleWithdraw}
disabled={saving || !withdrawAmount}
className="h-10 flex-1 gap-2 rounded-xl bg-[#1B2CC1] font-bold text-white shadow-md shadow-[#1B2CC1]/20 hover:bg-[#15229E] disabled:opacity-60"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Menyimpan...
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" />
Simpan Pengembalian
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* MODAL DELETE CONFIRM */}
<Dialog open={deleteModalOpen} onOpenChange={(o) => !o && setDeleteModalOpen(false)}>
<DialogContent className="overflow-hidden rounded-3xl border-slate-200/90 p-0 shadow-2xl sm:max-w-[400px] dark:border-slate-800">
<div className="flex items-center gap-3 border-b border-rose-100 bg-rose-50 p-5 dark:border-rose-900/50 dark:bg-rose-950/30">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-600 text-white shadow-md shadow-rose-600/20">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base font-black text-slate-900 dark:text-white">
Hapus Riwayat?
</DialogTitle>
<p className="text-xs text-slate-500">Saldo akan kembali bertambah</p>
</div>
</div>
<div className="space-y-4 bg-white p-5 dark:bg-slate-900">
<div className="rounded-xl border border-slate-100 bg-slate-50 p-3 text-center dark:border-slate-800 dark:bg-slate-800/50">
<p className="text-xs text-slate-500">Pengembalian sebesar</p>
<p className="text-lg font-black text-slate-900 dark:text-white">
{formatRupiah(deleteTarget?.amount || 0)}
</p>
<p className="mt-1 text-xs text-slate-500">akan dihapus dari riwayat.</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDeleteModalOpen(false)}
disabled={!!deletingId}
className="h-10 flex-1 rounded-xl font-bold"
>
Batal
</Button>
<Button
onClick={handleDelete}
disabled={!!deletingId}
className="h-10 flex-1 gap-2 rounded-xl bg-rose-600 font-bold text-white hover:bg-rose-700"
>
{deletingId ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Menghapus...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Ya, Hapus
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
) )
} }
+47 -22
View File
@@ -5,30 +5,15 @@ import {
getMyOrders, getMyOrders,
createOrder, createOrder,
updateOrder, updateOrder,
duplicateOrder,
updateOrderStatus, updateOrderStatus,
deleteOrder, deleteOrder,
getSessionUser, getSessionUser,
} from '@/app/actions' } from '@/app/actions'
import { Button, buttonVariants } from '@/components/ui/button' import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ShareButton } from '@/components/ShareButton' import { ShareButton } from '@/components/ShareButton'
import { import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -53,6 +38,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
} from 'lucide-react' } from 'lucide-react'
import PiutangManager from '@/components/PiutangManager'
export default function MyOrdersPage() { export default function MyOrdersPage() {
const [userId, setUserId] = useState<string | null>(null) const [userId, setUserId] = useState<string | null>(null)
@@ -63,6 +49,7 @@ export default function MyOrdersPage() {
const [orderToDelete, setOrderToDelete] = useState<any | null>(null) const [orderToDelete, setOrderToDelete] = useState<any | null>(null)
const [orderToDuplicate, setOrderToDuplicate] = useState<any | null>(null) const [orderToDuplicate, setOrderToDuplicate] = useState<any | null>(null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [mainTab, setMainTab] = useState<'PO' | 'PIUTANG'>('PO')
const [activeTab, setActiveTab] = useState<'ALL' | 'OPEN' | 'DRAFT' | 'CLOSE'>('ALL') const [activeTab, setActiveTab] = useState<'ALL' | 'OPEN' | 'DRAFT' | 'CLOSE'>('ALL')
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -154,6 +141,34 @@ export default function MyOrdersPage() {
return ( return (
<div className="animate-in fade-in space-y-6 pb-12 duration-500"> <div className="animate-in fade-in space-y-6 pb-12 duration-500">
{/* Main Tab Switcher */}
<div className="flex w-full max-w-sm rounded-xl border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-900">
<button
onClick={() => setMainTab('PO')}
className={cn(
'flex-1 rounded-lg py-2 text-xs font-bold transition-all',
mainTab === 'PO'
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-800 dark:text-white'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)}
>
Daftar PO
</button>
<button
onClick={() => setMainTab('PIUTANG')}
className={cn(
'flex-1 rounded-lg py-2 text-xs font-bold transition-all',
mainTab === 'PIUTANG'
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-800 dark:text-white'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)}
>
Kelola Piutang
</button>
</div>
{mainTab === 'PO' ? (
<>
{/* Top Controls & Segmented Control */} {/* Top Controls & Segmented Control */}
<div className="flex flex-col items-start justify-between gap-4 rounded-2xl border border-slate-200/80 bg-white p-4 shadow-sm md:flex-row md:items-center dark:border-slate-800 dark:bg-slate-900"> <div className="flex flex-col items-start justify-between gap-4 rounded-2xl border border-slate-200/80 bg-white p-4 shadow-sm md:flex-row md:items-center dark:border-slate-800 dark:bg-slate-900">
<div className="flex w-full flex-col items-start gap-4 md:w-auto md:flex-row md:items-center"> <div className="flex w-full flex-col items-start gap-4 md:w-auto md:flex-row md:items-center">
@@ -172,7 +187,11 @@ export default function MyOrdersPage() {
> >
{tab === 'ALL' ? 'Semua PO' : tab} {tab === 'ALL' ? 'Semua PO' : tab}
<span className="ml-1.5 text-[10px] opacity-70"> <span className="ml-1.5 text-[10px] opacity-70">
({tab === 'ALL' ? orders.length : orders.filter((o) => o.status === tab).length}) (
{tab === 'ALL'
? orders.length
: orders.filter((o) => o.status === tab).length}
)
</span> </span>
</button> </button>
))} ))}
@@ -386,15 +405,17 @@ export default function MyOrdersPage() {
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-black text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400"> <span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-black text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400">
Lunas:{' '} Lunas:{' '}
{ {
order.submissions.filter((s: any) => s.payment_status === 'LUNAS') order.submissions.filter(
.length (s: any) => s.payment_status === 'LUNAS'
).length
} }
</span> </span>
<span className="rounded bg-rose-50 px-1.5 py-0.5 text-[10px] font-black text-rose-700 dark:bg-rose-950/50 dark:text-rose-400"> <span className="rounded bg-rose-50 px-1.5 py-0.5 text-[10px] font-black text-rose-700 dark:bg-rose-950/50 dark:text-rose-400">
Belum:{' '} Belum:{' '}
{ {
order.submissions.filter((s: any) => s.payment_status !== 'LUNAS') order.submissions.filter(
.length (s: any) => s.payment_status !== 'LUNAS'
).length
} }
</span> </span>
</div> </div>
@@ -618,6 +639,10 @@ export default function MyOrdersPage() {
)} )}
</div> </div>
)} )}
</>
) : (
<PiutangManager creatorId={userId!} />
)}
</div> </div>
) )
} }
+9 -8
View File
@@ -436,14 +436,15 @@ export default function MyPurchasesPage() {
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center gap-1.5 rounded-xl bg-slate-100/70 py-2 text-[11px] font-medium text-slate-400 dark:bg-slate-800/40"> // <div className="flex items-center justify-center gap-1.5 rounded-xl bg-slate-100/70 py-2 text-[11px] font-medium text-slate-400 dark:bg-slate-800/40">
<Lock className="h-3 w-3" /> // <Lock className="h-3 w-3" />
<span className="text-center leading-tight"> // <span className="text-center leading-tight">
PO ditutup // PO ditutup
<br /> // <br />
(pesanan terkunci) // (pesanan terkunci)
</span> // </span>
</div> // </div>
<div></div>
)} )}
</div> </div>
</div> </div>
+83 -5
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { getSessionUser, updateProfile, changePassword } from '@/app/actions' import { getSessionUser, updateProfile, changePassword, getSettings, getUserNotificationConfig, updateUserNotificationConfig } from '@/app/actions'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -21,6 +21,7 @@ import {
UserCircle, UserCircle,
KeyRound, KeyRound,
LockKeyhole, LockKeyhole,
MessageCircle,
} from 'lucide-react' } from 'lucide-react'
export default function ProfilePage() { export default function ProfilePage() {
@@ -29,6 +30,13 @@ export default function ProfilePage() {
const [photo, setPhoto] = useState('') const [photo, setPhoto] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Notification Config state
const [globalNotifEnabled, setGlobalNotifEnabled] = useState(false)
const [mmChannelId, setMmChannelId] = useState('')
const [mmActive, setMmActive] = useState(true)
const [savingNotif, setSavingNotif] = useState(false)
const [notifSuccess, setNotifSuccess] = useState(false)
// Profile state // Profile state
const [savingProfile, setSavingProfile] = useState(false) const [savingProfile, setSavingProfile] = useState(false)
const [profileError, setProfileError] = useState('') const [profileError, setProfileError] = useState('')
@@ -54,6 +62,20 @@ export default function ProfilePage() {
setUser(sessionUser) setUser(sessionUser)
setName(sessionUser.name) setName(sessionUser.name)
setPhoto(sessionUser.photo || '') setPhoto(sessionUser.photo || '')
const [globalSettings, notifConfig] = await Promise.all([
getSettings(),
getUserNotificationConfig(sessionUser.id)
])
if (globalSettings?.MATTERMOST_NOTIF_ENABLED === 'true') {
setGlobalNotifEnabled(true)
}
if (notifConfig?.success && notifConfig.config) {
setMmChannelId(notifConfig.config.mattermost_channel_id || '')
setMmActive(notifConfig.config.is_active ?? true)
}
} }
setLoading(false) setLoading(false)
} }
@@ -69,14 +91,20 @@ export default function ProfilePage() {
setProfileSuccess(false) setProfileSuccess(false)
setSavingProfile(true) setSavingProfile(true)
const res = await updateProfile(user.id, name.trim(), photo.trim() || null) const [res, notifRes] = await Promise.all([
updateProfile(user.id, name.trim(), photo.trim() || null),
globalNotifEnabled ? updateUserNotificationConfig(user.id, {
mattermost_channel_id: mmChannelId,
is_active: mmActive
}) : Promise.resolve({ success: true, error: null })
])
if (res.success) { if (res.success && notifRes.success) {
setUser(res.user) setUser(res.user)
setProfileSuccess(true) setProfileSuccess(true)
setTimeout(() => setProfileSuccess(false), 3000) setTimeout(() => setProfileSuccess(false), 3000)
} else { } else {
setProfileError(res.error || 'Terjadi kesalahan') setProfileError(res.error || notifRes.error || 'Terjadi kesalahan')
} }
setSavingProfile(false) setSavingProfile(false)
} }
@@ -215,6 +243,55 @@ export default function ProfilePage() {
/> />
</div> </div>
{globalNotifEnabled && (
<div className="space-y-6 pt-6 mt-6 border-t border-slate-100 dark:border-slate-800">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400">
<MessageCircle className="h-5 w-5" />
</div>
<div>
<h3 className="text-sm font-bold text-slate-900 dark:text-white">
Notifikasi Mattermost
</h3>
<p className="mt-0.5 text-[11px] text-slate-500">
Terima update pesanan PO langsung di DM Mattermost Anda.
</p>
</div>
</div>
<div className="flex flex-row items-start space-x-3 space-y-0 rounded-md border border-slate-200 p-4 dark:border-slate-800">
<input
type="checkbox"
id="mmActive"
checked={mmActive}
onChange={(e) => setMmActive(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-slate-300 text-[#1B2CC1] focus:ring-[#1B2CC1]"
/>
<div className="space-y-1 leading-none">
<Label htmlFor="mmActive" className="text-sm font-bold">
Aktifkan Notifikasi
</Label>
<p className="text-[11px] text-slate-500">
Kirim notifikasi setiap kali ada yang merubah pesanannya di PO Anda.
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="mm-channel" className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
Username / Channel ID
</Label>
<Input
id="mm-channel"
value={mmChannelId}
onChange={(e) => setMmChannelId(e.target.value)}
placeholder="Contoh: @firman atau 9dpxnitm..."
className="h-11 rounded-xl"
/>
</div>
</div>
)}
{profileError && ( {profileError && (
<div className="rounded-xl bg-red-50 p-3 text-xs font-semibold text-red-600"> <div className="rounded-xl bg-red-50 p-3 text-xs font-semibold text-red-600">
{profileError} {profileError}
@@ -223,7 +300,7 @@ export default function ProfilePage() {
{profileSuccess && ( {profileSuccess && (
<div className="flex items-center gap-2 rounded-xl bg-emerald-50 p-3 text-xs font-bold text-emerald-700"> <div className="flex items-center gap-2 rounded-xl bg-emerald-50 p-3 text-xs font-bold text-emerald-700">
<CheckCircle className="h-4 w-4" /> <CheckCircle className="h-4 w-4" />
<span>Profil berhasil diperbarui!</span> <span>Perubahan berhasil disimpan!</span>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -244,6 +321,7 @@ export default function ProfilePage() {
</div> </div>
</form> </form>
</Card> </Card>
</div> </div>
{/* Right 1 Col: Account Details */} {/* Right 1 Col: Account Details */}
+50 -3
View File
@@ -8,11 +8,15 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
export default function IntegrationsPage() { export default function IntegrationsPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
MATTERMOST_NOTIF_ENABLED: 'false',
MATTERMOST_BOT_TOKEN: '',
MATTERMOST_API_URL: '',
MATTERMOST_WEBHOOK_URL: '', MATTERMOST_WEBHOOK_URL: '',
MATTERMOST_TEMPLATE: '', MATTERMOST_TEMPLATE: '',
WHATSAPP_TEMPLATE: '', WHATSAPP_TEMPLATE: '',
@@ -23,6 +27,9 @@ export default function IntegrationsPage() {
const data = await getSettings() const data = await getSettings()
if (data) { if (data) {
setSettings({ setSettings({
MATTERMOST_NOTIF_ENABLED: data.MATTERMOST_NOTIF_ENABLED || 'false',
MATTERMOST_BOT_TOKEN: data.MATTERMOST_BOT_TOKEN || '',
MATTERMOST_API_URL: data.MATTERMOST_API_URL || '',
MATTERMOST_WEBHOOK_URL: data.MATTERMOST_WEBHOOK_URL || '', MATTERMOST_WEBHOOK_URL: data.MATTERMOST_WEBHOOK_URL || '',
MATTERMOST_TEMPLATE: MATTERMOST_TEMPLATE:
data.MATTERMOST_TEMPLATE || data.MATTERMOST_TEMPLATE ||
@@ -80,14 +87,54 @@ export default function IntegrationsPage() {
<div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50/50 p-5 dark:border-slate-800 dark:bg-slate-900/50"> <div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50/50 p-5 dark:border-slate-800 dark:bg-slate-900/50">
<div className="flex items-center gap-2 font-black text-indigo-600 dark:text-indigo-400"> <div className="flex items-center gap-2 font-black text-indigo-600 dark:text-indigo-400">
<MessageSquare className="h-5 w-5" /> <MessageSquare className="h-5 w-5" />
<h3>Mattermost Webhook</h3> <h3>Konfigurasi Mattermost</h3>
</div> </div>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Gunakan URL Incoming Webhook dari Mattermost untuk mengirim broadcast PO baru secara Atur integrasi Mattermost. Bagian atas untuk Notifikasi Personal (DM) ke Kreator, sedangkan bagian bawah untuk fitur Share (Broadcast) PO menggunakan Webhook.
otomatis.
</p> </p>
<div className="flex flex-row items-start space-x-3 space-y-0 rounded-md border border-slate-200 p-4 dark:border-slate-800 bg-white dark:bg-slate-950">
<Checkbox
id="mattermostNotifEnabled"
checked={settings.MATTERMOST_NOTIF_ENABLED === 'true'}
onCheckedChange={(checked) => handleChange('MATTERMOST_NOTIF_ENABLED', checked ? 'true' : 'false')}
/>
<div className="space-y-1 leading-none w-full">
<Label htmlFor="mattermostNotifEnabled" className="text-sm font-bold">
Aktifkan DM Kreator saat Update Pesanan (Menggunakan Bot API)
</Label>
<p className="text-xs text-slate-500 mb-3">
Kirim notifikasi otomatis ke channel/DM kreator PO tiap ada update pesanan. (Membutuhkan Bot Token).
</p>
{settings.MATTERMOST_NOTIF_ENABLED === 'true' && (
<div className="space-y-4 mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-[11px] font-bold text-slate-700 uppercase">API URL POST</Label>
<Input
value={settings.MATTERMOST_API_URL}
onChange={(e) => handleChange('MATTERMOST_API_URL', e.target.value)}
className="h-10 rounded-xl"
placeholder="https://mattermost.domain.com/api/v4/posts"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-bold text-slate-700 uppercase">Bot Bearer Token</Label>
<Input
type="password"
value={settings.MATTERMOST_BOT_TOKEN}
onChange={(e) => handleChange('MATTERMOST_BOT_TOKEN', e.target.value)}
className="h-10 rounded-xl"
placeholder="Masukan Bearer Token"
/>
</div>
</div>
)}
</div>
</div>
<div className="space-y-1.5 mt-6 pt-6 border-t border-slate-200 dark:border-slate-800">
<h4 className="text-sm font-bold text-slate-800 dark:text-slate-200 mb-3">Broadcast via Incoming Webhook</h4>
<Label className="text-xs font-bold text-slate-700 uppercase">Webhook URL</Label> <Label className="text-xs font-bold text-slate-700 uppercase">Webhook URL</Label>
<Input <Input
value={settings.MATTERMOST_WEBHOOK_URL} value={settings.MATTERMOST_WEBHOOK_URL}
+336 -116
View File
@@ -5,6 +5,7 @@ import { revalidatePath } from 'next/cache'
import { hashPassword, verifyPassword, encrypt, decrypt } from '@/lib/auth' import { hashPassword, verifyPassword, encrypt, decrypt } from '@/lib/auth'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { sendMattermostNotification } from '@/lib/mattermost'
// === AUTHENTICATION ACTIONS === // === AUTHENTICATION ACTIONS ===
export async function loginUser(username: string, password: string) { export async function loginUser(username: string, password: string) {
@@ -271,37 +272,6 @@ export async function createOrder(data: {
} }
} }
export async function duplicateOrder(order_id: string) {
try {
const oldOrder = await prisma.order.findUnique({
where: { id: order_id },
include: { available_items: true },
})
if (!oldOrder) return { success: false, error: 'Order tidak ditemukan' }
const newOrder = await prisma.order.create({
data: {
title: oldOrder.title + ' (Copy)',
description: oldOrder.description,
date: new Date(),
allow_custom: oldOrder.allow_custom,
creator_id: oldOrder.creator_id,
status: 'DRAFT',
available_items: {
create: oldOrder.available_items.map((ai) => ({
name: ai.name,
is_sold_out: ai.is_sold_out,
})),
},
},
})
revalidatePath('/my-orders')
return { success: true, order: newOrder }
} catch (e) {
return { success: false, error: 'Gagal menduplikasi order.' }
}
}
export async function updateOrder( export async function updateOrder(
order_id: string, order_id: string,
data: { data: {
@@ -440,48 +410,6 @@ export async function updateSubmissionPayment(
} }
} }
export async function updateBulkSubmissionPayment(
submission_ids: string[],
payment_status: string,
use_balance: boolean = false
) {
try {
if (payment_status === 'LUNAS') {
const submissions = await prisma.submission.findMany({
where: { id: { in: submission_ids } },
})
const ops = submissions.map((sub) =>
prisma.submission.update({
where: { id: sub.id },
data: {
payment_status,
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)
} else {
await prisma.submission.updateMany({
where: { id: { in: submission_ids } },
data: { payment_status },
})
}
revalidatePath(`/my-orders`)
revalidatePath(`/my-purchases`)
revalidatePath(`/reports`)
revalidatePath(`/balances`)
return { success: true }
} catch (e) {
console.error(e)
return { success: false, error: 'Gagal mengubah status tagihan massal.' }
}
}
export async function processBulkPayment(data: { export async function processBulkPayment(data: {
submission_ids: string[] submission_ids: string[]
cash_amount: number cash_amount: number
@@ -490,6 +418,11 @@ export async function processBulkPayment(data: {
user_id: string user_id: string
}) { }) {
try { try {
const me = await getSessionUser()
if (!me || me.id !== data.creator_id) {
return { success: false, error: 'Unauthorized' }
}
let currentBalance = 0 let currentBalance = 0
if (data.use_balance) { if (data.use_balance) {
const balanceData = await getBalancesAsCreator(data.creator_id) const balanceData = await getBalancesAsCreator(data.creator_id)
@@ -499,7 +432,7 @@ export async function processBulkPayment(data: {
const submissions = await prisma.submission.findMany({ const submissions = await prisma.submission.findMany({
where: { id: { in: data.submission_ids } }, 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) // Sort by order date ascending (oldest first)
@@ -515,11 +448,31 @@ export async function processBulkPayment(data: {
const sub = submissions[i] const sub = submissions[i]
const isLast = i === submissions.length - 1 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 subBill = sub.bill || 0
const prevPaid = sub.paid_amount || 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 // Fully covered -> LUNAS
let balanceToUse = Math.min(remainingBalance, amountToCover) let balanceToUse = Math.min(remainingBalance, amountToCover)
remainingBalance -= balanceToUse remainingBalance -= balanceToUse
@@ -533,44 +486,35 @@ export async function processBulkPayment(data: {
finalPaid += remainingCash finalPaid += remainingCash
remainingCash = 0 remainingCash = 0
} }
let finalSaldoUsed = prevSaldoUsed + balanceToUse
ops.push( ops.push(
prisma.submission.update({ prisma.submission.update({
where: { id: sub.id }, where: { id: sub.id },
data: { payment_status: 'LUNAS', paid_amount: finalPaid }, data: { payment_status: 'LUNAS', paid_amount: finalPaid, saldo_used: finalSaldoUsed },
})
)
} 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 },
}) })
) )
} else if (totalAvailable > 0) { } else if (totalAvailable > 0) {
// Partially covered -> BELUM_BAYAR. Only use cash. // Partially covered -> BELUM_BAYAR
let finalPaid = prevPaid + remainingCash let balanceToUse = Math.min(remainingBalance, totalAvailable)
remainingCash = 0 remainingBalance -= balanceToUse
totalAvailable = remainingBalance // only balance left, which can't be used
let cashToUse = totalAvailable - balanceToUse
remainingCash -= cashToUse
let finalPaid = prevPaid + cashToUse
let finalSaldoUsed = prevSaldoUsed + balanceToUse
totalAvailable = 0
ops.push( ops.push(
prisma.submission.update({ prisma.submission.update({
where: { id: sub.id }, where: { id: sub.id },
data: { payment_status: 'BELUM_BAYAR', paid_amount: finalPaid }, data: {
}) payment_status: 'BELUM_BAYAR',
) paid_amount: finalPaid,
} else { saldo_used: finalSaldoUsed,
// 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' },
}) })
) )
} }
@@ -664,6 +608,10 @@ export async function submitOrder(data: {
}) })
} }
// Trigger notification without awaiting so it doesn't block
notifyCreatorOnSubmission(data.order_id, data.user_id, existing ? 'update' : 'create').catch(console.error)
revalidatePath('/') revalidatePath('/')
revalidatePath('/my-purchases') revalidatePath('/my-purchases')
revalidatePath(`/my-orders/${data.order_id}`) revalidatePath(`/my-orders/${data.order_id}`)
@@ -718,14 +666,16 @@ export async function getCreatorReport(creator_id: string, startDate?: Date, end
// === BALANCE ACTIONS === // === BALANCE ACTIONS ===
export async function getBalancesAsCreator(creator_id: string) { export async function getBalancesAsCreator(creator_id: string) {
const submissions = await prisma.submission.findMany({ const [submissions, withdrawals] = await Promise.all([
where: { prisma.submission.findMany({
order: { creator_id }, where: { order: { creator_id } },
}, include: { user: { select: { id: true, name: true, photo: true } } },
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<string, { user: any; amount: number }>() const balanceMap = new Map<string, { user: any; amount: number }>()
@@ -748,14 +698,22 @@ 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) return Array.from(balanceMap.values()).filter((b) => b.amount !== 0)
} }
export async function getBalancesAsSubmittor(user_id: string) { export async function getBalancesAsSubmittor(user_id: string) {
const submissions = await prisma.submission.findMany({ const [submissions, withdrawals] = await Promise.all([
where: { prisma.submission.findMany({
user_id, where: { user_id },
},
include: { include: {
order: { order: {
include: { include: {
@@ -763,7 +721,12 @@ export async function getBalancesAsSubmittor(user_id: string) {
}, },
}, },
}, },
}) }),
prisma.balanceWithdrawal.findMany({
where: { user_id },
include: { creator: { select: { id: true, name: true, photo: true } } },
}),
])
const balanceMap = new Map<string, { creator: any; amount: number }>() const balanceMap = new Map<string, { creator: any; amount: number }>()
@@ -787,9 +750,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) 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) { export async function deleteSubmission(submission_id: string, user_id: string) {
try { try {
const submission = await prisma.submission.findUnique({ const submission = await prisma.submission.findUnique({
@@ -805,6 +858,10 @@ export async function deleteSubmission(submission_id: string, user_id: string) {
await prisma.submission.delete({ await prisma.submission.delete({
where: { id: submission_id }, where: { id: submission_id },
}) })
// Trigger notification
notifyCreatorOnSubmission(submission.order_id, user_id, 'delete').catch(console.error)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
return { success: false, error: 'Gagal membatalkan pesanan' } return { success: false, error: 'Gagal membatalkan pesanan' }
@@ -873,3 +930,166 @@ export async function broadcastToMattermost(orderId: string, orderUrl: string) {
return { success: false, error: 'Gagal mengirim ke Mattermost' } 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<string, any>()
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)
}
// === SETTINGS ACTIONS ===
export async function getSystemSettings() {
try {
const settings = await prisma.setting.findMany()
const config: Record<string, string> = {}
settings.forEach((s: any) => { config[s.key] = s.value })
return { success: true, config }
} catch (e: any) {
return { success: false, error: e.message }
}
}
export async function updateSystemSettings(key: string, value: string) {
try {
await prisma.setting.upsert({
where: { key },
update: { value },
create: { key, value }
})
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
// === USER NOTIFICATION CONFIG ACTIONS ===
export async function getUserNotificationConfig(userId: string) {
try {
const config = await prisma.userNotificationConfig.findUnique({
where: { user_id: userId }
})
return { success: true, config }
} catch (e: any) {
return { success: false, error: e.message }
}
}
export async function updateUserNotificationConfig(userId: string, data: { mattermost_channel_id?: string, is_active?: boolean }) {
try {
const config = await prisma.userNotificationConfig.upsert({
where: { user_id: userId },
update: data,
create: { user_id: userId, ...data }
})
return { success: true, config }
} catch (e: any) {
return { success: false, error: e.message }
}
}
export async function notifyCreatorOnSubmission(orderId: string, submittorId: string, actionType: 'create' | 'update' | 'delete' = 'update') {
try {
const settings = await getSettings()
if (!settings || settings.MATTERMOST_NOTIF_ENABLED !== 'true') return
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
creator: {
include: { notification_config: true }
},
submissions: {
include: { user: true, items: true }
}
}
})
if (!order) return
const config = order.creator.notification_config
if (!config?.is_active || !config?.mattermost_channel_id) return
const submittor = await prisma.user.findUnique({ where: { id: submittorId } })
if (!submittor) return
const actionText = actionType === 'delete' ? 'membatalkan pesanan' : actionType === 'create' ? 'menambahkan pesanan baru' : 'merubah pesanan'
const headerMessage = `Ada yang ${actionText} dari **${submittor.name}** di PO **${order.title}**!`
let summaryByPerson = `**Rekap per Orang:**\n\`\`\`text\n${order.title}\n`
const itemCounts: Record<string, number> = {}
order.submissions?.forEach((sub: any) => {
const itemStrings = sub.items.map(
(i: any) => `${i.name} ${i.qty}x${i.note ? ` (${i.note})` : ''}`
)
summaryByPerson += `- ${sub.user?.name || 'Unknown'} : ${itemStrings.join(', ')}\n`
sub.items.forEach((i: any) => {
const key = i.note ? `${i.name} (${i.note})` : i.name
itemCounts[key] = (itemCounts[key] || 0) + i.qty
})
})
summaryByPerson += `\`\`\``
let summaryByItem = `**Rekap per Item (Akumulasi):**\n\`\`\`text\n${order.title}\n`
Object.entries(itemCounts).forEach(([name, qty]) => {
summaryByItem += `- ${name} : ${qty} pcs\n`
})
summaryByItem += `\`\`\``
const finalMessage = `${headerMessage}\n\n${summaryByPerson}\n\n${summaryByItem}`
await sendMattermostNotification(
config.mattermost_channel_id,
finalMessage,
settings.MATTERMOST_BOT_TOKEN,
settings.MATTERMOST_API_URL
)
} catch (error) {
console.error('Failed to notify creator:', error)
}
}
+366
View File
@@ -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<any[]>([])
const [balancesData, setBalancesData] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({})
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<string>('')
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 (
<div className="flex min-h-[30vh] flex-col items-center justify-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-[#1B2CC1]" />
<span className="text-xs font-semibold text-slate-500">Memuat data piutang...</span>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 rounded-xl border border-slate-200/80 bg-white px-3 py-2 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<Search className="h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Cari nama penitip..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-slate-400 dark:text-white"
/>
</div>
{filteredData.length === 0 ? (
<div className="rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="text-sm font-medium text-slate-500">
{searchQuery
? 'Tidak ada penitip yang cocok dengan pencarian.'
: 'Tidak ada penitip yang memiliki hutang pada PO yang sudah CLOSE.'}
</p>
</div>
) : (
<Card className="overflow-hidden border-slate-200/80 shadow-sm dark:border-slate-800">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-xs font-bold tracking-wider text-slate-500 uppercase dark:bg-slate-800/50">
<tr>
<th className="px-6 py-4">Penitip</th>
<th className="px-6 py-4 text-right">Total Piutang</th>
<th className="px-6 py-4 text-center">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/60">
{filteredData.map((userObj) => {
const isExpanded = !!expandedRows[userObj.user.id]
return (
<React.Fragment key={userObj.user.id}>
<tr
onClick={() => 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"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{userObj.user.photo ? (
<img
src={userObj.user.photo}
alt={userObj.user.name}
className="h-10 w-10 rounded-full object-cover shadow-sm ring-2 ring-white dark:ring-slate-800"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-100 font-bold text-slate-600 shadow-sm ring-2 ring-white dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-900">
{userObj.user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="flex flex-col">
<span className="font-bold text-slate-900 dark:text-slate-100">
{userObj.user.name}
</span>
<span className="text-[10px] font-semibold text-slate-400 uppercase">
{userObj.submissions.length} Pesanan Belum Lunas
</span>
</div>
<div className="ml-auto text-slate-400 transition-colors group-hover:text-[#1B2CC1]">
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</div>
</div>
</td>
<td className="px-6 py-4 text-right">
<span className="text-base font-black text-rose-600 dark:text-rose-400">
{formatRupiah(userObj.totalPiutang)}
</span>
</td>
<td className="px-6 py-4 text-center">
<Button
onClick={(e) =>
openModal(
e,
userObj.submissions.map((s: any) => s.id),
`Semua tagihan ${userObj.user.name}`,
userObj.totalPiutang,
userObj.user.id
)
}
size="sm"
className="h-8 gap-1 bg-[#1B2CC1] px-3 text-[11px] font-bold text-white shadow-sm shadow-[#1B2CC1]/30 hover:bg-[#121E85]"
>
<CheckCircle2 className="h-3.5 w-3.5" /> Pelunasan
</Button>
</td>
</tr>
{isExpanded && (
<tr className="bg-slate-50 dark:bg-slate-900/60">
<td colSpan={3} className="border-l-4 border-l-[#1B2CC1] px-6 py-5">
<div className="space-y-3 pl-8">
<h4 className="flex items-center gap-1.5 text-[11px] font-bold tracking-wider text-slate-500 uppercase">
<Package className="h-3.5 w-3.5" /> Rincian Piutang per PO
</h4>
<div className="grid gap-2 sm:grid-cols-2">
{userObj.submissions.map((sub: any) => (
<div
key={sub.id}
className="flex items-center justify-between rounded-xl border border-slate-200/60 bg-white p-3 shadow-sm dark:border-slate-700/50 dark:bg-slate-800"
>
<div className="flex flex-col">
<span className="line-clamp-1 text-xs font-bold text-slate-800 dark:text-slate-200">
{sub.order.title}
</span>
<span className="text-[10px] text-slate-400">
PO: {format(new Date(sub.order.date), 'dd MMM yyyy')}
</span>
</div>
<div className="text-right">
<span className="text-sm font-bold text-rose-600 dark:text-rose-400">
{formatRupiah(sub.unpaidAmount)}
</span>
</div>
</div>
))}
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
</Card>
)}
{/* Modal Konfirmasi Pelunasan */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="overflow-hidden rounded-3xl border-0 p-0 shadow-2xl sm:max-w-[400px]">
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#0A1259] px-6 pt-8 pb-12 text-center text-white">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-white/10 ring-4 ring-white/5 backdrop-blur-md">
<CheckCircle2 className="h-8 w-8 text-white" />
</div>
<DialogTitle className="mt-5 text-2xl font-black tracking-tight text-white">
Pelunasan Tagihan
</DialogTitle>
<DialogDescription className="mt-1.5 text-blue-100/80">
Terima uang dari <strong>{target.label.replace('Semua tagihan ', '')}</strong>
</DialogDescription>
</div>
<div className="relative z-10 -mt-10 space-y-6 rounded-t-3xl bg-white px-6 pt-6 pb-6 shadow-[-0_-10px_40px_rgba(0,0,0,0.1)] dark:bg-slate-950">
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-5 text-center shadow-inner dark:border-rose-900/30 dark:bg-rose-950/40">
<span className="text-[10px] font-bold tracking-widest text-rose-400 uppercase">
Total Tagihan Saat Ini
</span>
<p className="mt-1 text-4xl font-black tracking-tight text-rose-600 dark:text-rose-500">
{formatRupiah(target.amount)}
</p>
</div>
<div className="space-y-3">
<label className="text-xs font-bold tracking-wider text-slate-500 uppercase dark:text-slate-400">
Uang Tunai Diterima
</label>
<div className="relative">
<span className="absolute top-3.5 left-4 text-sm font-bold text-slate-400">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="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"
/>
</div>
{targetUserBalance > 0 && (
<div className="flex items-center justify-between rounded-xl border border-emerald-100 bg-emerald-50/50 px-4 py-3 dark:border-emerald-900/30 dark:bg-emerald-900/20">
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400">
+ Pakai Saldo Penitip
</span>
<span className="text-sm font-black text-emerald-600 dark:text-emerald-500">
{formatRupiah(targetUserBalance)}
</span>
</div>
)}
</div>
{(() => {
const totalBayar = (Number(cashInput.replace(/\D/g, '')) || 0) + targetUserBalance
const kurang = target.amount - totalBayar
if (totalBayar === 0) return null
return (
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700/50 dark:bg-slate-800/50">
<p className="text-[10px] font-bold tracking-wider text-slate-500 uppercase">
Kalkulasi Akhir
</p>
</div>
<div className="p-4">
{kurang > 0 ? (
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-rose-500">Sisa Hutang Nanti:</span>
<span className="text-lg font-black text-rose-600">
{formatRupiah(kurang)}
</span>
</div>
) : kurang < 0 ? (
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-emerald-600">
Kelebihan (Jadi Saldo):
</span>
<span className="text-lg font-black text-emerald-500">
{formatRupiah(Math.abs(kurang))}
</span>
</div>
) : (
<div className="flex items-center justify-center gap-2 py-1 font-black text-emerald-600">
<CheckCircle2 className="h-5 w-5" /> LUNAS SEMPURNA
</div>
)}
</div>
</div>
)
})()}
</div>
<div className="flex flex-row gap-3 px-6 pt-2 pb-6">
<Button
variant="outline"
onClick={() => setModalOpen(false)}
className="h-12 flex-1 rounded-2xl border-slate-200 font-bold hover:bg-slate-100 dark:border-slate-700"
disabled={saving}
>
Batal
</Button>
<Button
onClick={handleConfirm}
disabled={
saving || (Number(cashInput.replace(/\D/g, '')) || 0) + targetUserBalance <= 0
}
className="h-12 flex-1 rounded-2xl bg-[#1B2CC1] font-bold text-white shadow-lg shadow-[#1B2CC1]/30 hover:bg-[#121E85] disabled:opacity-50"
>
{saving ? 'Memproses...' : 'Proses Pelunasan'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
export async function sendMattermostNotification(targetIdentifier: string, message: string, botToken?: string, apiUrl?: string) {
try {
const token = botToken || process.env.MATTERMOST_BOT_TOKEN || '5zubexudb38uuradfa36qy98ca'
// Ensure we get the base URL by stripping '/posts' if it exists in the configured URL
let baseUrl = apiUrl || process.env.MATTERMOST_API_URL || 'https://mattermost.eigen.co.id/api/v4/posts'
if (baseUrl.endsWith('/posts')) {
baseUrl = baseUrl.replace('/posts', '')
}
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}
let finalChannelId = targetIdentifier
let targetUserId = targetIdentifier
// 1. If it's a username (starts with @ or doesn't look like a 26-char ID), look up the User ID
if (targetIdentifier.startsWith('@') || targetIdentifier.length !== 26) {
const username = targetIdentifier.replace('@', '').trim()
const userRes = await fetch(`${baseUrl}/users/usernames`, {
method: 'POST',
headers,
body: JSON.stringify([username]),
})
if (userRes.ok) {
const users = await userRes.json()
if (users && users.length > 0) {
targetUserId = users[0].id
}
}
}
// 2. Try to create a DM channel if we have a valid 26-char User ID
if (targetUserId.length === 26) {
// Fetch Bot's own User ID
const meRes = await fetch(`${baseUrl}/users/me`, { headers })
if (meRes.ok) {
const me = await meRes.json()
const botId = me.id
// Create Direct Message channel between Bot and Target User
const dmRes = await fetch(`${baseUrl}/channels/direct`, {
method: 'POST',
headers,
body: JSON.stringify([botId, targetUserId]),
})
if (dmRes.ok) {
const dmChannel = await dmRes.json()
finalChannelId = dmChannel.id
}
}
}
// 3. Post the message to the final resolved channel ID
const res = await fetch(`${baseUrl}/posts`, {
method: 'POST',
headers,
body: JSON.stringify({
channel_id: finalChannelId,
message,
}),
})
if (!res.ok) {
const errorText = await res.text()
console.error(`[Mattermost Error] Failed to send message to ${targetIdentifier}:`, errorText)
return { success: false, error: errorText }
}
return { success: true }
} catch (error: any) {
console.error(`[Mattermost Exception] Failed to send message to ${targetIdentifier}:`, error.message)
return { success: false, error: error.message }
}
}