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 -->
+37 -11
View File
@@ -8,17 +8,20 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
username String @unique username String @unique
name String name String
password String password String
photo String? photo String?
role String @default("user") role String @default("user")
is_active Boolean @default(true) is_active Boolean @default(true)
created_at DateTime @default(now()) created_at DateTime @default(now())
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)
}
+655 -102
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
key={i}
className="overflow-hidden rounded-2xl border-slate-200/90 shadow-sm transition-all hover:border-[#1B2CC1]/30 dark:border-slate-800" return (
> <div
<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"> key={i}
{b.creator.photo ? ( className={cn(
<img 'overflow-hidden rounded-2xl border bg-white shadow-sm dark:bg-slate-900',
src={b.creator.photo} isPositive
alt={b.creator.name} ? 'border-emerald-100 dark:border-emerald-900/40'
className="h-10 w-10 rounded-full object-cover shadow-sm ring-2 ring-white dark:ring-slate-900" : 'border-rose-100 dark:border-rose-900/40'
/>
) : (
<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">
{b.creator.name.charAt(0).toUpperCase()}
</div>
)} )}
<div> >
<p className="text-sm font-bold text-slate-900 dark:text-white"> <div className="flex items-stretch">
{b.creator.name} {/* Left accent strip */}
</p>
<p className="text-[10px] font-semibold tracking-wider text-slate-500 uppercase">
Kreator
</p>
</div>
</div>
<CardContent className="bg-white p-4 dark:bg-slate-900">
<div className="flex flex-col gap-1">
<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', 'w-1 shrink-0',
b.amount > 0 ? 'text-emerald-600' : 'text-rose-600' isPositive ? 'bg-emerald-400' : 'bg-rose-400'
)} )}
> />
{b.amount > 0 ? (
<ArrowUpRight className="h-5 w-5" /> <div className="flex flex-1 flex-col gap-0">
) : ( {/* Main info row */}
<ArrowDownRight className="h-5 w-5" /> <div className="flex items-center justify-between gap-4 px-4 py-4">
)} {/* Avatar + name */}
{formatRupiah(b.amount)} <div className="flex min-w-0 items-center gap-3">
<div className="relative shrink-0">
{b.creator.photo ? (
<img
src={b.creator.photo}
alt={b.creator.name}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<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()}
</div>
)}
{/* Status dot */}
<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}
</p>
<p className="text-[10px] font-medium text-slate-400">Kreator</p>
</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>
</div>
</div>
{/* Footer */}
<div
className={cn(
'flex items-center justify-between gap-2 border-t px-4 py-2.5',
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'
)}
>
<span
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-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>
)}
</div>
</div> </div>
</div> </div>
</CardContent> </div>
</Card> )
))} })}
</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
key={i} const isExpanded = expandedHistory[uid]
className="overflow-hidden rounded-2xl border-slate-200/90 shadow-sm transition-all hover:border-[#1B2CC1]/30 dark:border-slate-800" const history = historyMap[uid] || []
> const isLoadingHist = loadingHistory[uid]
<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"> const isPositive = b.amount > 0
{b.user.photo ? (
<img return (
src={b.user.photo} <div
alt={b.user.name} key={i}
className="h-10 w-10 rounded-full object-cover shadow-sm ring-2 ring-white dark:ring-slate-900" className={cn(
/> 'overflow-hidden rounded-2xl border bg-white shadow-sm dark:bg-slate-900',
) : ( isPositive
<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"> ? 'border-rose-100 dark:border-rose-900/40'
{b.user.name.charAt(0).toUpperCase()} : 'border-emerald-100 dark:border-emerald-900/40'
</div>
)} )}
<div> >
<p className="text-sm font-bold text-slate-900 dark:text-white"> <div className="flex items-stretch">
{b.user.name} {/* Left accent strip */}
</p>
<p className="text-[10px] font-semibold tracking-wider text-slate-500 uppercase">
Penitip
</p>
</div>
</div>
<CardContent className="bg-white p-4 dark:bg-slate-900">
<div className="flex flex-col gap-1">
<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', 'w-1 shrink-0',
b.amount > 0 ? 'text-rose-600' : 'text-emerald-600' isPositive ? 'bg-rose-400' : 'bg-emerald-400'
)} )}
> />
{b.amount > 0 ? (
<ArrowDownRight className="h-5 w-5" /> <div className="flex flex-1 flex-col">
) : ( {/* Main info row */}
<ArrowUpRight className="h-5 w-5" /> <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 ? (
<img
src={b.user.photo}
alt={b.user.name}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<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()}
</div>
)}
<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-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}
</p>
<p className="text-[10px] font-medium text-slate-400">Penitip</p>
</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>
</div>
</div>
{/* Footer row */}
<div
className={cn(
'flex items-center justify-between gap-2 border-t px-4 py-2.5',
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'
)}
>
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-bold',
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'
)}
>
{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>
{/* 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>
)} )}
{formatRupiah(b.amount)}
</div> </div>
</div> </div>
</CardContent> </div>
</Card> )
))} })}
</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> </div>
) )
} }
+481 -456
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,469 +141,507 @@ 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">
{/* Top Controls & Segmented Control */} {/* Main Tab Switcher */}
<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 max-w-sm rounded-xl border border-slate-200 bg-slate-100 p-1 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"> <button
{/* Segmented Control / Tabs */} onClick={() => setMainTab('PO')}
<div className="flex w-full items-center overflow-x-auto rounded-xl bg-slate-100 p-1 text-xs font-bold md:w-auto dark:bg-slate-800/80"> className={cn(
{(['ALL', 'OPEN', 'DRAFT', 'CLOSE'] as const).map((tab) => ( 'flex-1 rounded-lg py-2 text-xs font-bold transition-all',
<button mainTab === 'PO'
key={tab} ? 'bg-white text-slate-900 shadow-sm dark:bg-slate-800 dark:text-white'
onClick={() => setActiveTab(tab)} : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
className={cn( )}
'rounded-lg px-4 py-2 whitespace-nowrap transition-all duration-200', >
activeTab === tab Daftar PO
? 'bg-white font-black text-[#1B2CC1] shadow-sm dark:bg-slate-900 dark:text-white' </button>
: 'text-slate-500 hover:text-slate-900 dark:hover:text-white' <button
)} onClick={() => setMainTab('PIUTANG')}
> className={cn(
{tab === 'ALL' ? 'Semua PO' : tab} 'flex-1 rounded-lg py-2 text-xs font-bold transition-all',
<span className="ml-1.5 text-[10px] opacity-70"> mainTab === 'PIUTANG'
({tab === 'ALL' ? orders.length : orders.filter((o) => o.status === tab).length}) ? 'bg-white text-slate-900 shadow-sm dark:bg-slate-800 dark:text-white'
</span> : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
</button> )}
))} >
</div> Kelola Piutang
</button>
{/* Search Input */}
<div className="relative w-full md:w-64">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-4 w-4 text-slate-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
/>
</svg>
</div>
<Input
type="text"
placeholder="Cari nama PO..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-10 w-full rounded-xl border-slate-200 bg-slate-50 pl-9 text-sm focus-visible:ring-[#1B2CC1] dark:border-slate-800 dark:bg-slate-900"
/>
</div>
</div>
{/* Action Button */}
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger
className={cn(
buttonVariants({ size: 'lg' }),
'h-10 w-full cursor-pointer gap-2 rounded-xl bg-[#1B2CC1] font-bold text-white shadow-md shadow-[#1B2CC1]/20 hover:bg-[#15229E] md:w-auto'
)}
>
<Plus className="h-4 w-4" /> Buka Jasa PO Baru
</DialogTrigger>
<CreateOrderModal
userId={userId!}
onSuccess={() => {
setIsCreateOpen(false)
if (userId) loadOrders(userId)
}}
/>
</Dialog>
</div> </div>
{/* Edit Order Modal */} {mainTab === 'PO' ? (
{editingOrder && ( <>
<Dialog open={!!editingOrder} onOpenChange={(open) => !open && setEditingOrder(null)}> {/* Top Controls & Segmented Control */}
<EditOrderModal <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">
order={editingOrder} <div className="flex w-full flex-col items-start gap-4 md:w-auto md:flex-row md:items-center">
onClose={() => setEditingOrder(null)} {/* Segmented Control / Tabs */}
onSuccess={() => { <div className="flex w-full items-center overflow-x-auto rounded-xl bg-slate-100 p-1 text-xs font-bold md:w-auto dark:bg-slate-800/80">
setEditingOrder(null) {(['ALL', 'OPEN', 'DRAFT', 'CLOSE'] as const).map((tab) => (
if (userId) loadOrders(userId) <button
}} key={tab}
/> onClick={() => setActiveTab(tab)}
</Dialog> className={cn(
)} 'rounded-lg px-4 py-2 whitespace-nowrap transition-all duration-200',
activeTab === tab
{/* Duplicate Order Modal */} ? 'bg-white font-black text-[#1B2CC1] shadow-sm dark:bg-slate-900 dark:text-white'
{orderToDuplicate && ( : 'text-slate-500 hover:text-slate-900 dark:hover:text-white'
<Dialog )}
open={!!orderToDuplicate} >
onOpenChange={(open) => !open && setOrderToDuplicate(null)} {tab === 'ALL' ? 'Semua PO' : tab}
> <span className="ml-1.5 text-[10px] opacity-70">
<CreateOrderModal (
userId={userId!} {tab === 'ALL'
initialData={orderToDuplicate} ? orders.length
onSuccess={() => { : orders.filter((o) => o.status === tab).length}
setOrderToDuplicate(null) )
if (userId) loadOrders(userId) </span>
}} </button>
/> ))}
</Dialog>
)}
{/* Delete Confirmation Modal */}
{orderToDelete && (
<Dialog open={!!orderToDelete} onOpenChange={(open) => !open && setOrderToDelete(null)}>
<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-red-100 bg-red-50 p-6 dark:border-red-900/50 dark:bg-red-950/30">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-600 text-white shadow-md shadow-red-600/20">
<AlertTriangle className="h-5 w-5" />
</div> </div>
<div>
<DialogTitle className="text-lg font-black text-slate-900 dark:text-white"> {/* Search Input */}
Hapus Jasa PO Ini? <div className="relative w-full md:w-64">
</DialogTitle> <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<p className="mt-0.5 text-xs text-slate-500"> <svg
Tindakan ini permanen dan tidak dapat dibatalkan. className="h-4 w-4 text-slate-400"
</p> xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
/>
</svg>
</div>
<Input
type="text"
placeholder="Cari nama PO..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-10 w-full rounded-xl border-slate-200 bg-slate-50 pl-9 text-sm focus-visible:ring-[#1B2CC1] dark:border-slate-800 dark:bg-slate-900"
/>
</div> </div>
</div> </div>
<div className="space-y-4 bg-white p-6 dark:bg-slate-900"> {/* Action Button */}
<div className="rounded-xl border border-slate-200/80 bg-slate-50 p-3.5 text-xs dark:border-slate-700 dark:bg-slate-800/50"> <Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<p className="mb-1 font-bold text-slate-800 dark:text-slate-200"> <DialogTrigger
{orderToDelete.title} className={cn(
</p> buttonVariants({ size: 'lg' }),
<p className="text-[11px] text-slate-500"> 'h-10 w-full cursor-pointer gap-2 rounded-xl bg-[#1B2CC1] font-bold text-white shadow-md shadow-[#1B2CC1]/20 hover:bg-[#15229E] md:w-auto'
Status:{' '} )}
<span className="font-bold text-slate-700 dark:text-slate-300"> >
{orderToDelete.status} <Plus className="h-4 w-4" /> Buka Jasa PO Baru
</span>{' '} </DialogTrigger>
• {orderToDelete.submissions?.length || 0} titipan pemesan <CreateOrderModal
</p> userId={userId!}
</div> onSuccess={() => {
setIsCreateOpen(false)
<p className="text-xs leading-relaxed text-slate-600 dark:text-slate-400"> if (userId) loadOrders(userId)
Seluruh data pesanan dan daftar menu yang ada di dalam PO ini akan dihapus dari }}
sistem. />
</p> </Dialog>
<div className="flex gap-2.5 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setOrderToDelete(null)}
disabled={deleting}
className="h-10 w-1/2 rounded-xl text-xs font-bold"
>
Batal
</Button>
<Button
type="button"
onClick={handleDelete}
disabled={deleting}
className="h-10 w-1/2 gap-1.5 rounded-xl bg-red-600 text-xs font-bold text-white shadow-md shadow-red-600/20 hover:bg-red-700"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
<span>{deleting ? 'Menghapus...' : 'Ya, Hapus PO'}</span>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* Order List */}
{filteredOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-3xl border-2 border-dashed border-slate-200 bg-white p-16 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[#1B2CC1]/10 text-[#1B2CC1]">
<Layers className="h-8 w-8" />
</div> </div>
<h3 className="mb-1 text-lg font-bold text-slate-800 dark:text-slate-200">
{activeTab === 'ALL' ? 'Belum Ada Jasa PO' : `Tidak Ada PO Berstatus ${activeTab}`}
</h3>
<p className="mb-6 max-w-sm text-xs leading-relaxed text-slate-500">
Mulai buka jasa titip pesanan makanan atau kebutuhan untuk teman kantor.
</p>
<Button
onClick={() => setIsCreateOpen(true)}
className="h-10 gap-2 rounded-xl bg-[#1B2CC1] font-bold text-white shadow-md shadow-[#1B2CC1]/20 hover:bg-[#15229E]"
>
<Plus className="h-4 w-4" /> Buat PO Baru
</Button>
</div>
) : (
<div className="flex h-full flex-col space-y-4">
<div className="flex max-h-[calc(100vh-260px)] scrollbar-thin flex-col space-y-4 overflow-y-auto pr-2">
{paginatedOrders.map((order) => {
const date = new Date(order.date)
const isClosed = order.status === 'CLOSE'
const isDraft = order.status === 'DRAFT'
const canDelete = isDraft || isClosed
return ( {/* Edit Order Modal */}
<div {editingOrder && (
key={order.id} <Dialog open={!!editingOrder} onOpenChange={(open) => !open && setEditingOrder(null)}>
className="flex shrink-0 flex-col overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-sm transition-all hover:border-[#1B2CC1]/40 hover:shadow-md md:flex-row dark:border-slate-800 dark:bg-slate-900" <EditOrderModal
> order={editingOrder}
{/* Left: Info */} onClose={() => setEditingOrder(null)}
<div className="flex w-full flex-col justify-center border-slate-100 p-5 md:flex-1 md:border-r dark:border-slate-800/80"> onSuccess={() => {
<div className="flex items-start justify-between gap-4"> setEditingOrder(null)
<div className="min-w-0 flex-1"> if (userId) loadOrders(userId)
<h3 className="line-clamp-2 text-xl leading-snug font-bold text-slate-900 dark:text-white"> }}
{order.title} />
</h3> </Dialog>
{order.description && ( )}
<p className="mt-1 line-clamp-2 text-xs font-medium text-slate-500">
{order.description}
</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
<span className="rounded-full bg-slate-100/80 px-2.5 py-1 text-[11px] font-semibold whitespace-nowrap text-slate-500 dark:bg-slate-800">
{format(date, 'EEEE, dd MMM yyyy', { locale: idLocale })}
</span>
{getStatusBadge(order.status)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-slate-100 pt-3 dark:border-slate-800/60"> {/* Duplicate Order Modal */}
<div className="flex items-center gap-1.5"> {orderToDuplicate && (
<Users className="h-4 w-4 text-[#1B2CC1]" /> <Dialog
<span className="text-xs font-bold text-slate-700 dark:text-slate-300"> open={!!orderToDuplicate}
{order.submissions.length} Orang Menitip onOpenChange={(open) => !open && setOrderToDuplicate(null)}
</span> >
{isClosed && order.submissions.length > 0 && ( <CreateOrderModal
<div className="ml-1 flex items-center gap-1.5 border-slate-200 sm:ml-2 sm:border-l sm:pl-3 dark:border-slate-700"> userId={userId!}
<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"> initialData={orderToDuplicate}
Lunas:{' '} onSuccess={() => {
{ setOrderToDuplicate(null)
order.submissions.filter((s: any) => s.payment_status === 'LUNAS') if (userId) loadOrders(userId)
.length }}
} />
</span> </Dialog>
<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:{' '}
{
order.submissions.filter((s: any) => s.payment_status !== 'LUNAS')
.length
}
</span>
</div>
)}
</div>
<div className="hidden h-1 w-1 rounded-full bg-slate-300 sm:block dark:bg-slate-600"></div> {/* Delete Confirmation Modal */}
{orderToDelete && (
<div className="flex items-center gap-2"> <Dialog open={!!orderToDelete} onOpenChange={(open) => !open && setOrderToDelete(null)}>
{order.available_items.length > 0 && ( <DialogContent className="overflow-hidden rounded-3xl border-slate-200/90 p-0 shadow-2xl sm:max-w-[440px] dark:border-slate-800">
<span className="text-xs font-bold text-slate-600 dark:text-slate-400"> <div className="flex items-center gap-3 border-b border-red-100 bg-red-50 p-6 dark:border-red-900/50 dark:bg-red-950/30">
{order.available_items.length} Menu Pilihan <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-600 text-white shadow-md shadow-red-600/20">
</span> <AlertTriangle className="h-5 w-5" />
)}
{order.allow_custom && (
<div className="inline-flex items-center gap-1 rounded-md bg-blue-50 px-2 py-0.5 text-[10px] font-semibold text-[#1B2CC1] dark:bg-blue-950/40">
<Sparkles className="h-3 w-3" /> Custom Item Aktif
</div>
)}
</div>
</div>
</div> </div>
<div>
{/* Right: Actions */} <DialogTitle className="text-lg font-black text-slate-900 dark:text-white">
<div className="flex w-full shrink-0 flex-col justify-center gap-3 bg-slate-50/70 p-4 sm:p-5 md:w-[280px] dark:bg-slate-800/40"> Hapus Jasa PO Ini?
<div className="flex w-full flex-row gap-2"> </DialogTitle>
{/* Detail Link */} <p className="mt-0.5 text-xs text-slate-500">
<Link Tindakan ini permanen dan tidak dapat dibatalkan.
href={`/my-orders/${order.id}`} </p>
className={cn(
buttonVariants({ variant: 'outline', size: 'sm' }),
'flex h-10 flex-1 items-center justify-center gap-1.5 rounded-xl border-slate-200 bg-white px-2 text-xs font-bold shadow-sm transition-all hover:border-[#1B2CC1]/40 hover:bg-blue-50/60 hover:text-[#1B2CC1] dark:border-slate-700 dark:bg-slate-900'
)}
>
<FileText className="h-4 w-4 shrink-0 text-[#1B2CC1]" />
<span className="truncate">Detail</span>
</Link>
{/* Status Action Button */}
<div className="flex-1">
{order.status === 'DRAFT' && (
<Button
onClick={() => handleStatusChange(order.id, 'OPEN')}
disabled={!isToday(new Date(order.date))}
className={cn(
'h-10 w-full gap-1.5 rounded-xl px-2 text-xs font-bold shadow-sm transition-all',
isToday(new Date(order.date))
? 'cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700'
: 'cursor-not-allowed bg-slate-200 text-slate-500 opacity-50'
)}
title={
isToday(new Date(order.date))
? 'Buka PO'
: 'Hanya PO hari ini yang bisa dibuka'
}
>
<Sparkles className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Buka</span>
</Button>
)}
{order.status === 'OPEN' && (
<Button
onClick={() => handleStatusChange(order.id, 'CLOSE')}
className="h-10 w-full cursor-pointer gap-1.5 rounded-xl bg-rose-600 px-2 text-xs font-bold text-white shadow-sm transition-all hover:bg-rose-700"
title="Tutup PO"
>
<Lock className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Tutup</span>
</Button>
)}
{order.status === 'CLOSE' && (
<Button
onClick={() => handleStatusChange(order.id, 'OPEN')}
disabled={!isToday(new Date(order.date))}
className={cn(
'h-10 w-full gap-1.5 rounded-xl px-2 text-xs font-bold shadow-sm transition-all',
isToday(new Date(order.date))
? 'cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700'
: 'cursor-not-allowed bg-slate-200 text-slate-500 opacity-50'
)}
title={
isToday(new Date(order.date))
? 'Buka Kembali PO'
: 'Hanya PO hari ini yang bisa dibuka'
}
>
<Sparkles className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Buka</span>
</Button>
)}
</div>
</div>
{/* Utility Tools Row */}
<div className="flex items-center justify-between gap-1 border-t border-slate-200/60 pt-3 dark:border-slate-800/60">
<Button
variant="ghost"
size="sm"
disabled={isClosed || !isToday(new Date(order.date))}
onClick={() => setEditingOrder(order)}
className={cn(
'h-8 flex-1 gap-1 rounded-lg px-1 text-[10px] font-bold transition-all sm:text-[11px]',
isClosed || !isToday(new Date(order.date))
? 'cursor-not-allowed text-slate-400 opacity-35'
: 'text-slate-600 hover:bg-[#1B2CC1]/10 hover:text-[#1B2CC1] dark:text-slate-300'
)}
title={
isClosed
? 'PO sudah CLOSE'
: !isToday(new Date(order.date))
? 'Hanya PO hari ini yang bisa diedit'
: 'Edit PO'
}
>
<Pencil className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Edit</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setOrderToDuplicate(order)}
className="h-8 flex-1 gap-1 rounded-lg px-1 text-[10px] font-bold text-slate-600 transition-all hover:bg-slate-200/60 hover:text-slate-900 sm:text-[11px] dark:text-slate-300"
title="Duplikasi PO ini"
>
<Copy className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Duplikat</span>
</Button>
<Button
variant="ghost"
size="sm"
disabled={!canDelete}
onClick={() => setOrderToDelete(order)}
className={cn(
'h-8 flex-1 gap-1 rounded-lg px-1 text-[10px] font-bold transition-all sm:text-[11px]',
!canDelete
? 'cursor-not-allowed text-slate-400 opacity-35'
: 'text-red-600 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/40'
)}
title={!canDelete ? 'PO OPEN tidak dapat dihapus' : 'Hapus PO'}
>
<Trash2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Hapus</span>
</Button>
{order.status === 'OPEN' && (
<ShareButton
orderId={order.id}
variant="ghost"
className="h-8 flex-1 gap-1 rounded-lg px-1 text-[10px] font-bold text-slate-600 transition-all hover:bg-[#1B2CC1]/10 hover:text-[#1B2CC1] sm:text-[11px] dark:text-slate-300"
/>
)}
</div>
</div> </div>
</div> </div>
)
})}
</div>
{/* Pagination Controls */} <div className="space-y-4 bg-white p-6 dark:bg-slate-900">
{totalPages > 1 && ( <div className="rounded-xl border border-slate-200/80 bg-slate-50 p-3.5 text-xs dark:border-slate-700 dark:bg-slate-800/50">
<div className="mt-4 flex items-center justify-between border-t border-slate-200 pt-6 dark:border-slate-800"> <p className="mb-1 font-bold text-slate-800 dark:text-slate-200">
<span className="text-xs font-medium text-slate-500"> {orderToDelete.title}
Menampilkan{' '} </p>
<span className="font-bold text-slate-900 dark:text-white"> <p className="text-[11px] text-slate-500">
{(currentPage - 1) * ITEMS_PER_PAGE + 1} Status:{' '}
</span>{' '} <span className="font-bold text-slate-700 dark:text-slate-300">
hingga{' '} {orderToDelete.status}
<span className="font-bold text-slate-900 dark:text-white"> </span>{' '}
{Math.min(currentPage * ITEMS_PER_PAGE, filteredOrders.length)} • {orderToDelete.submissions?.length || 0} titipan pemesan
</span>{' '} </p>
dari{' '} </div>
<span className="font-bold text-slate-900 dark:text-white">
{filteredOrders.length}
</span>{' '}
PO
</span>
<div className="flex items-center gap-1.5"> <p className="text-xs leading-relaxed text-slate-600 dark:text-slate-400">
<Button Seluruh data pesanan dan daftar menu yang ada di dalam PO ini akan dihapus dari
variant="outline" sistem.
size="sm" </p>
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="h-8 w-8 rounded-lg p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 px-2"> <div className="flex gap-2.5 pt-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Button <Button
key={page} type="button"
variant="ghost" variant="outline"
size="sm" onClick={() => setOrderToDelete(null)}
onClick={() => setCurrentPage(page)} disabled={deleting}
className={cn( className="h-10 w-1/2 rounded-xl text-xs font-bold"
'h-8 w-8 rounded-lg p-0 text-xs font-bold transition-all',
currentPage === page
? 'bg-[#1B2CC1] text-white hover:bg-[#15229E] hover:text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
)}
> >
{page} Batal
</Button> </Button>
))} <Button
type="button"
onClick={handleDelete}
disabled={deleting}
className="h-10 w-1/2 gap-1.5 rounded-xl bg-red-600 text-xs font-bold text-white shadow-md shadow-red-600/20 hover:bg-red-700"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
<span>{deleting ? 'Menghapus...' : 'Ya, Hapus PO'}</span>
</Button>
</div>
</div> </div>
</DialogContent>
</Dialog>
)}
<Button {/* Order List */}
variant="outline" {filteredOrders.length === 0 ? (
size="sm" <div className="flex flex-col items-center justify-center rounded-3xl border-2 border-dashed border-slate-200 bg-white p-16 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))} <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[#1B2CC1]/10 text-[#1B2CC1]">
disabled={currentPage === totalPages} <Layers className="h-8 w-8" />
className="h-8 w-8 rounded-lg p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div> </div>
<h3 className="mb-1 text-lg font-bold text-slate-800 dark:text-slate-200">
{activeTab === 'ALL' ? 'Belum Ada Jasa PO' : `Tidak Ada PO Berstatus ${activeTab}`}
</h3>
<p className="mb-6 max-w-sm text-xs leading-relaxed text-slate-500">
Mulai buka jasa titip pesanan makanan atau kebutuhan untuk teman kantor.
</p>
<Button
onClick={() => setIsCreateOpen(true)}
className="h-10 gap-2 rounded-xl bg-[#1B2CC1] font-bold text-white shadow-md shadow-[#1B2CC1]/20 hover:bg-[#15229E]"
>
<Plus className="h-4 w-4" /> Buat PO Baru
</Button>
</div>
) : (
<div className="flex h-full flex-col space-y-4">
<div className="flex max-h-[calc(100vh-260px)] scrollbar-thin flex-col space-y-4 overflow-y-auto pr-2">
{paginatedOrders.map((order) => {
const date = new Date(order.date)
const isClosed = order.status === 'CLOSE'
const isDraft = order.status === 'DRAFT'
const canDelete = isDraft || isClosed
return (
<div
key={order.id}
className="flex shrink-0 flex-col overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-sm transition-all hover:border-[#1B2CC1]/40 hover:shadow-md md:flex-row dark:border-slate-800 dark:bg-slate-900"
>
{/* Left: Info */}
<div className="flex w-full flex-col justify-center border-slate-100 p-5 md:flex-1 md:border-r dark:border-slate-800/80">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="line-clamp-2 text-xl leading-snug font-bold text-slate-900 dark:text-white">
{order.title}
</h3>
{order.description && (
<p className="mt-1 line-clamp-2 text-xs font-medium text-slate-500">
{order.description}
</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
<span className="rounded-full bg-slate-100/80 px-2.5 py-1 text-[11px] font-semibold whitespace-nowrap text-slate-500 dark:bg-slate-800">
{format(date, 'EEEE, dd MMM yyyy', { locale: idLocale })}
</span>
{getStatusBadge(order.status)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-slate-100 pt-3 dark:border-slate-800/60">
<div className="flex items-center gap-1.5">
<Users className="h-4 w-4 text-[#1B2CC1]" />
<span className="text-xs font-bold text-slate-700 dark:text-slate-300">
{order.submissions.length} Orang Menitip
</span>
{isClosed && order.submissions.length > 0 && (
<div className="ml-1 flex items-center gap-1.5 border-slate-200 sm:ml-2 sm:border-l sm:pl-3 dark:border-slate-700">
<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:{' '}
{
order.submissions.filter(
(s: any) => s.payment_status === 'LUNAS'
).length
}
</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">
Belum:{' '}
{
order.submissions.filter(
(s: any) => s.payment_status !== 'LUNAS'
).length
}
</span>
</div>
)}
</div>
<div className="hidden h-1 w-1 rounded-full bg-slate-300 sm:block dark:bg-slate-600"></div>
<div className="flex items-center gap-2">
{order.available_items.length > 0 && (
<span className="text-xs font-bold text-slate-600 dark:text-slate-400">
{order.available_items.length} Menu Pilihan
</span>
)}
{order.allow_custom && (
<div className="inline-flex items-center gap-1 rounded-md bg-blue-50 px-2 py-0.5 text-[10px] font-semibold text-[#1B2CC1] dark:bg-blue-950/40">
<Sparkles className="h-3 w-3" /> Custom Item Aktif
</div>
)}
</div>
</div>
</div>
{/* Right: Actions */}
<div className="flex w-full shrink-0 flex-col justify-center gap-3 bg-slate-50/70 p-4 sm:p-5 md:w-[280px] dark:bg-slate-800/40">
<div className="flex w-full flex-row gap-2">
{/* Detail Link */}
<Link
href={`/my-orders/${order.id}`}
className={cn(
buttonVariants({ variant: 'outline', size: 'sm' }),
'flex h-10 flex-1 items-center justify-center gap-1.5 rounded-xl border-slate-200 bg-white px-2 text-xs font-bold shadow-sm transition-all hover:border-[#1B2CC1]/40 hover:bg-blue-50/60 hover:text-[#1B2CC1] dark:border-slate-700 dark:bg-slate-900'
)}
>
<FileText className="h-4 w-4 shrink-0 text-[#1B2CC1]" />
<span className="truncate">Detail</span>
</Link>
{/* Status Action Button */}
<div className="flex-1">
{order.status === 'DRAFT' && (
<Button
onClick={() => handleStatusChange(order.id, 'OPEN')}
disabled={!isToday(new Date(order.date))}
className={cn(
'h-10 w-full gap-1.5 rounded-xl px-2 text-xs font-bold shadow-sm transition-all',
isToday(new Date(order.date))
? 'cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700'
: 'cursor-not-allowed bg-slate-200 text-slate-500 opacity-50'
)}
title={
isToday(new Date(order.date))
? 'Buka PO'
: 'Hanya PO hari ini yang bisa dibuka'
}
>
<Sparkles className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Buka</span>
</Button>
)}
{order.status === 'OPEN' && (
<Button
onClick={() => handleStatusChange(order.id, 'CLOSE')}
className="h-10 w-full cursor-pointer gap-1.5 rounded-xl bg-rose-600 px-2 text-xs font-bold text-white shadow-sm transition-all hover:bg-rose-700"
title="Tutup PO"
>
<Lock className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Tutup</span>
</Button>
)}
{order.status === 'CLOSE' && (
<Button
onClick={() => handleStatusChange(order.id, 'OPEN')}
disabled={!isToday(new Date(order.date))}
className={cn(
'h-10 w-full gap-1.5 rounded-xl px-2 text-xs font-bold shadow-sm transition-all',
isToday(new Date(order.date))
? 'cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700'
: 'cursor-not-allowed bg-slate-200 text-slate-500 opacity-50'
)}
title={
isToday(new Date(order.date))
? 'Buka Kembali PO'
: 'Hanya PO hari ini yang bisa dibuka'
}
>
<Sparkles className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Buka</span>
</Button>
)}
</div>
</div>
{/* Utility Tools Row */}
<div className="flex items-center justify-between gap-1 border-t border-slate-200/60 pt-3 dark:border-slate-800/60">
<Button
variant="ghost"
size="sm"
disabled={isClosed || !isToday(new Date(order.date))}
onClick={() => setEditingOrder(order)}
className={cn(
'h-8 flex-1 gap-1 rounded-lg px-1 text-[10px] font-bold transition-all sm:text-[11px]',
isClosed || !isToday(new Date(order.date))
? 'cursor-not-allowed text-slate-400 opacity-35'
: 'text-slate-600 hover:bg-[#1B2CC1]/10 hover:text-[#1B2CC1] dark:text-slate-300'
)}
title={
isClosed
? 'PO sudah CLOSE'
: !isToday(new Date(order.date))
? 'Hanya PO hari ini yang bisa diedit'
: 'Edit PO'
}
>
<Pencil className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Edit</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setOrderToDuplicate(order)}
className="h-8 flex-1 gap-1 rounded-lg px-1 text-[10px] font-bold text-slate-600 transition-all hover:bg-slate-200/60 hover:text-slate-900 sm:text-[11px] dark:text-slate-300"
title="Duplikasi PO ini"
>
<Copy className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Duplikat</span>
</Button>
<Button
variant="ghost"
size="sm"
disabled={!canDelete}
onClick={() => setOrderToDelete(order)}
className={cn(
'h-8 flex-1 gap-1 rounded-lg px-1 text-[10px] font-bold transition-all sm:text-[11px]',
!canDelete
? 'cursor-not-allowed text-slate-400 opacity-35'
: 'text-red-600 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/40'
)}
title={!canDelete ? 'PO OPEN tidak dapat dihapus' : 'Hapus PO'}
>
<Trash2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Hapus</span>
</Button>
{order.status === 'OPEN' && (
<ShareButton
orderId={order.id}
variant="ghost"
className="h-8 flex-1 gap-1 rounded-lg px-1 text-[10px] font-bold text-slate-600 transition-all hover:bg-[#1B2CC1]/10 hover:text-[#1B2CC1] sm:text-[11px] dark:text-slate-300"
/>
)}
</div>
</div>
</div>
)
})}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between border-t border-slate-200 pt-6 dark:border-slate-800">
<span className="text-xs font-medium text-slate-500">
Menampilkan{' '}
<span className="font-bold text-slate-900 dark:text-white">
{(currentPage - 1) * ITEMS_PER_PAGE + 1}
</span>{' '}
hingga{' '}
<span className="font-bold text-slate-900 dark:text-white">
{Math.min(currentPage * ITEMS_PER_PAGE, filteredOrders.length)}
</span>{' '}
dari{' '}
<span className="font-bold text-slate-900 dark:text-white">
{filteredOrders.length}
</span>{' '}
PO
</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="h-8 w-8 rounded-lg p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 px-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Button
key={page}
variant="ghost"
size="sm"
onClick={() => setCurrentPage(page)}
className={cn(
'h-8 w-8 rounded-lg p-0 text-xs font-bold transition-all',
currentPage === page
? 'bg-[#1B2CC1] text-white hover:bg-[#15229E] hover:text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
)}
>
{page}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-8 w-8 rounded-lg p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div> </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 */}
+51 -4
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="space-y-1.5"> <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">
<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}
+341 -121
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,22 +698,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) 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: { creator: { select: { id: true, name: true, photo: true } },
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<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 }
}
}