5 Commits
10 changed files with 2121 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.
- 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.
<!-- 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 {
id String @id @default(uuid())
username String @unique
name String
password String
photo String?
role String @default("user")
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
orders Order[] @relation("CreatedOrders")
purchases Submission[]
id String @id @default(uuid())
username String @unique
name String
password String
photo String?
role String @default("user")
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
orders Order[] @relation("CreatedOrders")
purchases Submission[]
creator_withdrawals BalanceWithdrawal[] @relation("CreatorWithdrawals")
user_withdrawals BalanceWithdrawal[] @relation("UserWithdrawals")
notification_config UserNotificationConfig?
}
model Order {
@@ -78,3 +81,26 @@ model Setting {
value String
updated_at DateTime @updatedAt
}
model BalanceWithdrawal {
id String @id @default(uuid())
creator_id String
user_id String
amount Int
note String?
created_at DateTime @default(now())
creator User @relation("CreatorWithdrawals", fields: [creator_id], references: [id], onDelete: Cascade)
user User @relation("UserWithdrawals", fields: [user_id], references: [id], onDelete: Cascade)
}
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'
import { useEffect, useState } from 'react'
import { getBalancesAsCreator, getBalancesAsSubmittor, getSessionUser } from '@/app/actions'
import {
getBalancesAsCreator,
getBalancesAsSubmittor,
getSessionUser,
createWithdrawal,
getWithdrawalHistory,
deleteWithdrawal,
} from '@/app/actions'
import { Card, CardContent } from '@/components/ui/card'
import { Wallet, ArrowDownRight, ArrowUpRight, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import {
Wallet,
ArrowDownRight,
ArrowUpRight,
Loader2,
Banknote,
History,
Trash2,
ChevronDown,
ChevronUp,
AlertTriangle,
CheckCircle2,
FileText,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
import { id as idLocale } from 'date-fns/locale'
export default function BalancesPage() {
const [activeTab, setActiveTab] = useState<'SUBMITTOR' | 'CREATOR'>('SUBMITTOR')
@@ -13,6 +39,24 @@ export default function BalancesPage() {
const [creatorBalances, setCreatorBalances] = useState<any[]>([])
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(() => {
const init = async () => {
const user = await getSessionUser()
@@ -42,6 +86,83 @@ export default function BalancesPage() {
maximumFractionDigits: 0,
}).format(n)
const openWithdrawModal = (e: React.MouseEvent, balance: any) => {
e.stopPropagation()
setWithdrawTarget(balance)
setWithdrawAmount(String(balance.amount))
setWithdrawNote('')
setModalError('')
setModalOpen(true)
}
const handleWithdraw = async () => {
if (!withdrawTarget || !userId) return
const amount = parseInt(withdrawAmount.replace(/\D/g, '')) || 0
if (amount <= 0) {
setModalError('Nominal harus lebih dari 0')
return
}
if (amount > withdrawTarget.amount) {
setModalError(`Tidak boleh melebihi saldo tersedia (${formatRupiah(withdrawTarget.amount)})`)
return
}
setSaving(true)
setModalError('')
const res = await createWithdrawal({
creator_id: userId,
user_id: withdrawTarget.user.id,
amount,
note: withdrawNote.trim() || undefined,
})
setSaving(false)
if (res.success) {
setModalOpen(false)
loadData(userId)
if (expandedHistory[withdrawTarget.user.id]) {
loadHistory(withdrawTarget.user.id)
}
} else {
setModalError(res.error || 'Gagal mencatat pengembalian')
}
}
const loadHistory = async (targetUserId: string) => {
if (!userId) return
setLoadingHistory((prev) => ({ ...prev, [targetUserId]: true }))
const history = await getWithdrawalHistory(userId, targetUserId)
setHistoryMap((prev) => ({ ...prev, [targetUserId]: history }))
setLoadingHistory((prev) => ({ ...prev, [targetUserId]: false }))
}
const toggleHistory = (targetUserId: string) => {
const willOpen = !expandedHistory[targetUserId]
setExpandedHistory((prev) => ({ ...prev, [targetUserId]: willOpen }))
if (willOpen && !historyMap[targetUserId]) {
loadHistory(targetUserId)
}
}
const confirmDelete = (id: string, amount: number) => {
setDeleteTarget({ id, amount })
setDeleteModalOpen(true)
}
const handleDelete = async () => {
if (!deleteTarget || !userId) return
setDeletingId(deleteTarget.id)
const res = await deleteWithdrawal(deleteTarget.id)
setDeletingId(null)
setDeleteModalOpen(false)
if (res.success) {
loadData(userId)
Object.keys(expandedHistory).forEach((uid) => {
if (expandedHistory[uid]) loadHistory(uid)
})
} else {
alert(res.error || 'Gagal menghapus')
}
}
return (
<div className="animate-in fade-in space-y-6 pb-12 duration-500">
<div>
@@ -86,149 +207,581 @@ export default function BalancesPage() {
<span className="text-xs font-semibold text-slate-500">Memuat saldo...</span>
</div>
) : activeTab === 'SUBMITTOR' ? (
<div className="space-y-4">
<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="space-y-5">
<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">
Saldo Anda di Kreator Lain
</h3>
<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
untuk pesanan berikutnya di kreator tersebut. Jika <strong>negatif</strong> (merah),
Anda berhutang.
untuk pesanan berikutnya. Jika <strong>negatif</strong> (merah), Anda berhutang.
</p>
</div>
{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">
<p className="text-xs font-medium text-slate-500">Belum ada catatan saldo.</p>
<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">
<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 className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{submittorBalances.map((b, i) => (
<Card
key={i}
className="overflow-hidden rounded-2xl border-slate-200/90 shadow-sm transition-all hover:border-[#1B2CC1]/30 dark:border-slate-800"
>
<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">
{b.creator.photo ? (
<img
src={b.creator.photo}
alt={b.creator.name}
className="h-10 w-10 rounded-full object-cover shadow-sm ring-2 ring-white dark:ring-slate-900"
/>
) : (
<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 className="space-y-3">
{submittorBalances.map((b, i) => {
const isPositive = b.amount > 0
return (
<div
key={i}
className={cn(
'overflow-hidden rounded-2xl border bg-white shadow-sm dark:bg-slate-900',
isPositive
? 'border-emerald-100 dark:border-emerald-900/40'
: 'border-rose-100 dark:border-rose-900/40'
)}
<div>
<p className="text-sm font-bold text-slate-900 dark:text-white">
{b.creator.name}
</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 className="flex items-stretch">
{/* Left accent strip */}
<div
className={cn(
'flex items-center gap-1.5 text-xl font-black',
b.amount > 0 ? 'text-emerald-600' : 'text-rose-600'
'w-1 shrink-0',
isPositive ? 'bg-emerald-400' : 'bg-rose-400'
)}
>
{b.amount > 0 ? (
<ArrowUpRight className="h-5 w-5" />
) : (
<ArrowDownRight className="h-5 w-5" />
)}
{formatRupiah(b.amount)}
/>
<div className="flex flex-1 flex-col gap-0">
{/* Main info row */}
<div className="flex items-center justify-between gap-4 px-4 py-4">
{/* Avatar + name */}
<div className="flex min-w-0 items-center gap-3">
<div className="relative shrink-0">
{b.creator.photo ? (
<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>
</CardContent>
</Card>
))}
</div>
)
})}
</div>
)}
</div>
) : (
<div className="space-y-4">
<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">
/* TAB CREATOR */
<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">
Saldo Orang Lain di Anda
</h3>
<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
lebih milik penitip (Hutang Anda ke mereka). Jika <strong>negatif</strong> (hijau),
mereka berhutang ke Anda.
Saldo <strong>positif</strong> = Anda memegang uang lebih milik penitip. Klik{' '}
<strong>Kembalikan</strong> untuk mencatatnya. Saldo <strong>negatif</strong> =
penitip masih berhutang ke Anda.
</p>
</div>
{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">
<p className="text-xs font-medium text-slate-500">
Belum ada penitip yang memiliki catatan saldo dengan Anda.
<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">
<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">Tidak ada catatan saldo</p>
<p className="mt-1 text-xs text-slate-400">
Saldo muncul saat ada kelebihan atau kekurangan bayar.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{creatorBalances.map((b, i) => (
<Card
key={i}
className="overflow-hidden rounded-2xl border-slate-200/90 shadow-sm transition-all hover:border-[#1B2CC1]/30 dark:border-slate-800"
>
<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">
{b.user.photo ? (
<img
src={b.user.photo}
alt={b.user.name}
className="h-10 w-10 rounded-full object-cover shadow-sm ring-2 ring-white dark:ring-slate-900"
/>
) : (
<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">
{b.user.name.charAt(0).toUpperCase()}
</div>
<div className="space-y-3">
{creatorBalances.map((b, i) => {
const uid = b.user.id
const isExpanded = expandedHistory[uid]
const history = historyMap[uid] || []
const isLoadingHist = loadingHistory[uid]
const isPositive = b.amount > 0
return (
<div
key={i}
className={cn(
'overflow-hidden rounded-2xl border bg-white shadow-sm dark:bg-slate-900',
isPositive
? 'border-rose-100 dark:border-rose-900/40'
: 'border-emerald-100 dark:border-emerald-900/40'
)}
<div>
<p className="text-sm font-bold text-slate-900 dark:text-white">
{b.user.name}
</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 className="flex items-stretch">
{/* Left accent strip */}
<div
className={cn(
'flex items-center gap-1.5 text-xl font-black',
b.amount > 0 ? 'text-rose-600' : 'text-emerald-600'
'w-1 shrink-0',
isPositive ? 'bg-rose-400' : 'bg-emerald-400'
)}
>
{b.amount > 0 ? (
<ArrowDownRight className="h-5 w-5" />
) : (
<ArrowUpRight className="h-5 w-5" />
/>
<div className="flex flex-1 flex-col">
{/* Main info row */}
<div className="flex items-center justify-between gap-4 px-4 py-4">
{/* Avatar + name */}
<div className="flex min-w-0 items-center gap-3">
<div className="relative shrink-0">
{b.user.photo ? (
<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>
</CardContent>
</Card>
))}
</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>
)
}
+481 -456
View File
@@ -5,30 +5,15 @@ import {
getMyOrders,
createOrder,
updateOrder,
duplicateOrder,
updateOrderStatus,
deleteOrder,
getSessionUser,
} from '@/app/actions'
import { Button, buttonVariants } from '@/components/ui/button'
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -53,6 +38,7 @@ import {
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import PiutangManager from '@/components/PiutangManager'
export default function MyOrdersPage() {
const [userId, setUserId] = useState<string | null>(null)
@@ -63,6 +49,7 @@ export default function MyOrdersPage() {
const [orderToDelete, setOrderToDelete] = useState<any | null>(null)
const [orderToDuplicate, setOrderToDuplicate] = useState<any | null>(null)
const [deleting, setDeleting] = useState(false)
const [mainTab, setMainTab] = useState<'PO' | 'PIUTANG'>('PO')
const [activeTab, setActiveTab] = useState<'ALL' | 'OPEN' | 'DRAFT' | 'CLOSE'>('ALL')
const [currentPage, setCurrentPage] = useState(1)
const [searchQuery, setSearchQuery] = useState('')
@@ -154,469 +141,507 @@ export default function MyOrdersPage() {
return (
<div className="animate-in fade-in space-y-6 pb-12 duration-500">
{/* Top Controls & Segmented Control */}
<div className="flex flex-col items-start justify-between gap-4 rounded-2xl border border-slate-200/80 bg-white p-4 shadow-sm md:flex-row md:items-center dark:border-slate-800 dark:bg-slate-900">
<div className="flex w-full flex-col items-start gap-4 md:w-auto md:flex-row md:items-center">
{/* Segmented Control / Tabs */}
<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">
{(['ALL', 'OPEN', 'DRAFT', 'CLOSE'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
'rounded-lg px-4 py-2 whitespace-nowrap transition-all duration-200',
activeTab === tab
? 'bg-white font-black text-[#1B2CC1] shadow-sm dark:bg-slate-900 dark:text-white'
: 'text-slate-500 hover:text-slate-900 dark:hover:text-white'
)}
>
{tab === 'ALL' ? 'Semua PO' : tab}
<span className="ml-1.5 text-[10px] opacity-70">
({tab === 'ALL' ? orders.length : orders.filter((o) => o.status === tab).length})
</span>
</button>
))}
</div>
{/* 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>
{/* Main Tab Switcher */}
<div className="flex w-full max-w-sm rounded-xl border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-900">
<button
onClick={() => setMainTab('PO')}
className={cn(
'flex-1 rounded-lg py-2 text-xs font-bold transition-all',
mainTab === 'PO'
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-800 dark:text-white'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)}
>
Daftar PO
</button>
<button
onClick={() => setMainTab('PIUTANG')}
className={cn(
'flex-1 rounded-lg py-2 text-xs font-bold transition-all',
mainTab === 'PIUTANG'
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-800 dark:text-white'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)}
>
Kelola Piutang
</button>
</div>
{/* Edit Order Modal */}
{editingOrder && (
<Dialog open={!!editingOrder} onOpenChange={(open) => !open && setEditingOrder(null)}>
<EditOrderModal
order={editingOrder}
onClose={() => setEditingOrder(null)}
onSuccess={() => {
setEditingOrder(null)
if (userId) loadOrders(userId)
}}
/>
</Dialog>
)}
{/* Duplicate Order Modal */}
{orderToDuplicate && (
<Dialog
open={!!orderToDuplicate}
onOpenChange={(open) => !open && setOrderToDuplicate(null)}
>
<CreateOrderModal
userId={userId!}
initialData={orderToDuplicate}
onSuccess={() => {
setOrderToDuplicate(null)
if (userId) loadOrders(userId)
}}
/>
</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" />
{mainTab === 'PO' ? (
<>
{/* Top Controls & Segmented Control */}
<div className="flex flex-col items-start justify-between gap-4 rounded-2xl border border-slate-200/80 bg-white p-4 shadow-sm md:flex-row md:items-center dark:border-slate-800 dark:bg-slate-900">
<div className="flex w-full flex-col items-start gap-4 md:w-auto md:flex-row md:items-center">
{/* Segmented Control / Tabs */}
<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">
{(['ALL', 'OPEN', 'DRAFT', 'CLOSE'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
'rounded-lg px-4 py-2 whitespace-nowrap transition-all duration-200',
activeTab === tab
? 'bg-white font-black text-[#1B2CC1] shadow-sm dark:bg-slate-900 dark:text-white'
: 'text-slate-500 hover:text-slate-900 dark:hover:text-white'
)}
>
{tab === 'ALL' ? 'Semua PO' : tab}
<span className="ml-1.5 text-[10px] opacity-70">
(
{tab === 'ALL'
? orders.length
: orders.filter((o) => o.status === tab).length}
)
</span>
</button>
))}
</div>
<div>
<DialogTitle className="text-lg font-black text-slate-900 dark:text-white">
Hapus Jasa PO Ini?
</DialogTitle>
<p className="mt-0.5 text-xs text-slate-500">
Tindakan ini permanen dan tidak dapat dibatalkan.
</p>
{/* 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>
<div className="space-y-4 bg-white p-6 dark:bg-slate-900">
<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">
<p className="mb-1 font-bold text-slate-800 dark:text-slate-200">
{orderToDelete.title}
</p>
<p className="text-[11px] text-slate-500">
Status:{' '}
<span className="font-bold text-slate-700 dark:text-slate-300">
{orderToDelete.status}
</span>{' '}
• {orderToDelete.submissions?.length || 0} titipan pemesan
</p>
</div>
<p className="text-xs leading-relaxed text-slate-600 dark:text-slate-400">
Seluruh data pesanan dan daftar menu yang ada di dalam PO ini akan dihapus dari
sistem.
</p>
<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" />
{/* 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>
<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>
{/* Edit Order Modal */}
{editingOrder && (
<Dialog open={!!editingOrder} onOpenChange={(open) => !open && setEditingOrder(null)}>
<EditOrderModal
order={editingOrder}
onClose={() => setEditingOrder(null)}
onSuccess={() => {
setEditingOrder(null)
if (userId) loadOrders(userId)
}}
/>
</Dialog>
)}
<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>
{/* Duplicate Order Modal */}
{orderToDuplicate && (
<Dialog
open={!!orderToDuplicate}
onOpenChange={(open) => !open && setOrderToDuplicate(null)}
>
<CreateOrderModal
userId={userId!}
initialData={orderToDuplicate}
onSuccess={() => {
setOrderToDuplicate(null)
if (userId) loadOrders(userId)
}}
/>
</Dialog>
)}
<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>
{/* 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>
{/* 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>
<DialogTitle className="text-lg font-black text-slate-900 dark:text-white">
Hapus Jasa PO Ini?
</DialogTitle>
<p className="mt-0.5 text-xs text-slate-500">
Tindakan ini permanen dan tidak dapat dibatalkan.
</p>
</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="space-y-4 bg-white p-6 dark:bg-slate-900">
<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">
<p className="mb-1 font-bold text-slate-800 dark:text-slate-200">
{orderToDelete.title}
</p>
<p className="text-[11px] text-slate-500">
Status:{' '}
<span className="font-bold text-slate-700 dark:text-slate-300">
{orderToDelete.status}
</span>{' '}
• {orderToDelete.submissions?.length || 0} titipan pemesan
</p>
</div>
<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>
<p className="text-xs leading-relaxed text-slate-600 dark:text-slate-400">
Seluruh data pesanan dan daftar menu yang ada di dalam PO ini akan dihapus dari
sistem.
</p>
<div className="flex items-center gap-1 px-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<div className="flex gap-2.5 pt-2">
<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'
)}
type="button"
variant="outline"
onClick={() => setOrderToDelete(null)}
disabled={deleting}
className="h-10 w-1/2 rounded-xl text-xs font-bold"
>
{page}
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>
)}
<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>
{/* 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>
<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>
</>
) : (
<PiutangManager creatorId={userId!} />
)}
</div>
)
+9 -8
View File
@@ -436,14 +436,15 @@ export default function MyPurchasesPage() {
</Button>
</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">
<Lock className="h-3 w-3" />
<span className="text-center leading-tight">
PO ditutup
<br />
(pesanan terkunci)
</span>
</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">
// <Lock className="h-3 w-3" />
// <span className="text-center leading-tight">
// PO ditutup
// <br />
// (pesanan terkunci)
// </span>
// </div>
<div></div>
)}
</div>
</div>
+94 -5
View File
@@ -1,7 +1,14 @@
'use client'
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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -21,6 +28,7 @@ import {
UserCircle,
KeyRound,
LockKeyhole,
MessageCircle,
} from 'lucide-react'
export default function ProfilePage() {
@@ -29,6 +37,13 @@ export default function ProfilePage() {
const [photo, setPhoto] = useState('')
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
const [savingProfile, setSavingProfile] = useState(false)
const [profileError, setProfileError] = useState('')
@@ -54,6 +69,20 @@ export default function ProfilePage() {
setUser(sessionUser)
setName(sessionUser.name)
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)
}
@@ -69,14 +98,22 @@ export default function ProfilePage() {
setProfileSuccess(false)
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)
setProfileSuccess(true)
setTimeout(() => setProfileSuccess(false), 3000)
} else {
setProfileError(res.error || 'Terjadi kesalahan')
setProfileError(res.error || notifRes.error || 'Terjadi kesalahan')
}
setSavingProfile(false)
}
@@ -215,6 +252,58 @@ export default function ProfilePage() {
/>
</div>
{globalNotifEnabled && (
<div className="mt-6 space-y-6 border-t border-slate-100 pt-6 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-y-0 space-x-3 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: budisantoso atau 9dpxnitm..."
className="h-11 rounded-xl"
/>
</div>
</div>
)}
{profileError && (
<div className="rounded-xl bg-red-50 p-3 text-xs font-semibold text-red-600">
{profileError}
@@ -223,7 +312,7 @@ export default function ProfilePage() {
{profileSuccess && (
<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" />
<span>Profil berhasil diperbarui!</span>
<span>Perubahan berhasil disimpan!</span>
</div>
)}
</CardContent>
+51 -4
View File
@@ -8,11 +8,15 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
export default function IntegrationsPage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [settings, setSettings] = useState({
MATTERMOST_NOTIF_ENABLED: 'false',
MATTERMOST_BOT_TOKEN: '',
MATTERMOST_API_URL: '',
MATTERMOST_WEBHOOK_URL: '',
MATTERMOST_TEMPLATE: '',
WHATSAPP_TEMPLATE: '',
@@ -23,6 +27,9 @@ export default function IntegrationsPage() {
const data = await getSettings()
if (data) {
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_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="flex items-center gap-2 font-black text-indigo-600 dark:text-indigo-400">
<MessageSquare className="h-5 w-5" />
<h3>Mattermost Webhook</h3>
<h3>Konfigurasi Mattermost</h3>
</div>
<p className="text-xs text-slate-500">
Gunakan URL Incoming Webhook dari Mattermost untuk mengirim broadcast PO baru secara
otomatis.
Atur integrasi Mattermost. Bagian atas untuk Notifikasi Personal (DM) ke Kreator, sedangkan bagian bawah untuk fitur Share (Broadcast) PO menggunakan Webhook.
</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>
<Input
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 { cookies } from 'next/headers'
import { sendMattermostNotification } from '@/lib/mattermost'
// === AUTHENTICATION ACTIONS ===
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(
order_id: string,
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: {
submission_ids: string[]
cash_amount: number
@@ -490,6 +418,11 @@ export async function processBulkPayment(data: {
user_id: string
}) {
try {
const me = await getSessionUser()
if (!me || me.id !== data.creator_id) {
return { success: false, error: 'Unauthorized' }
}
let currentBalance = 0
if (data.use_balance) {
const balanceData = await getBalancesAsCreator(data.creator_id)
@@ -499,7 +432,7 @@ export async function processBulkPayment(data: {
const submissions = await prisma.submission.findMany({
where: { id: { in: data.submission_ids } },
include: { order: { select: { date: true } } },
include: { order: { select: { date: true, status: true } } },
})
// Sort by order date ascending (oldest first)
@@ -515,11 +448,31 @@ export async function processBulkPayment(data: {
const sub = submissions[i]
const isLast = i === submissions.length - 1
// Don't process if order isn't CLOSE
if (sub.order.status !== 'CLOSE') continue
const subBill = sub.bill || 0
const prevPaid = sub.paid_amount || 0
const amountToCover = Math.max(0, subBill - prevPaid)
const prevSaldoUsed = sub.saldo_used || 0
const amountToCover = Math.max(0, subBill - prevPaid - prevSaldoUsed)
if (totalAvailable >= amountToCover && amountToCover > 0) {
if (amountToCover === 0) {
// It's already fully paid somehow, just mark LUNAS. Give excess cash if last.
let finalPaid = prevPaid
if (isLast && remainingCash > 0) {
finalPaid += remainingCash
remainingCash = 0
}
ops.push(
prisma.submission.update({
where: { id: sub.id },
data: { payment_status: 'LUNAS', paid_amount: finalPaid },
})
)
continue
}
if (totalAvailable >= amountToCover) {
// Fully covered -> LUNAS
let balanceToUse = Math.min(remainingBalance, amountToCover)
remainingBalance -= balanceToUse
@@ -533,44 +486,35 @@ export async function processBulkPayment(data: {
finalPaid += remainingCash
remainingCash = 0
}
let finalSaldoUsed = prevSaldoUsed + balanceToUse
ops.push(
prisma.submission.update({
where: { id: sub.id },
data: { payment_status: 'LUNAS', paid_amount: finalPaid },
})
)
} else if (totalAvailable >= amountToCover && amountToCover === 0) {
// It's already fully paid somehow, just mark LUNAS. Give excess cash if last.
let finalPaid = prevPaid
if (isLast && remainingCash > 0) {
finalPaid += remainingCash
remainingCash = 0
}
ops.push(
prisma.submission.update({
where: { id: sub.id },
data: { payment_status: 'LUNAS', paid_amount: finalPaid },
data: { payment_status: 'LUNAS', paid_amount: finalPaid, saldo_used: finalSaldoUsed },
})
)
} else if (totalAvailable > 0) {
// Partially covered -> BELUM_BAYAR. Only use cash.
let finalPaid = prevPaid + remainingCash
remainingCash = 0
totalAvailable = remainingBalance // only balance left, which can't be used
// Partially covered -> BELUM_BAYAR
let balanceToUse = Math.min(remainingBalance, totalAvailable)
remainingBalance -= balanceToUse
let cashToUse = totalAvailable - balanceToUse
remainingCash -= cashToUse
let finalPaid = prevPaid + cashToUse
let finalSaldoUsed = prevSaldoUsed + balanceToUse
totalAvailable = 0
ops.push(
prisma.submission.update({
where: { id: sub.id },
data: { payment_status: 'BELUM_BAYAR', paid_amount: finalPaid },
})
)
} else {
// totalAvailable == 0. No more money. Just leave it as is, or update to BELUM_BAYAR.
ops.push(
prisma.submission.update({
where: { id: sub.id },
data: { payment_status: 'BELUM_BAYAR' },
data: {
payment_status: 'BELUM_BAYAR',
paid_amount: finalPaid,
saldo_used: finalSaldoUsed,
},
})
)
}
@@ -663,6 +607,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('/my-purchases')
@@ -718,14 +666,16 @@ export async function getCreatorReport(creator_id: string, startDate?: Date, end
// === BALANCE ACTIONS ===
export async function getBalancesAsCreator(creator_id: string) {
const submissions = await prisma.submission.findMany({
where: {
order: { creator_id },
},
include: {
user: { select: { id: true, name: true, photo: true } },
},
})
const [submissions, withdrawals] = await Promise.all([
prisma.submission.findMany({
where: { order: { creator_id } },
include: { user: { select: { id: true, name: true, photo: true } } },
}),
prisma.balanceWithdrawal.findMany({
where: { creator_id },
include: { user: { select: { id: true, name: true, photo: true } } },
}),
])
const balanceMap = new Map<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)
}
export async function getBalancesAsSubmittor(user_id: string) {
const submissions = await prisma.submission.findMany({
where: {
user_id,
},
include: {
order: {
include: {
creator: { select: { id: true, name: true, photo: true } },
const [submissions, withdrawals] = await Promise.all([
prisma.submission.findMany({
where: { user_id },
include: {
order: {
include: {
creator: { select: { id: true, name: true, photo: true } },
},
},
},
},
})
}),
prisma.balanceWithdrawal.findMany({
where: { user_id },
include: { creator: { select: { id: true, name: true, photo: true } } },
}),
])
const balanceMap = new Map<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)
}
// === WITHDRAWAL ACTIONS ===
export async function createWithdrawal(data: {
creator_id: string
user_id: string
amount: number
note?: string
}) {
try {
const me = await getSessionUser()
if (!me || me.id !== data.creator_id) {
return { success: false, error: 'Unauthorized' }
}
if (!data.amount || data.amount <= 0) {
return { success: false, error: 'Nominal harus lebih dari 0' }
}
// Hitung saldo tersedia saat ini untuk user ini
const balances = await getBalancesAsCreator(data.creator_id)
const userBalance = balances.find((b) => b.user.id === data.user_id)
const available = userBalance ? userBalance.amount : 0
if (data.amount > available) {
return {
success: false,
error: `Nominal melebihi saldo tersedia (${new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(available)})`,
}
}
await prisma.balanceWithdrawal.create({
data: {
creator_id: data.creator_id,
user_id: data.user_id,
amount: data.amount,
note: data.note || null,
},
})
revalidatePath('/balances')
revalidatePath('/my-orders')
revalidatePath('/reports')
return { success: true }
} catch (e) {
console.error(e)
return { success: false, error: 'Gagal mencatat penarikan saldo.' }
}
}
export async function getWithdrawalHistory(creator_id: string, user_id?: string) {
return await prisma.balanceWithdrawal.findMany({
where: {
creator_id,
...(user_id ? { user_id } : {}),
},
include: {
user: { select: { id: true, name: true, photo: true } },
},
orderBy: { created_at: 'desc' },
})
}
export async function deleteWithdrawal(id: string) {
try {
const me = await getSessionUser()
const record = await prisma.balanceWithdrawal.findUnique({ where: { id } })
if (!record) return { success: false, error: 'Data tidak ditemukan' }
if (!me || me.id !== record.creator_id) {
return { success: false, error: 'Unauthorized' }
}
await prisma.balanceWithdrawal.delete({ where: { id } })
revalidatePath('/balances')
revalidatePath('/my-orders')
revalidatePath('/reports')
return { success: true }
} catch (e) {
console.error(e)
return { success: false, error: 'Gagal menghapus riwayat penarikan.' }
}
}
export async function deleteSubmission(submission_id: string, user_id: string) {
try {
const submission = await prisma.submission.findUnique({
@@ -805,6 +858,10 @@ export async function deleteSubmission(submission_id: string, user_id: string) {
await prisma.submission.delete({
where: { id: submission_id },
})
// Trigger notification
notifyCreatorOnSubmission(submission.order_id, user_id, 'delete').catch(console.error)
return { success: true }
} catch (error) {
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' }
}
}
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 }
}
}