7 Commits
58 changed files with 11888 additions and 5198 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
dist
build
public
*.md
.env*
+11
View File
@@ -0,0 +1,11 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"jsxSingleQuote": false,
"bracketSpacing": true,
"arrowParens": "always",
"plugins": ["prettier-plugin-tailwindcss"]
}
+10
View File
@@ -182,3 +182,13 @@ Buatkan file `README.md` yang mendokumentasikan panduan lengkap langkah demi lan
- Pengaturan Environment: Cara setup konfigurasi environment variables `.env` berdasarkan struktur yang sudah dijelaskan di atas. - Pengaturan Environment: Cara setup konfigurasi environment variables `.env` berdasarkan struktur yang sudah dijelaskan di atas.
- Eksekusi Migrasi Database: Perintah terminal yang wajib dijalankan secara berurutan untuk sinkronisasi database dan mengaktifkan Prisma Client (contoh: `npx prisma generate` lalu `npx prisma db push`). - Eksekusi Migrasi Database: Perintah terminal yang wajib dijalankan secara berurutan untuk sinkronisasi database dan mengaktifkan Prisma Client (contoh: `npx prisma generate` lalu `npx prisma db push`).
- Menjalankan Aplikasi: Cara menjalankan server lokal (development mode) dan URL default yang bisa diakses di browser. - Menjalankan Aplikasi: Cara menjalankan server lokal (development mode) dan URL default yang bisa diakses di browser.
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+4574 -394
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -7,7 +7,8 @@
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"postinstall": "prisma skills sync || exit 0" "postinstall": "prisma skills sync || exit 0",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css}\""
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
@@ -45,6 +46,8 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.3", "eslint-config-next": "16.3.3",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.23.12", "tsx": "^4.23.12",
@@ -53,4 +56,4 @@
"prisma": { "prisma": {
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
} }
} }
+39 -11
View File
@@ -8,17 +8,20 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
username String @unique username String @unique
name String name String
password String password String
photo String? photo String?
role String @default("user") role String @default("user")
is_active Boolean @default(true) is_active Boolean @default(true)
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
orders Order[] @relation("CreatedOrders") orders Order[] @relation("CreatedOrders")
purchases Submission[] purchases Submission[]
creator_withdrawals BalanceWithdrawal[] @relation("CreatorWithdrawals")
user_withdrawals BalanceWithdrawal[] @relation("UserWithdrawals")
notification_config UserNotificationConfig?
} }
model Order { model Order {
@@ -55,6 +58,7 @@ model Submission {
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
paid_amount Int? paid_amount Int?
saldo_used Int @default(0)
order Order @relation(fields: [order_id], references: [id], onDelete: Cascade) order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
items SubmissionItem[] items SubmissionItem[]
@@ -64,6 +68,7 @@ model SubmissionItem {
id String @id @default(uuid()) id String @id @default(uuid())
submission_id String submission_id String
name String name String
note String?
qty Int @default(1) qty Int @default(1)
is_custom Boolean @default(false) is_custom Boolean @default(false)
created_at DateTime @default(now()) created_at DateTime @default(now())
@@ -76,3 +81,26 @@ model Setting {
value String value String
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
} }
model BalanceWithdrawal {
id String @id @default(uuid())
creator_id String
user_id String
amount Int
note String?
created_at DateTime @default(now())
creator User @relation("CreatorWithdrawals", fields: [creator_id], references: [id], onDelete: Cascade)
user User @relation("UserWithdrawals", fields: [user_id], references: [id], onDelete: Cascade)
}
model UserNotificationConfig {
id String @id @default(uuid())
user_id String @unique
mattermost_channel_id String?
telegram_chat_id String?
whatsapp_number String?
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
}
+694 -83
View File
@@ -1,10 +1,36 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { getBalancesAsCreator, getBalancesAsSubmittor, getSessionUser } from '@/app/actions' import {
getBalancesAsCreator,
getBalancesAsSubmittor,
getSessionUser,
createWithdrawal,
getWithdrawalHistory,
deleteWithdrawal,
} from '@/app/actions'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Wallet, ArrowDownRight, ArrowUpRight, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import {
Wallet,
ArrowDownRight,
ArrowUpRight,
Loader2,
Banknote,
History,
Trash2,
ChevronDown,
ChevronUp,
AlertTriangle,
CheckCircle2,
FileText,
} from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { format } from 'date-fns'
import { id as idLocale } from 'date-fns/locale'
export default function BalancesPage() { export default function BalancesPage() {
const [activeTab, setActiveTab] = useState<'SUBMITTOR' | 'CREATOR'>('SUBMITTOR') const [activeTab, setActiveTab] = useState<'SUBMITTOR' | 'CREATOR'>('SUBMITTOR')
@@ -13,6 +39,24 @@ export default function BalancesPage() {
const [creatorBalances, setCreatorBalances] = useState<any[]>([]) const [creatorBalances, setCreatorBalances] = useState<any[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Modal state
const [withdrawTarget, setWithdrawTarget] = useState<any | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [withdrawAmount, setWithdrawAmount] = useState('')
const [withdrawNote, setWithdrawNote] = useState('')
const [saving, setSaving] = useState(false)
const [modalError, setModalError] = useState('')
// History state per user
const [historyMap, setHistoryMap] = useState<Record<string, any[]>>({})
const [expandedHistory, setExpandedHistory] = useState<Record<string, boolean>>({})
const [loadingHistory, setLoadingHistory] = useState<Record<string, boolean>>({})
const [deletingId, setDeletingId] = useState<string | null>(null)
// Delete confirm
const [deleteTarget, setDeleteTarget] = useState<{ id: string; amount: number } | null>(null)
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
const user = await getSessionUser() const user = await getSessionUser()
@@ -28,7 +72,7 @@ export default function BalancesPage() {
setLoading(true) setLoading(true)
const [subRes, creRes] = await Promise.all([ const [subRes, creRes] = await Promise.all([
getBalancesAsSubmittor(id), getBalancesAsSubmittor(id),
getBalancesAsCreator(id) getBalancesAsCreator(id),
]) ])
setSubmittorBalances(subRes) setSubmittorBalances(subRes)
setCreatorBalances(creRes) setCreatorBalances(creRes)
@@ -36,29 +80,110 @@ export default function BalancesPage() {
} }
const formatRupiah = (n: number) => const formatRupiah = (n: number) =>
new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(n) new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(n)
const openWithdrawModal = (e: React.MouseEvent, balance: any) => {
e.stopPropagation()
setWithdrawTarget(balance)
setWithdrawAmount(String(balance.amount))
setWithdrawNote('')
setModalError('')
setModalOpen(true)
}
const handleWithdraw = async () => {
if (!withdrawTarget || !userId) return
const amount = parseInt(withdrawAmount.replace(/\D/g, '')) || 0
if (amount <= 0) {
setModalError('Nominal harus lebih dari 0')
return
}
if (amount > withdrawTarget.amount) {
setModalError(`Tidak boleh melebihi saldo tersedia (${formatRupiah(withdrawTarget.amount)})`)
return
}
setSaving(true)
setModalError('')
const res = await createWithdrawal({
creator_id: userId,
user_id: withdrawTarget.user.id,
amount,
note: withdrawNote.trim() || undefined,
})
setSaving(false)
if (res.success) {
setModalOpen(false)
loadData(userId)
if (expandedHistory[withdrawTarget.user.id]) {
loadHistory(withdrawTarget.user.id)
}
} else {
setModalError(res.error || 'Gagal mencatat pengembalian')
}
}
const loadHistory = async (targetUserId: string) => {
if (!userId) return
setLoadingHistory((prev) => ({ ...prev, [targetUserId]: true }))
const history = await getWithdrawalHistory(userId, targetUserId)
setHistoryMap((prev) => ({ ...prev, [targetUserId]: history }))
setLoadingHistory((prev) => ({ ...prev, [targetUserId]: false }))
}
const toggleHistory = (targetUserId: string) => {
const willOpen = !expandedHistory[targetUserId]
setExpandedHistory((prev) => ({ ...prev, [targetUserId]: willOpen }))
if (willOpen && !historyMap[targetUserId]) {
loadHistory(targetUserId)
}
}
const confirmDelete = (id: string, amount: number) => {
setDeleteTarget({ id, amount })
setDeleteModalOpen(true)
}
const handleDelete = async () => {
if (!deleteTarget || !userId) return
setDeletingId(deleteTarget.id)
const res = await deleteWithdrawal(deleteTarget.id)
setDeletingId(null)
setDeleteModalOpen(false)
if (res.success) {
loadData(userId)
Object.keys(expandedHistory).forEach((uid) => {
if (expandedHistory[uid]) loadHistory(uid)
})
} else {
alert(res.error || 'Gagal menghapus')
}
}
return ( return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12"> <div className="animate-in fade-in space-y-6 pb-12 duration-500">
<div> <div>
<span className="text-xs font-semibold text-slate-400">Keuangan / Saldo</span> <span className="text-xs font-semibold text-slate-400">Keuangan / Saldo</span>
<h2 className="text-xl font-black text-slate-900 dark:text-white flex items-center gap-2 tracking-tight"> <h2 className="flex items-center gap-2 text-xl font-black tracking-tight text-slate-900 dark:text-white">
<Wallet className="w-6 h-6 text-[#1B2CC1]" /> <Wallet className="h-6 w-6 text-[#1B2CC1]" />
Buku Saldo Buku Saldo
</h2> </h2>
<p className="text-sm text-slate-500 mt-1"> <p className="mt-1 text-sm text-slate-500">
Pantau riwayat saldo lebih atau kurang dari transaksi pesanan. Pantau riwayat saldo lebih atau kurang dari transaksi pesanan.
</p> </p>
</div> </div>
<div className="flex bg-slate-100 dark:bg-slate-900 p-1 rounded-xl w-full max-w-sm border border-slate-200 dark:border-slate-800"> <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 <button
onClick={() => setActiveTab('SUBMITTOR')} onClick={() => setActiveTab('SUBMITTOR')}
className={cn( className={cn(
"flex-1 text-xs font-bold py-2 rounded-lg transition-all", 'flex-1 rounded-lg py-2 text-xs font-bold transition-all',
activeTab === 'SUBMITTOR' activeTab === 'SUBMITTOR'
? "bg-white dark:bg-slate-800 shadow-sm text-slate-900 dark:text-white" ? '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" : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)} )}
> >
Saldo Saya (Penitip) Saldo Saya (Penitip)
@@ -66,10 +191,10 @@ export default function BalancesPage() {
<button <button
onClick={() => setActiveTab('CREATOR')} onClick={() => setActiveTab('CREATOR')}
className={cn( className={cn(
"flex-1 text-xs font-bold py-2 rounded-lg transition-all", 'flex-1 rounded-lg py-2 text-xs font-bold transition-all',
activeTab === 'CREATOR' activeTab === 'CREATOR'
? "bg-white dark:bg-slate-800 shadow-sm text-slate-900 dark:text-white" ? '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" : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)} )}
> >
Saldo Orang (Kreator) Saldo Orang (Kreator)
@@ -77,100 +202,586 @@ export default function BalancesPage() {
</div> </div>
{loading ? ( {loading ? (
<div className="flex flex-col items-center justify-center min-h-[30vh] gap-3"> <div className="flex min-h-[30vh] flex-col items-center justify-center gap-3">
<Loader2 className="animate-spin text-[#1B2CC1] w-8 h-8" /> <Loader2 className="h-8 w-8 animate-spin text-[#1B2CC1]" />
<span className="text-xs text-slate-500 font-semibold">Memuat saldo...</span> <span className="text-xs font-semibold text-slate-500">Memuat saldo...</span>
</div> </div>
) : activeTab === 'SUBMITTOR' ? ( ) : activeTab === 'SUBMITTOR' ? (
<div className="space-y-4"> <div className="space-y-5">
<div className="bg-blue-50 dark:bg-blue-950/30 p-4 rounded-xl border border-blue-100 dark:border-blue-900/50 mb-6"> <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="font-bold text-blue-900 dark:text-blue-100 text-sm mb-1">Saldo Anda di Kreator Lain</h3> <h3 className="mb-1 text-sm font-bold text-blue-900 dark:text-blue-100">
<p className="text-xs text-blue-700 dark:text-blue-300 leading-relaxed"> Saldo Anda di Kreator Lain
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. </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. Jika <strong>negatif</strong> (merah), Anda berhutang.
</p> </p>
</div> </div>
{submittorBalances.length === 0 ? ( {submittorBalances.length === 0 ? (
<div className="text-center p-12 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm"> <div className="rounded-2xl border-2 border-dashed border-slate-200 bg-white py-16 text-center dark:border-slate-800 dark:bg-slate-900">
<p className="text-xs text-slate-500 font-medium">Belum ada catatan saldo.</p> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
<Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
</div>
<p className="text-sm font-semibold text-slate-500">Belum ada catatan saldo</p>
<p className="mt-1 text-xs text-slate-400">
Saldo muncul saat ada sisa atau kekurangan bayar dari PO.
</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="space-y-3">
{submittorBalances.map((b, i) => ( {submittorBalances.map((b, i) => {
<Card key={i} className="rounded-2xl border-slate-200/90 dark:border-slate-800 overflow-hidden hover:border-[#1B2CC1]/30 transition-all shadow-sm"> const isPositive = b.amount > 0
<div className="p-4 flex items-center gap-3 bg-slate-50/50 dark:bg-slate-800/40 border-b border-slate-100 dark:border-slate-800">
{b.creator.photo ? ( return (
<img src={b.creator.photo} alt={b.creator.name} className="w-10 h-10 rounded-full object-cover ring-2 ring-white dark:ring-slate-900 shadow-sm" /> <div
) : ( key={i}
<div className="w-10 h-10 rounded-full bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center font-bold text-sm ring-2 ring-white dark:ring-slate-900 shadow-sm"> className={cn(
{b.creator.name.charAt(0).toUpperCase()} 'overflow-hidden rounded-2xl border bg-white shadow-sm dark:bg-slate-900',
</div> isPositive
? 'border-emerald-100 dark:border-emerald-900/40'
: 'border-rose-100 dark:border-rose-900/40'
)} )}
<div> >
<p className="font-bold text-slate-900 dark:text-white text-sm">{b.creator.name}</p> <div className="flex items-stretch">
<p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wider">Kreator</p> {/* Left accent strip */}
<div
className={cn(
'w-1 shrink-0',
isPositive ? 'bg-emerald-400' : 'bg-rose-400'
)}
/>
<div className="flex flex-1 flex-col gap-0">
{/* Main info row */}
<div className="flex items-center justify-between gap-4 px-4 py-4">
{/* Avatar + name */}
<div className="flex min-w-0 items-center gap-3">
<div className="relative shrink-0">
{b.creator.photo ? (
<img
src={b.creator.photo}
alt={b.creator.name}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full text-sm font-black',
isPositive
? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400'
: 'bg-rose-100 text-rose-600 dark:bg-rose-950/60 dark:text-rose-400'
)}
>
{b.creator.name.charAt(0).toUpperCase()}
</div>
)}
{/* Status dot */}
<span
className={cn(
'absolute -right-0.5 -bottom-0.5 h-3 w-3 rounded-full border-2 border-white dark:border-slate-900',
isPositive ? 'bg-emerald-400' : 'bg-rose-400'
)}
/>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-bold text-slate-900 dark:text-white">
{b.creator.name}
</p>
<p className="text-[10px] font-medium text-slate-400">Kreator</p>
</div>
</div>
{/* Amount */}
<div className="shrink-0 text-right">
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
Total Saldo
</p>
<p
className={cn(
'text-xl font-black tabular-nums',
isPositive
? 'text-emerald-600 dark:text-emerald-400'
: 'text-rose-600 dark:text-rose-400'
)}
>
{formatRupiah(Math.abs(b.amount))}
</p>
</div>
</div>
{/* Footer */}
<div
className={cn(
'flex items-center justify-between gap-2 border-t px-4 py-2.5',
isPositive
? 'border-emerald-50 bg-emerald-50/60 dark:border-emerald-900/30 dark:bg-emerald-950/20'
: 'border-rose-50 bg-rose-50/60 dark:border-rose-900/30 dark:bg-rose-950/20'
)}
>
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-bold',
isPositive
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-400'
: 'bg-rose-100 text-rose-700 dark:bg-rose-950/60 dark:text-rose-400'
)}
>
{isPositive ? (
<ArrowUpRight className="h-3 w-3" />
) : (
<ArrowDownRight className="h-3 w-3" />
)}
{isPositive ? 'Deposit Tersedia' : 'Anda Berhutang'}
</span>
{isPositive && (
<p className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
âś“ Bisa dipakai untuk PO
</p>
)}
</div>
</div>
</div> </div>
</div> </div>
<CardContent className="p-4 bg-white dark:bg-slate-900"> )
<div className="flex flex-col gap-1"> })}
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">Total Saldo</span>
<div className={cn("text-xl font-black flex items-center gap-1.5", b.amount > 0 ? "text-emerald-600" : "text-rose-600")}>
{b.amount > 0 ? <ArrowUpRight className="w-5 h-5" /> : <ArrowDownRight className="w-5 h-5" />}
{formatRupiah(b.amount)}
</div>
</div>
</CardContent>
</Card>
))}
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="space-y-4"> /* TAB CREATOR */
<div className="bg-amber-50 dark:bg-amber-950/30 p-4 rounded-xl border border-amber-100 dark:border-amber-900/50 mb-6"> <div className="space-y-5">
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-sm mb-1">Saldo Orang Lain di Anda</h3> <div className="rounded-xl border border-amber-100 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
<p className="text-xs text-amber-700 dark:text-amber-300 leading-relaxed"> <h3 className="mb-1 text-sm font-bold text-amber-900 dark:text-amber-100">
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 Orang Lain di Anda
</h3>
<p className="text-xs leading-relaxed text-amber-700 dark:text-amber-300">
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> </p>
</div> </div>
{creatorBalances.length === 0 ? ( {creatorBalances.length === 0 ? (
<div className="text-center p-12 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm"> <div className="rounded-2xl border-2 border-dashed border-slate-200 bg-white py-16 text-center dark:border-slate-800 dark:bg-slate-900">
<p className="text-xs text-slate-500 font-medium">Belum ada penitip yang memiliki catatan saldo dengan Anda.</p> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
<Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
</div>
<p className="text-sm font-semibold text-slate-500">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>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="space-y-3">
{creatorBalances.map((b, i) => ( {creatorBalances.map((b, i) => {
<Card key={i} className="rounded-2xl border-slate-200/90 dark:border-slate-800 overflow-hidden hover:border-[#1B2CC1]/30 transition-all shadow-sm"> const uid = b.user.id
<div className="p-4 flex items-center gap-3 bg-slate-50/50 dark:bg-slate-800/40 border-b border-slate-100 dark:border-slate-800"> const isExpanded = expandedHistory[uid]
{b.user.photo ? ( const history = historyMap[uid] || []
<img src={b.user.photo} alt={b.user.name} className="w-10 h-10 rounded-full object-cover ring-2 ring-white dark:ring-slate-900 shadow-sm" /> const isLoadingHist = loadingHistory[uid]
) : ( const isPositive = b.amount > 0
<div className="w-10 h-10 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300 flex items-center justify-center font-bold text-sm ring-2 ring-white dark:ring-slate-900 shadow-sm">
{b.user.name.charAt(0).toUpperCase()} return (
</div> <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="font-bold text-slate-900 dark:text-white text-sm">{b.user.name}</p> <div className="flex items-stretch">
<p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wider">Penitip</p> {/* Left accent strip */}
<div
className={cn(
'w-1 shrink-0',
isPositive ? 'bg-rose-400' : 'bg-emerald-400'
)}
/>
<div className="flex flex-1 flex-col">
{/* Main info row */}
<div className="flex items-center justify-between gap-4 px-4 py-4">
{/* Avatar + name */}
<div className="flex min-w-0 items-center gap-3">
<div className="relative shrink-0">
{b.user.photo ? (
<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>
)}
</div>
</div> </div>
</div> </div>
<CardContent className="p-4 bg-white dark:bg-slate-900"> )
<div className="flex flex-col gap-1"> })}
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">Saldo Penitip</span>
{/* From Creator perspective: positive balance of submittor is bad for creator (creator holds their money) */}
<div className={cn("text-xl font-black flex items-center gap-1.5", b.amount > 0 ? "text-rose-600" : "text-emerald-600")}>
{b.amount > 0 ? <ArrowDownRight className="w-5 h-5" /> : <ArrowUpRight className="w-5 h-5" />}
{formatRupiah(b.amount)}
</div>
</div>
</CardContent>
</Card>
))}
</div> </div>
)} )}
</div> </div>
)} )}
{/* MODAL PENGEMBALIAN */}
<Dialog open={modalOpen} onOpenChange={(o) => !o && setModalOpen(false)}>
<DialogContent className="overflow-hidden rounded-3xl border-slate-200/90 p-0 shadow-2xl sm:max-w-[440px] dark:border-slate-800">
<div className="flex items-center gap-3 border-b border-[#1B2CC1]/10 bg-[#1B2CC1]/5 p-5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#1B2CC1] text-white shadow-md shadow-[#1B2CC1]/20">
<Banknote className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base font-black text-slate-900 dark:text-white">
Kembalikan Saldo
</DialogTitle>
<p className="text-xs text-slate-500">
Catat pengembalian uang ke {withdrawTarget?.user?.name}
</p>
</div>
</div>
<div className="space-y-4 bg-white p-5 dark:bg-slate-900">
{withdrawTarget && (
<div className="flex items-center gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-800/50">
{withdrawTarget.user.photo ? (
<img
src={withdrawTarget.user.photo}
alt={withdrawTarget.user.name}
className="h-9 w-9 rounded-full object-cover ring-2 ring-white dark:ring-slate-900"
/>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-200 text-sm font-bold text-slate-600 dark:bg-slate-700 dark:text-slate-300">
{withdrawTarget.user.name.charAt(0).toUpperCase()}
</div>
)}
<div>
<p className="text-sm font-bold text-slate-900 dark:text-white">
{withdrawTarget.user.name}
</p>
<p className="text-xs text-slate-500">
Saldo tersedia:{' '}
<span className="font-bold text-rose-600">
{formatRupiah(withdrawTarget?.amount || 0)}
</span>
</p>
</div>
</div>
)}
<div className="space-y-1.5">
<Label className="text-[11px] font-bold tracking-wider text-slate-500 uppercase">
Nominal Pengembalian
</Label>
<div className="relative">
<span className="absolute top-1/2 left-3 -translate-y-1/2 text-xs font-bold text-slate-400">
Rp
</span>
<Input
type="number"
placeholder="0"
value={withdrawAmount}
onChange={(e) => {
setWithdrawAmount(e.target.value)
setModalError('')
}}
className="h-11 pl-8 text-sm font-bold"
min={1}
max={withdrawTarget?.amount}
/>
</div>
{withdrawTarget && (
<div className="flex gap-2 pt-1">
<button
onClick={() => setWithdrawAmount(String(withdrawTarget.amount))}
className="rounded-lg border border-[#1B2CC1]/20 bg-[#1B2CC1]/5 px-2.5 py-1 text-[10px] font-bold text-[#1B2CC1] hover:bg-[#1B2CC1]/10 dark:border-[#1B2CC1]/30"
>
Semua ({formatRupiah(withdrawTarget.amount)})
</button>
{withdrawTarget.amount >= 2 && (
<button
onClick={() =>
setWithdrawAmount(String(Math.floor(withdrawTarget.amount / 2)))
}
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1 text-[10px] font-bold text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400"
>
Setengah ({formatRupiah(Math.floor(withdrawTarget.amount / 2))})
</button>
)}
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-bold tracking-wider text-slate-500 uppercase">
Catatan <span className="font-normal text-slate-400 normal-case">(opsional)</span>
</Label>
<Input
placeholder="Contoh: Transfer BCA, Cash langsung, dll"
value={withdrawNote}
onChange={(e) => setWithdrawNote(e.target.value)}
className="h-10 text-sm"
maxLength={100}
/>
</div>
{modalError && (
<div className="flex items-start gap-2 rounded-xl border border-rose-200 bg-rose-50 p-3 dark:border-rose-800 dark:bg-rose-950/30">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-rose-500" />
<p className="text-xs font-semibold text-rose-600 dark:text-rose-400">
{modalError}
</p>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
onClick={() => setModalOpen(false)}
disabled={saving}
className="h-10 flex-1 rounded-xl font-bold"
>
Batal
</Button>
<Button
onClick={handleWithdraw}
disabled={saving || !withdrawAmount}
className="h-10 flex-1 gap-2 rounded-xl bg-[#1B2CC1] font-bold text-white shadow-md shadow-[#1B2CC1]/20 hover:bg-[#15229E] disabled:opacity-60"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Menyimpan...
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" />
Simpan Pengembalian
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* MODAL DELETE CONFIRM */}
<Dialog open={deleteModalOpen} onOpenChange={(o) => !o && setDeleteModalOpen(false)}>
<DialogContent className="overflow-hidden rounded-3xl border-slate-200/90 p-0 shadow-2xl sm:max-w-[400px] dark:border-slate-800">
<div className="flex items-center gap-3 border-b border-rose-100 bg-rose-50 p-5 dark:border-rose-900/50 dark:bg-rose-950/30">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-600 text-white shadow-md shadow-rose-600/20">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base font-black text-slate-900 dark:text-white">
Hapus Riwayat?
</DialogTitle>
<p className="text-xs text-slate-500">Saldo akan kembali bertambah</p>
</div>
</div>
<div className="space-y-4 bg-white p-5 dark:bg-slate-900">
<div className="rounded-xl border border-slate-100 bg-slate-50 p-3 text-center dark:border-slate-800 dark:bg-slate-800/50">
<p className="text-xs text-slate-500">Pengembalian sebesar</p>
<p className="text-lg font-black text-slate-900 dark:text-white">
{formatRupiah(deleteTarget?.amount || 0)}
</p>
<p className="mt-1 text-xs text-slate-500">akan dihapus dari riwayat.</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDeleteModalOpen(false)}
disabled={!!deletingId}
className="h-10 flex-1 rounded-xl font-bold"
>
Batal
</Button>
<Button
onClick={handleDelete}
disabled={!!deletingId}
className="h-10 flex-1 gap-2 rounded-xl bg-rose-600 font-bold text-white hover:bg-rose-700"
>
{deletingId ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Menghapus...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Ya, Hapus
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
+2 -6
View File
@@ -1,9 +1,5 @@
import { AppLayout } from "@/components/AppLayout"; import { AppLayout } from '@/components/AppLayout'
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return <AppLayout>{children}</AppLayout>
<AppLayout>
{children}
</AppLayout>
);
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -16,18 +16,18 @@ export function PublicOrderActions({ order }: { order: any }) {
} }
return ( return (
<div className="flex flex-row justify-center items-center gap-2 mt-5"> <div className="mt-5 flex flex-row items-center justify-center gap-2">
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="h-10 px-5 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold text-sm shadow-md shadow-[#1B2CC1]/20 gap-2 flex items-center justify-center transition-all cursor-pointer"> <DialogTrigger className="flex h-10 cursor-pointer items-center justify-center gap-2 rounded-xl bg-[#1B2CC1] px-5 text-sm font-bold text-white shadow-md shadow-[#1B2CC1]/20 transition-all hover:bg-[#15229E]">
<ShoppingBag className="w-4 h-4" /> <ShoppingBag className="h-4 w-4" />
Titip Sekarang Titip Sekarang
</DialogTrigger> </DialogTrigger>
{open && <OrderFormModal order={order} onSuccess={() => setOpen(false)} />} {open && <OrderFormModal order={order} onSuccess={() => setOpen(false)} />}
</Dialog> </Dialog>
<ShareButton <ShareButton
orderId={order.id} orderId={order.id}
className="h-10 px-4 rounded-xl border-slate-200 dark:border-slate-700 font-bold text-sm gap-2 hover:bg-slate-50 dark:hover:bg-slate-800 shadow-sm text-slate-700 dark:text-slate-200" className="h-10 gap-2 rounded-xl border-slate-200 px-4 text-sm font-bold text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
showText={true} showText={true}
/> />
</div> </div>
+99 -62
View File
@@ -10,10 +10,14 @@ import { cn } from '@/lib/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function PublicOrderDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function PublicOrderDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params const { id } = await params
const session = await getSessionUser() const session = await getSessionUser()
if (!session) { if (!session) {
redirect(`/login?callbackUrl=/order/${id}`) redirect(`/login?callbackUrl=/order/${id}`)
} }
@@ -25,48 +29,59 @@ export default async function PublicOrderDetailPage({ params }: { params: Promis
} }
return ( return (
<div className="space-y-6 max-w-4xl mx-auto pb-20 animate-in fade-in duration-500"> <div className="animate-in fade-in mx-auto max-w-4xl space-y-6 pb-20 duration-500">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/" className="p-2 -ml-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-500 transition-colors"> <Link
<ChevronLeft className="w-5 h-5" /> href="/"
className="-ml-2 rounded-xl p-2 text-slate-500 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<ChevronLeft className="h-5 w-5" />
</Link> </Link>
<h1 className="text-xl font-black text-slate-900 dark:text-white tracking-tight">Detail PO</h1> <h1 className="text-xl font-black tracking-tight text-slate-900 dark:text-white">
Detail PO
</h1>
</div> </div>
{/* Hero Card */} {/* Hero Card */}
<div className="relative overflow-hidden rounded-3xl bg-white dark:bg-slate-900 shadow-sm border border-slate-200/60 dark:border-slate-800"> <div className="relative overflow-hidden rounded-3xl border border-slate-200/60 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="absolute top-0 left-0 w-full h-24 bg-gradient-to-r from-[#1B2CC1] to-[#121E85]" /> <div className="absolute top-0 left-0 h-24 w-full bg-gradient-to-r from-[#1B2CC1] to-[#121E85]" />
<div className="relative pt-10 px-5 pb-5 sm:px-6 sm:pb-6 flex flex-col items-center text-center"> <div className="relative flex flex-col items-center px-5 pt-10 pb-5 text-center sm:px-6 sm:pb-6">
<div className="w-20 h-20 rounded-2xl bg-white dark:bg-slate-900 shadow-md border-4 border-white dark:border-slate-900 flex items-center justify-center mb-3"> <div className="mb-3 flex h-20 w-20 items-center justify-center rounded-2xl border-4 border-white bg-white shadow-md dark:border-slate-900 dark:bg-slate-900">
{order.creator.photo ? ( {order.creator.photo ? (
<img src={order.creator.photo} alt={order.creator.name} className="w-full h-full object-cover rounded-xl" /> <img
src={order.creator.photo}
alt={order.creator.name}
className="h-full w-full rounded-xl object-cover"
/>
) : ( ) : (
<Store className="w-8 h-8 text-[#1B2CC1]" /> <Store className="h-8 w-8 text-[#1B2CC1]" />
)} )}
</div> </div>
<h2 className="text-xl sm:text-2xl font-black text-slate-900 dark:text-white mb-2 leading-tight"> <h2 className="mb-2 text-xl leading-tight font-black text-slate-900 sm:text-2xl dark:text-white">
{order.title} {order.title}
</h2> </h2>
<div className="flex flex-wrap justify-center items-center gap-3 text-sm font-semibold text-slate-600 dark:text-slate-400"> <div className="flex flex-wrap items-center justify-center gap-3 text-sm font-semibold text-slate-600 dark:text-slate-400">
<span className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800 px-3 py-1 rounded-full"> <span className="flex items-center gap-1.5 rounded-full bg-slate-100 px-3 py-1 dark:bg-slate-800">
<User className="w-4 h-4 text-[#1B2CC1]" /> <User className="h-4 w-4 text-[#1B2CC1]" />
{order.creator.name} {order.creator.name}
</span> </span>
<span className={cn( <span
"flex items-center gap-1.5 px-3 py-1 rounded-full border", className={cn(
order.status === 'OPEN' 'flex items-center gap-1.5 rounded-full border px-3 py-1',
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 border-emerald-100 dark:border-emerald-900/50" order.status === 'OPEN'
: "bg-rose-50 text-rose-700 dark:bg-rose-950/30 dark:text-rose-400 border-rose-100 dark:border-rose-900/50" ? 'border-emerald-100 bg-emerald-50 text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-400'
)}> : 'border-rose-100 bg-rose-50 text-rose-700 dark:border-rose-900/50 dark:bg-rose-950/30 dark:text-rose-400'
<CheckCircle2 className="w-4 h-4" /> )}
>
<CheckCircle2 className="h-4 w-4" />
{order.status} {order.status}
</span> </span>
{order.allow_custom && ( {order.allow_custom && (
<span className="flex items-center gap-1.5 bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 px-3 py-1 rounded-full border border-blue-100 dark:border-blue-900/50"> <span className="flex items-center gap-1.5 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-blue-700 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-400">
<Sparkles className="w-4 h-4" /> <Sparkles className="h-4 w-4" />
Custom Item Custom Item
</span> </span>
)} )}
@@ -74,46 +89,54 @@ export default async function PublicOrderDetailPage({ params }: { params: Promis
<PublicOrderActions order={order} /> <PublicOrderActions order={order} />
</div> </div>
<div className="bg-slate-50 dark:bg-slate-800/50 border-t border-slate-100 dark:border-slate-800 p-4 sm:p-5 flex flex-col sm:flex-row divide-y sm:divide-y-0 sm:divide-x divide-slate-200 dark:divide-slate-700"> <div className="flex flex-col divide-y divide-slate-200 border-t border-slate-100 bg-slate-50 p-4 sm:flex-row sm:divide-x sm:divide-y-0 sm:p-5 dark:divide-slate-700 dark:border-slate-800 dark:bg-slate-800/50">
<div className="flex-1 py-3 sm:py-0 sm:px-4 text-center"> <div className="flex-1 py-3 text-center sm:px-4 sm:py-0">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Tanggal PO</p> <p className="mb-0.5 text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Tanggal PO
</p>
<p className="text-base font-black text-slate-800 dark:text-slate-200"> <p className="text-base font-black text-slate-800 dark:text-slate-200">
{format(new Date(order.date), 'dd MMM yyyy', { locale: idLocale })} {format(new Date(order.date), 'dd MMM yyyy', { locale: idLocale })}
</p> </p>
</div> </div>
<div className="flex-1 py-3 sm:py-0 sm:px-4 text-center"> <div className="flex-1 py-3 text-center sm:px-4 sm:py-0">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Total Orang</p> <p className="mb-0.5 text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Total Orang
</p>
<p className="text-base font-black text-slate-800 dark:text-slate-200"> <p className="text-base font-black text-slate-800 dark:text-slate-200">
{order.submissions.length} <span className="text-xs font-semibold text-slate-500">menitip</span> {order.submissions.length}{' '}
<span className="text-xs font-semibold text-slate-500">menitip</span>
</p> </p>
</div> </div>
<div className="flex-1 py-3 sm:py-0 sm:px-4 text-center"> <div className="flex-1 py-3 text-center sm:px-4 sm:py-0">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Menu Tersedia</p> <p className="mb-0.5 text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Menu Tersedia
</p>
<p className="text-base font-black text-slate-800 dark:text-slate-200"> <p className="text-base font-black text-slate-800 dark:text-slate-200">
{order.available_items.length} <span className="text-xs font-semibold text-slate-500">item</span> {order.available_items.length}{' '}
<span className="text-xs font-semibold text-slate-500">item</span>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start"> <div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-3">
{/* Left 2 Cols: Daftar Penitip & Description */} {/* Left 2 Cols: Daftar Penitip & Description */}
<div className="lg:col-span-2 space-y-6"> <div className="space-y-6 lg:col-span-2">
{order.description && ( {order.description && (
<div className="bg-white dark:bg-slate-900 rounded-3xl border border-slate-200/60 dark:border-slate-800 shadow-sm overflow-hidden p-5 sm:p-6"> <div className="overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
<h3 className="font-bold text-slate-900 dark:text-white mb-3">Deskripsi</h3> <h3 className="mb-3 font-bold text-slate-900 dark:text-white">Deskripsi</h3>
<div className="text-slate-600 dark:text-slate-300 text-sm leading-relaxed whitespace-pre-wrap"> <div className="text-sm leading-relaxed whitespace-pre-wrap text-slate-600 dark:text-slate-300">
{order.description} {order.description}
</div> </div>
</div> </div>
)} )}
<div className="bg-white dark:bg-slate-900 rounded-3xl border border-slate-200/60 dark:border-slate-800 shadow-sm overflow-hidden"> <div className="overflow-hidden rounded-3xl border border-slate-200/60 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="p-5 sm:p-6 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30 flex items-center justify-between"> <div className="flex items-center justify-between border-b border-slate-100 bg-slate-50/50 p-5 sm:p-6 dark:border-slate-800 dark:bg-slate-800/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center"> <div className="flex h-8 w-8 items-center justify-center rounded-xl bg-[#1B2CC1]/10 text-[#1B2CC1]">
<Users className="w-4 h-4" /> <Users className="h-4 w-4" />
</div> </div>
<h3 className="font-bold text-slate-900 dark:text-white">Daftar Penitip</h3> <h3 className="font-bold text-slate-900 dark:text-white">Daftar Penitip</h3>
</div> </div>
@@ -122,28 +145,40 @@ export default async function PublicOrderDetailPage({ params }: { params: Promis
<div className="divide-y divide-slate-100 dark:divide-slate-800"> <div className="divide-y divide-slate-100 dark:divide-slate-800">
{order.submissions.length > 0 ? ( {order.submissions.length > 0 ? (
order.submissions.map((sub: any) => ( order.submissions.map((sub: any) => (
<div key={sub.id} className="p-4 sm:p-6 flex items-start gap-4 hover:bg-slate-50/50 dark:hover:bg-slate-800/20 transition-colors"> <div
key={sub.id}
className="flex items-start gap-4 p-4 transition-colors hover:bg-slate-50/50 sm:p-6 dark:hover:bg-slate-800/20"
>
{sub.user.photo ? ( {sub.user.photo ? (
<img src={sub.user.photo} alt={sub.user.name} className="w-10 h-10 rounded-full object-cover shrink-0" /> <img
src={sub.user.photo}
alt={sub.user.name}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : ( ) : (
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500 flex items-center justify-center font-bold text-sm shrink-0"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-sm font-bold text-slate-500 dark:bg-slate-800">
{sub.user.name.charAt(0).toUpperCase()} {sub.user.name.charAt(0).toUpperCase()}
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<h4 className="font-bold text-slate-800 dark:text-slate-200 text-sm mb-2">{sub.user.name}</h4> <h4 className="mb-2 text-sm font-bold text-slate-800 dark:text-slate-200">
{sub.user.name}
</h4>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{sub.items.map((item: any) => ( {sub.items.map((item: any) => (
<span <span
key={item.id} key={item.id}
className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium border ${ className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium ${
item.is_custom item.is_custom
? 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-800' ? 'border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700' : 'border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300'
}`} }`}
> >
{item.qty}x {item.name} {item.qty}x {item.name}{' '}
{item.note && (
<span className="ml-0.5 italic opacity-70">({item.note})</span>
)}
</span> </span>
))} ))}
</div> </div>
@@ -152,8 +187,10 @@ export default async function PublicOrderDetailPage({ params }: { params: Promis
)) ))
) : ( ) : (
<div className="p-12 text-center"> <div className="p-12 text-center">
<p className="text-slate-500 font-medium">Belum ada yang menitip.</p> <p className="font-medium text-slate-500">Belum ada yang menitip.</p>
<p className="text-xs text-slate-400 mt-1">Jadilah yang pertama untuk menitip pesanan!</p> <p className="mt-1 text-xs text-slate-400">
Jadilah yang pertama untuk menitip pesanan!
</p>
</div> </div>
)} )}
</div> </div>
+35 -31
View File
@@ -11,37 +11,38 @@ export default async function Dashboard() {
const orders = await getAvailableOrders() const orders = await getAvailableOrders()
return ( return (
<div className="space-y-8 animate-in fade-in duration-500"> <div className="animate-in fade-in space-y-8 duration-500">
{/* Banner / Hero Section */} {/* Banner / Hero Section */}
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#1B2CC1] via-[#2135E0] to-[#121E85] p-6 sm:p-8 text-white shadow-xl shadow-[#1B2CC1]/15"> <div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#1B2CC1] via-[#2135E0] to-[#121E85] p-6 text-white shadow-xl shadow-[#1B2CC1]/15 sm:p-8">
<div className="absolute right-0 top-0 -mt-10 -mr-10 h-64 w-64 rounded-full bg-white/10 blur-3xl pointer-events-none" /> <div className="pointer-events-none absolute top-0 right-0 -mt-10 -mr-10 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
<div className="relative z-10 max-w-2xl space-y-3"> <div className="relative z-10 max-w-2xl space-y-3">
<div className="inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-bold text-blue-100 backdrop-blur-md border border-white/20"> <div className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/15 px-3 py-1 text-xs font-bold text-blue-100 backdrop-blur-md">
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3.5 w-3.5" />
<span>Sistem Titip & Jastip Cepat</span> <span>Sistem Titip & Jastip Cepat</span>
</div> </div>
<h2 className="text-2xl sm:text-3xl font-black tracking-tight leading-tight"> <h2 className="text-2xl leading-tight font-black tracking-tight sm:text-3xl">
Titip Makanan & Minuman Bareng Teman Kantor Titip Makanan & Minuman Bareng Teman Kantor
</h2> </h2>
<p className="text-sm text-blue-100/90 leading-relaxed max-w-xl"> <p className="max-w-xl text-sm leading-relaxed text-blue-100/90">
Pilih PO yang sedang buka hari ini, tentukan menu favoritmu, dan biarkan pembuat PO mengurus tagihan secara praktis. Pilih PO yang sedang buka hari ini, tentukan menu favoritmu, dan biarkan pembuat PO
mengurus tagihan secara praktis.
</p> </p>
<div className="pt-2 flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3 pt-2">
<Link <Link
href="/my-orders" href="/my-orders"
className={cn( className={cn(
buttonVariants(), buttonVariants(),
"rounded-xl bg-white text-[#1B2CC1] hover:bg-blue-50 font-bold shadow-md h-10 px-5 gap-2" 'h-10 gap-2 rounded-xl bg-white px-5 font-bold text-[#1B2CC1] shadow-md hover:bg-blue-50'
)} )}
> >
<Plus className="w-4 h-4" /> <Plus className="h-4 w-4" />
Buka Jasa PO Baru Buka Jasa PO Baru
</Link> </Link>
<Link <Link
href="/my-purchases" href="/my-purchases"
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: 'outline' }),
"rounded-xl bg-white/10 hover:bg-white/20 text-white border-white/20 font-semibold h-10 px-4" 'h-10 rounded-xl border-white/20 bg-white/10 px-4 font-semibold text-white hover:bg-white/20'
)} )}
> >
Lihat Titipan Saya Lihat Titipan Saya
@@ -51,46 +52,49 @@ export default async function Dashboard() {
</div> </div>
{/* Main Grid Header */} {/* Main Grid Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> <div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-[#1B2CC1] animate-pulse" /> <div className="h-2.5 w-2.5 animate-pulse rounded-full bg-[#1B2CC1]" />
<h2 className="text-xl font-black text-slate-900 dark:text-white tracking-tight"> <h2 className="text-xl font-black tracking-tight text-slate-900 dark:text-white">
Daftar PO Terbuka Hari Ini Daftar PO Terbuka Hari Ini
</h2> </h2>
</div> </div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5"> <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
Pesanan dengan status OPEN yang bisa Anda ikuti hari ini. Pesanan dengan status OPEN yang bisa Anda ikuti hari ini.
</p> </p>
</div> </div>
<span className="text-xs font-bold text-[#1B2CC1] bg-[#1B2CC1]/10 px-3 py-1.5 rounded-xl"> <span className="rounded-xl bg-[#1B2CC1]/10 px-3 py-1.5 text-xs font-bold text-[#1B2CC1]">
Total: {orders.length} PO Aktif Total: {orders.length} PO Aktif
</span> </span>
</div> </div>
{orders.length === 0 ? ( {orders.length === 0 ? (
<div className="flex flex-col items-center justify-center p-12 sm:p-16 text-center rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm"> <div className="flex flex-col items-center justify-center rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm sm:p-16 dark:border-slate-800 dark:bg-slate-900">
<div className="w-16 h-16 bg-[#1B2CC1]/10 rounded-2xl flex items-center justify-center mb-4 text-[#1B2CC1]"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[#1B2CC1]/10 text-[#1B2CC1]">
<Store className="w-8 h-8" /> <Store className="h-8 w-8" />
</div> </div>
<h3 className="text-lg font-bold text-slate-800 dark:text-slate-200 mb-1">Belum Ada PO yang Dibuka Hari Ini</h3> <h3 className="mb-1 text-lg font-bold text-slate-800 dark:text-slate-200">
<p className="text-xs text-slate-500 max-w-md mb-6 leading-relaxed"> Belum Ada PO yang Dibuka Hari Ini
Mau jajan atau beli sesuatu? Jadilah orang pertama yang membuka jasa titip pesanan untuk teman-teman Anda! </h3>
<p className="mb-6 max-w-md text-xs leading-relaxed text-slate-500">
Mau jajan atau beli sesuatu? Jadilah orang pertama yang membuka jasa titip pesanan untuk
teman-teman Anda!
</p> </p>
<Link <Link
href="/my-orders" href="/my-orders"
className={cn( className={cn(
buttonVariants(), buttonVariants(),
"bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold rounded-xl shadow-md shadow-[#1B2CC1]/25 h-10 px-5 gap-2" 'h-10 gap-2 rounded-xl bg-[#1B2CC1] px-5 font-bold text-white shadow-md shadow-[#1B2CC1]/25 hover:bg-[#15229E]'
)} )}
> >
<Plus className="w-4 h-4" /> Buka PO Sekarang <Plus className="h-4 w-4" /> Buka PO Sekarang
</Link> </Link>
</div> </div>
) : ( ) : (
<div className="flex flex-col h-full space-y-4"> <div className="flex h-full flex-col space-y-4">
<div className="flex flex-col space-y-4 max-h-[calc(100vh-260px)] overflow-y-auto pr-2 scrollbar-thin"> <div className="flex max-h-[calc(100vh-260px)] scrollbar-thin flex-col space-y-4 overflow-y-auto pr-2">
{orders.map((order: any) => ( {orders.map((order: any) => (
<OrderCard key={order.id} order={order} /> <OrderCard key={order.id} order={order} />
))} ))}
+261 -80
View File
@@ -1,25 +1,47 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { getSessionUser, updateProfile, changePassword } from '@/app/actions' import { getSessionUser, updateProfile, changePassword, getSettings, getUserNotificationConfig, updateUserNotificationConfig } from '@/app/actions'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import {
import { Loader2, Save, CheckCircle, ShieldCheck, UserCircle, KeyRound, LockKeyhole } from 'lucide-react' Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Loader2,
Save,
CheckCircle,
ShieldCheck,
UserCircle,
KeyRound,
LockKeyhole,
MessageCircle,
} from 'lucide-react'
export default function ProfilePage() { export default function ProfilePage() {
const [user, setUser] = useState<any>(null) const [user, setUser] = useState<any>(null)
const [name, setName] = useState('') const [name, setName] = useState('')
const [photo, setPhoto] = useState('') const [photo, setPhoto] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Notification Config state
const [globalNotifEnabled, setGlobalNotifEnabled] = useState(false)
const [mmChannelId, setMmChannelId] = useState('')
const [mmActive, setMmActive] = useState(true)
const [savingNotif, setSavingNotif] = useState(false)
const [notifSuccess, setNotifSuccess] = useState(false)
// Profile state // Profile state
const [savingProfile, setSavingProfile] = useState(false) const [savingProfile, setSavingProfile] = useState(false)
const [profileError, setProfileError] = useState('') const [profileError, setProfileError] = useState('')
const [profileSuccess, setProfileSuccess] = useState(false) const [profileSuccess, setProfileSuccess] = useState(false)
// Password state // Password state
const [oldPassword, setOldPassword] = useState('') const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
@@ -40,6 +62,20 @@ export default function ProfilePage() {
setUser(sessionUser) setUser(sessionUser)
setName(sessionUser.name) setName(sessionUser.name)
setPhoto(sessionUser.photo || '') setPhoto(sessionUser.photo || '')
const [globalSettings, notifConfig] = await Promise.all([
getSettings(),
getUserNotificationConfig(sessionUser.id)
])
if (globalSettings?.MATTERMOST_NOTIF_ENABLED === 'true') {
setGlobalNotifEnabled(true)
}
if (notifConfig?.success && notifConfig.config) {
setMmChannelId(notifConfig.config.mattermost_channel_id || '')
setMmActive(notifConfig.config.is_active ?? true)
}
} }
setLoading(false) setLoading(false)
} }
@@ -50,19 +86,25 @@ export default function ProfilePage() {
setProfileError('Nama wajib diisi') setProfileError('Nama wajib diisi')
return return
} }
setProfileError('') setProfileError('')
setProfileSuccess(false) setProfileSuccess(false)
setSavingProfile(true) setSavingProfile(true)
const res = await updateProfile(user.id, name.trim(), photo.trim() || null) const [res, notifRes] = await Promise.all([
updateProfile(user.id, name.trim(), photo.trim() || null),
if (res.success) { globalNotifEnabled ? updateUserNotificationConfig(user.id, {
mattermost_channel_id: mmChannelId,
is_active: mmActive
}) : Promise.resolve({ success: true, error: null })
])
if (res.success && notifRes.success) {
setUser(res.user) setUser(res.user)
setProfileSuccess(true) setProfileSuccess(true)
setTimeout(() => setProfileSuccess(false), 3000) setTimeout(() => setProfileSuccess(false), 3000)
} else { } else {
setProfileError(res.error || 'Terjadi kesalahan') setProfileError(res.error || notifRes.error || 'Terjadi kesalahan')
} }
setSavingProfile(false) setSavingProfile(false)
} }
@@ -104,167 +146,306 @@ export default function ProfilePage() {
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3"> <div className="flex min-h-[50vh] flex-col items-center justify-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-[#1B2CC1]" /> <Loader2 className="h-8 w-8 animate-spin text-[#1B2CC1]" />
<span className="text-xs text-slate-500 font-semibold">Memuat profil...</span> <span className="text-xs font-semibold text-slate-500">Memuat profil...</span>
</div> </div>
) )
} }
if (!user) { if (!user) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3"> <div className="flex min-h-[50vh] flex-col items-center justify-center gap-3">
<span className="text-xs text-slate-500 font-semibold">Anda belum login.</span> <span className="text-xs font-semibold text-slate-500">Anda belum login.</span>
</div> </div>
) )
} }
return ( return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12 max-w-5xl"> <div className="animate-in fade-in max-w-5xl space-y-6 pb-12 duration-500">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left 2 Cols: Forms */} {/* Left 2 Cols: Forms */}
<div className="lg:col-span-2 space-y-6"> <div className="space-y-6 lg:col-span-2">
{/* PROFILE FORM */} {/* PROFILE FORM */}
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden"> <Card className="overflow-hidden rounded-3xl border border-slate-200/90 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="p-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between"> <div className="flex items-center justify-between border-b border-slate-100 p-6 dark:border-slate-800">
<div> <div>
<h2 className="text-lg font-black text-slate-900 dark:text-white">Informasi Akun</h2> <h2 className="text-lg font-black text-slate-900 dark:text-white">
<p className="text-xs text-slate-500 mt-0.5">Ubah nama tampilan dan foto profil Anda.</p> Informasi Akun
</h2>
<p className="mt-0.5 text-xs text-slate-500">
Ubah nama tampilan dan foto profil Anda.
</p>
</div> </div>
<span className="text-[11px] font-bold text-[#1B2CC1] bg-[#1B2CC1]/10 px-2.5 py-1 rounded-full uppercase tracking-wider"> <span className="rounded-full bg-[#1B2CC1]/10 px-2.5 py-1 text-[11px] font-bold tracking-wider text-[#1B2CC1] uppercase">
{user.role} {user.role}
</span> </span>
</div> </div>
<form onSubmit={handleSaveProfile}> <form onSubmit={handleSaveProfile}>
<CardContent className="p-6 space-y-6"> <CardContent className="space-y-6 p-6">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">Foto Profil</Label> <Label className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
<div className="flex flex-col sm:flex-row items-center gap-5 p-5 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30"> Foto Profil
</Label>
<div className="flex flex-col items-center gap-5 rounded-2xl border-2 border-dashed border-slate-200 bg-slate-50/50 p-5 sm:flex-row dark:border-slate-800 dark:bg-slate-800/30">
{photo ? ( {photo ? (
<img src={photo} alt={name} className="w-20 h-20 rounded-2xl object-cover ring-4 ring-white dark:ring-slate-700 shadow-md shrink-0" /> <img
src={photo}
alt={name}
className="h-20 w-20 shrink-0 rounded-2xl object-cover shadow-md ring-4 ring-white dark:ring-slate-700"
/>
) : ( ) : (
<div className="w-20 h-20 rounded-2xl bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center font-bold text-2xl ring-4 ring-white dark:ring-slate-700 shadow-sm shrink-0"> <div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-2xl bg-[#1B2CC1]/10 text-2xl font-bold text-[#1B2CC1] shadow-sm ring-4 ring-white dark:ring-slate-700">
{name ? name.charAt(0).toUpperCase() : <UserCircle className="w-10 h-10" />} {name ? name.charAt(0).toUpperCase() : <UserCircle className="h-10 w-10" />}
</div> </div>
)} )}
<div className="space-y-1.5 flex-1 w-full text-center sm:text-left"> <div className="w-full flex-1 space-y-1.5 text-center sm:text-left">
<p className="text-xs font-bold text-slate-800 dark:text-slate-200">Pratinjau Avatar</p> <p className="text-xs font-bold text-slate-800 dark:text-slate-200">
<p className="text-[11px] text-slate-400">Masukkan tautan URL foto gambar di bawah untuk memperbarui gambar profil.</p> Pratinjau Avatar
</p>
<p className="text-[11px] text-slate-400">
Masukkan tautan URL foto gambar di bawah untuk memperbarui gambar profil.
</p>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="prof-name" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300"> <Label
htmlFor="prof-name"
className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300"
>
Nama Tampilan <span className="text-red-500">*</span> Nama Tampilan <span className="text-red-500">*</span>
</Label> </Label>
<Input id="prof-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Contoh: Budi Santoso" className="h-11 rounded-xl" /> <Input
id="prof-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Contoh: Budi Santoso"
className="h-11 rounded-xl"
/>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="prof-photo" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300"> <Label
htmlFor="prof-photo"
className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300"
>
URL Foto Profil URL Foto Profil
</Label> </Label>
<Input id="prof-photo" value={photo} onChange={(e) => setPhoto(e.target.value)} placeholder="https://..." className="h-11 rounded-xl" /> <Input
id="prof-photo"
value={photo}
onChange={(e) => setPhoto(e.target.value)}
placeholder="https://..."
className="h-11 rounded-xl"
/>
</div> </div>
{globalNotifEnabled && (
<div className="space-y-6 pt-6 mt-6 border-t border-slate-100 dark:border-slate-800">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400">
<MessageCircle className="h-5 w-5" />
</div>
<div>
<h3 className="text-sm font-bold text-slate-900 dark:text-white">
Notifikasi Mattermost
</h3>
<p className="mt-0.5 text-[11px] text-slate-500">
Terima update pesanan PO langsung di DM Mattermost Anda.
</p>
</div>
</div>
<div className="flex flex-row items-start space-x-3 space-y-0 rounded-md border border-slate-200 p-4 dark:border-slate-800">
<input
type="checkbox"
id="mmActive"
checked={mmActive}
onChange={(e) => setMmActive(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-slate-300 text-[#1B2CC1] focus:ring-[#1B2CC1]"
/>
<div className="space-y-1 leading-none">
<Label htmlFor="mmActive" className="text-sm font-bold">
Aktifkan Notifikasi
</Label>
<p className="text-[11px] text-slate-500">
Kirim notifikasi setiap kali ada yang merubah pesanannya di PO Anda.
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="mm-channel" className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
Username / Channel ID
</Label>
<Input
id="mm-channel"
value={mmChannelId}
onChange={(e) => setMmChannelId(e.target.value)}
placeholder="Contoh: @firman atau 9dpxnitm..."
className="h-11 rounded-xl"
/>
</div>
</div>
)}
{profileError && ( {profileError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-semibold">{profileError}</div> <div className="rounded-xl bg-red-50 p-3 text-xs font-semibold text-red-600">
{profileError}
</div>
)} )}
{profileSuccess && ( {profileSuccess && (
<div className="p-3 bg-emerald-50 text-emerald-700 rounded-xl text-xs font-bold flex items-center gap-2"> <div className="flex items-center gap-2 rounded-xl bg-emerald-50 p-3 text-xs font-bold text-emerald-700">
<CheckCircle className="w-4 h-4" /><span>Profil berhasil diperbarui!</span> <CheckCircle className="h-4 w-4" />
<span>Perubahan berhasil disimpan!</span>
</div> </div>
)} )}
</CardContent> </CardContent>
<div className="p-6 bg-slate-50/60 border-t border-slate-100 flex justify-end"> <div className="flex justify-end border-t border-slate-100 bg-slate-50/60 p-6">
<Button type="submit" disabled={savingProfile} className="h-11 px-6 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold"> <Button
{savingProfile ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Save className="w-4 h-4 mr-2" />} type="submit"
disabled={savingProfile}
className="h-11 rounded-xl bg-[#1B2CC1] px-6 font-bold text-white hover:bg-[#15229E]"
>
{savingProfile ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Simpan Profil Simpan Profil
</Button> </Button>
</div> </div>
</form> </form>
</Card> </Card>
</div> </div>
{/* Right 1 Col: Account Details */} {/* Right 1 Col: Account Details */}
<div className="space-y-6"> <div className="space-y-6">
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6 space-y-4"> <Card className="space-y-4 rounded-3xl border border-slate-200/90 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-2 text-slate-800 dark:text-slate-200 font-bold text-sm mb-0"> <div className="mb-0 flex items-center gap-2 text-sm font-bold text-slate-800 dark:text-slate-200">
<ShieldCheck className="w-4 h-4 text-[#1B2CC1]" /> <ShieldCheck className="h-4 w-4 text-[#1B2CC1]" />
<span>Detail Akun</span> <span>Detail Akun</span>
</div> </div>
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800"> <div className="space-y-2 border-t border-slate-100 pt-2 dark:border-slate-800">
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] uppercase font-bold text-slate-400 block">Username</span> <span className="block text-[10px] font-bold text-slate-400 uppercase">
<div className="font-semibold text-slate-800 text-sm">{user.username}</div> Username
</span>
<div className="text-sm font-semibold text-slate-800">{user.username}</div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] uppercase font-bold text-slate-400 block">Role Akses</span> <span className="block text-[10px] font-bold text-slate-400 uppercase">
<div className="font-semibold text-[#1B2CC1] text-sm uppercase">{user.role}</div> Role Akses
</span>
<div className="text-sm font-semibold text-[#1B2CC1] uppercase">{user.role}</div>
</div> </div>
</div> </div>
{/* PASSWORD CHANGE MODAL BUTTON */} {/* PASSWORD CHANGE MODAL BUTTON */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-800"> <div className="border-t border-slate-100 pt-2 dark:border-slate-800">
<Dialog open={isPasswordModalOpen} onOpenChange={setIsPasswordModalOpen}> <Dialog open={isPasswordModalOpen} onOpenChange={setIsPasswordModalOpen}>
<DialogTrigger className="cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#1B2CC1] disabled:pointer-events-none disabled:opacity-50 h-11 px-4 py-2 rounded-xl gap-2 text-[#1B2CC1] hover:text-[#15229E] font-bold border border-[#1B2CC1]/20 bg-[#1B2CC1]/5 hover:bg-[#1B2CC1]/10 w-full"> <DialogTrigger className="inline-flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#1B2CC1]/20 bg-[#1B2CC1]/5 px-4 py-2 text-sm font-bold whitespace-nowrap text-[#1B2CC1] hover:bg-[#1B2CC1]/10 hover:text-[#15229E] focus-visible:ring-1 focus-visible:ring-[#1B2CC1] focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50">
<KeyRound className="w-4 h-4" /> <KeyRound className="h-4 w-4" />
Ganti Password Ganti Password
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px] rounded-3xl p-0 overflow-hidden border-slate-200/90 dark:border-slate-800 shadow-xl"> <DialogContent className="overflow-hidden rounded-3xl border-slate-200/90 p-0 shadow-xl sm:max-w-[425px] dark:border-slate-800">
<DialogHeader className="p-6 bg-slate-50 dark:bg-slate-900/50 border-b border-slate-100 dark:border-slate-800"> <DialogHeader className="border-b border-slate-100 bg-slate-50 p-6 dark:border-slate-800 dark:bg-slate-900/50">
<DialogTitle className="text-xl font-black text-slate-900 dark:text-white">Ganti Password</DialogTitle> <DialogTitle className="text-xl font-black text-slate-900 dark:text-white">
<p className="text-xs text-slate-500 mt-1">Lindungi akun Anda dengan mengubah password secara berkala.</p> Ganti Password
</DialogTitle>
<p className="mt-1 text-xs text-slate-500">
Lindungi akun Anda dengan mengubah password secara berkala.
</p>
</DialogHeader> </DialogHeader>
<form onSubmit={handleChangePassword}> <form onSubmit={handleChangePassword}>
<div className="p-6 space-y-5 bg-white dark:bg-slate-900"> <div className="space-y-5 bg-white p-6 dark:bg-slate-900">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700">Password Lama</Label> <Label className="text-xs font-bold tracking-wider text-slate-700 uppercase">
Password Lama
</Label>
<div className="relative"> <div className="relative">
<LockKeyhole className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <LockKeyhole className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} className="pl-9 h-11 rounded-xl" placeholder="Masukkan password lama" /> <Input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className="h-11 rounded-xl pl-9"
placeholder="Masukkan password lama"
/>
</div> </div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700">Password Baru</Label> <Label className="text-xs font-bold tracking-wider text-slate-700 uppercase">
Password Baru
</Label>
<div className="relative"> <div className="relative">
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <KeyRound className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="pl-9 h-11 rounded-xl" placeholder="Minimal 6 karakter" /> <Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="h-11 rounded-xl pl-9"
placeholder="Minimal 6 karakter"
/>
</div> </div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700">Konfirmasi Password Baru</Label> <Label className="text-xs font-bold tracking-wider text-slate-700 uppercase">
Konfirmasi Password Baru
</Label>
<div className="relative"> <div className="relative">
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <KeyRound className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="pl-9 h-11 rounded-xl" placeholder="Ketik ulang password baru" /> <Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-11 rounded-xl pl-9"
placeholder="Ketik ulang password baru"
/>
</div> </div>
</div> </div>
</div> </div>
{passwordError && ( {passwordError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-semibold">{passwordError}</div> <div className="rounded-xl bg-red-50 p-3 text-xs font-semibold text-red-600">
{passwordError}
</div>
)} )}
{passwordSuccess && ( {passwordSuccess && (
<div className="p-3 bg-emerald-50 text-emerald-700 rounded-xl text-xs font-bold flex items-center gap-2"> <div className="flex items-center gap-2 rounded-xl bg-emerald-50 p-3 text-xs font-bold text-emerald-700">
<CheckCircle className="w-4 h-4" /><span>Password berhasil diubah!</span> <CheckCircle className="h-4 w-4" />
<span>Password berhasil diubah!</span>
</div> </div>
)} )}
</div> </div>
<div className="p-6 bg-slate-50/60 border-t border-slate-100 flex justify-end gap-3"> <div className="flex justify-end gap-3 border-t border-slate-100 bg-slate-50/60 p-6">
<Button type="button" variant="ghost" onClick={() => setIsPasswordModalOpen(false)} className="h-11 rounded-xl font-bold">Batal</Button> <Button
<Button type="submit" disabled={savingPassword} className="h-11 px-6 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold"> type="button"
{savingPassword ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Save className="w-4 h-4 mr-2" />} variant="ghost"
onClick={() => setIsPasswordModalOpen(false)}
className="h-11 rounded-xl font-bold"
>
Batal
</Button>
<Button
type="submit"
disabled={savingPassword}
className="h-11 rounded-xl bg-[#1B2CC1] px-6 font-bold text-white hover:bg-[#15229E]"
>
{savingPassword ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Ubah Password Ubah Password
</Button> </Button>
</div> </div>
+118 -68
View File
@@ -1,18 +1,34 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { getCreatorReport, getSubmittorReport, getSessionUser, getBalancesAsCreator } from '@/app/actions' import {
getCreatorReport,
getSubmittorReport,
getSessionUser,
getBalancesAsCreator,
} from '@/app/actions'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Loader2, TrendingUp, TrendingDown, Wallet, ArrowRightLeft, Calendar as CalendarIcon } from 'lucide-react' import {
Loader2,
TrendingUp,
TrendingDown,
Wallet,
ArrowRightLeft,
Calendar as CalendarIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import ReportCharts from '@/components/ReportCharts' import ReportCharts from '@/components/ReportCharts'
import ReportDataGrid from '@/components/ReportDataGrid' import ReportDataGrid from '@/components/ReportDataGrid'
import ReportByPersonGrid from '@/components/ReportByPersonGrid' import ReportByPersonGrid from '@/components/ReportByPersonGrid'
import { import {
getISOWeek, getYear, getISOWeek,
startOfISOWeek, endOfISOWeek, getYear,
startOfMonth, endOfMonth, startOfISOWeek,
setISOWeek, setYear as dfSetYear endOfISOWeek,
startOfMonth,
endOfMonth,
setISOWeek,
setYear as dfSetYear,
} from 'date-fns' } from 'date-fns'
// ── Helpers ────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────
@@ -28,7 +44,10 @@ function getMonthValue(date: Date) {
return `${y}-${m.toString().padStart(2, '0')}` return `${y}-${m.toString().padStart(2, '0')}`
} }
function getIntervalFromFilter(type: 'WEEK' | 'MONTH', value: string): { start: Date; end: Date } | null { function getIntervalFromFilter(
type: 'WEEK' | 'MONTH',
value: string
): { start: Date; end: Date } | null {
if (!value) return null if (!value) return null
if (type === 'WEEK') { if (type === 'WEEK') {
@@ -83,7 +102,7 @@ export default function ReportsPage() {
const [subRes, creRes, balRes] = await Promise.all([ const [subRes, creRes, balRes] = await Promise.all([
getSubmittorReport(id, interval?.start, interval?.end), getSubmittorReport(id, interval?.start, interval?.end),
getCreatorReport(id, interval?.start, interval?.end), getCreatorReport(id, interval?.start, interval?.end),
getBalancesAsCreator(id) getBalancesAsCreator(id),
]) ])
setSubmittorData(subRes) setSubmittorData(subRes)
setCreatorData(creRes) setCreatorData(creRes)
@@ -97,67 +116,74 @@ export default function ReportsPage() {
} }
const formatRupiah = (n: number) => const formatRupiah = (n: number) =>
new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(n) new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(n)
// ── Summary calcs ────────────────────────────────────── // ── Summary calcs ──────────────────────────────────────
const totalPengeluaran = submittorData.reduce((acc, s) => acc + (s.payment_status === 'LUNAS' ? Number(s.bill) || 0 : 0), 0) const getPaidAmount = (sub: any) =>
const totalHutang = submittorData.reduce((acc, s) => acc + (s.payment_status !== 'LUNAS' ? Number(s.bill) || 0 : 0), 0) (sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? Number(sub.bill) || 0 : 0)) +
(sub.saldo_used || 0)
const getUnpaidAmount = (sub: any) => Math.max(0, (Number(sub.bill) || 0) - getPaidAmount(sub))
const totalPengeluaran = submittorData.reduce((acc, s) => acc + getPaidAmount(s), 0)
const totalHutang = submittorData.reduce((acc, s) => acc + getUnpaidAmount(s), 0)
let totalOmzet = 0 let totalOmzet = 0
let totalPiutang = 0 let totalPiutang = 0
creatorData.forEach(order => { creatorData.forEach((order) => {
order.submissions.forEach((sub: any) => { order.submissions.forEach((sub: any) => {
totalOmzet += Number(sub.bill) || 0 totalOmzet += Number(sub.bill) || 0
if (sub.payment_status !== 'LUNAS') totalPiutang += Number(sub.bill) || 0 totalPiutang += getUnpaidAmount(sub)
}) })
}) })
const filterLabel = filterType === 'WEEK' ? `Minggu ${filterValue}` : `Bulan ${filterValue}` const filterLabel = filterType === 'WEEK' ? `Minggu ${filterValue}` : `Bulan ${filterValue}`
return ( return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12"> <div className="animate-in fade-in space-y-6 pb-12 duration-500">
{/* ── Header: Tab Switcher (left) + Filter Controls (right) ── */} {/* ── Header: Tab Switcher (left) + Filter Controls (right) ── */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white dark:bg-slate-900 px-4 py-3 rounded-2xl border border-slate-200/80 dark:border-slate-800 shadow-sm"> <div className="flex flex-col justify-between gap-4 rounded-2xl border border-slate-200/80 bg-white px-4 py-3 shadow-sm md:flex-row md:items-center dark:border-slate-800 dark:bg-slate-900">
{/* Tab Switcher — positioned where the title was */} {/* Tab Switcher — positioned where the title was */}
<div className="flex items-center bg-slate-100 dark:bg-slate-800/80 p-1 rounded-xl text-xs font-bold"> <div className="flex items-center rounded-xl bg-slate-100 p-1 text-xs font-bold dark:bg-slate-800/80">
<button <button
onClick={() => setActiveTab('SUBMITTOR')} onClick={() => setActiveTab('SUBMITTOR')}
className={cn( className={cn(
'px-5 py-2 rounded-lg transition-all duration-300 flex items-center justify-center gap-2', 'flex items-center justify-center gap-2 rounded-lg px-5 py-2 transition-all duration-300',
activeTab === 'SUBMITTOR' activeTab === 'SUBMITTOR'
? 'bg-white dark:bg-slate-900 text-[#1B2CC1] dark:text-white shadow-sm font-black' ? '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' : 'text-slate-500 hover:text-slate-900 dark:hover:text-white'
)} )}
> >
<TrendingDown className="w-4 h-4" /> Pengeluaran Saya <TrendingDown className="h-4 w-4" /> Pengeluaran Saya
</button> </button>
<button <button
onClick={() => setActiveTab('CREATOR')} onClick={() => setActiveTab('CREATOR')}
className={cn( className={cn(
'px-5 py-2 rounded-lg transition-all duration-300 flex items-center justify-center gap-2', 'flex items-center justify-center gap-2 rounded-lg px-5 py-2 transition-all duration-300',
activeTab === 'CREATOR' activeTab === 'CREATOR'
? 'bg-white dark:bg-slate-900 text-[#1B2CC1] dark:text-white shadow-sm font-black' ? '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' : 'text-slate-500 hover:text-slate-900 dark:hover:text-white'
)} )}
> >
<TrendingUp className="w-4 h-4" /> Omzet (Kreator) <TrendingUp className="h-4 w-4" /> Omzet (Kreator)
</button> </button>
</div> </div>
{/* Filter Controls */} {/* Filter Controls */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex flex-wrap items-center gap-2">
{/* Type toggle */} {/* Type toggle */}
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-xl p-1 gap-1"> <div className="flex items-center gap-1 rounded-xl bg-slate-100 p-1 dark:bg-slate-800">
{(['WEEK', 'MONTH'] as const).map(t => ( {(['WEEK', 'MONTH'] as const).map((t) => (
<button <button
key={t} key={t}
onClick={() => handleFilterTypeChange(t)} onClick={() => handleFilterTypeChange(t)}
className={cn( className={cn(
'px-3 py-1.5 rounded-lg text-xs font-bold transition-all', 'rounded-lg px-3 py-1.5 text-xs font-bold transition-all',
filterType === t filterType === t
? 'bg-white dark:bg-slate-700 text-[#1B2CC1] shadow-sm' ? 'bg-white text-[#1B2CC1] shadow-sm dark:bg-slate-700'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
)} )}
> >
@@ -169,57 +195,67 @@ export default function ReportsPage() {
{/* Date Picker */} {/* Date Picker */}
<div <div
onClick={() => pickerRef.current?.showPicker()} onClick={() => pickerRef.current?.showPicker()}
className="flex items-center gap-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl px-3 py-2 shadow-sm cursor-pointer hover:border-[#1B2CC1]/50 transition-colors select-none" className="flex cursor-pointer items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm transition-colors select-none hover:border-[#1B2CC1]/50 dark:border-slate-700 dark:bg-slate-900"
> >
<input <input
ref={pickerRef} ref={pickerRef}
type={filterType === 'WEEK' ? 'week' : 'month'} type={filterType === 'WEEK' ? 'week' : 'month'}
value={filterValue} value={filterValue}
onChange={e => setFilterValue(e.target.value)} onChange={(e) => setFilterValue(e.target.value)}
className="text-sm font-bold text-slate-700 dark:text-slate-200 bg-transparent border-none outline-none cursor-pointer w-[140px] [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-inner-spin-button]:hidden" className="w-[140px] cursor-pointer border-none bg-transparent text-sm font-bold text-slate-700 outline-none dark:text-slate-200 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-inner-spin-button]:hidden"
/> />
<CalendarIcon className="w-4 h-4 text-slate-400 shrink-0" /> <CalendarIcon className="h-4 w-4 shrink-0 text-slate-400" />
</div> </div>
</div> </div>
</div> </div>
{/* Loading overlay */} {/* Loading overlay */}
{loading && ( {loading && (
<div className="flex items-center justify-center py-16 gap-3 text-slate-400"> <div className="flex items-center justify-center gap-3 py-16 text-slate-400">
<Loader2 className="w-6 h-6 animate-spin text-[#1B2CC1]" /> <Loader2 className="h-6 w-6 animate-spin text-[#1B2CC1]" />
<span className="text-sm font-semibold">Memuat data {filterLabel}...</span> <span className="text-sm font-semibold">Memuat data {filterLabel}...</span>
</div> </div>
)} )}
{/* ── SUBMITTOR TAB ── */} {/* ── SUBMITTOR TAB ── */}
{!loading && activeTab === 'SUBMITTOR' && ( {!loading && activeTab === 'SUBMITTOR' && (
<div className="space-y-6 animate-in slide-in-from-left-2 duration-300"> <div className="animate-in slide-in-from-left-2 space-y-6 duration-300">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card className="rounded-2xl border-none bg-gradient-to-br from-emerald-500 to-emerald-700 text-white shadow-lg shadow-emerald-500/20"> <Card className="rounded-2xl border-none bg-gradient-to-br from-emerald-500 to-emerald-700 text-white shadow-lg shadow-emerald-500/20">
<div className="p-4 flex items-center gap-4"> <div className="flex items-center gap-4 p-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center backdrop-blur-sm shrink-0"> <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
<Wallet className="w-6 h-6 text-white" /> <Wallet className="h-6 w-6 text-white" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-emerald-100 font-bold text-xs tracking-wide uppercase mb-1">Total Pengeluaran (Lunas)</span> <span className="mb-1 text-xs font-bold tracking-wide text-emerald-100 uppercase">
<h2 className="text-2xl font-black leading-none mb-1">{formatRupiah(totalPengeluaran)}</h2> Total Pengeluaran (Lunas)
</span>
<h2 className="mb-1 text-2xl leading-none font-black">
{formatRupiah(totalPengeluaran)}
</h2>
<p className="text-xs text-emerald-100"> <p className="text-xs text-emerald-100">
Dari {submittorData.filter(s => s.payment_status === 'LUNAS').length} pesanan lunas. Dari {submittorData.filter((s) => s.payment_status === 'LUNAS').length} pesanan
lunas.
</p> </p>
</div> </div>
</div> </div>
</Card> </Card>
<Card className="rounded-2xl border-none bg-gradient-to-br from-rose-500 to-rose-700 text-white shadow-lg shadow-rose-500/20"> <Card className="rounded-2xl border-none bg-gradient-to-br from-rose-500 to-rose-700 text-white shadow-lg shadow-rose-500/20">
<div className="p-4 flex items-center gap-4"> <div className="flex items-center gap-4 p-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center backdrop-blur-sm shrink-0"> <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
<ArrowRightLeft className="w-6 h-6 text-white" /> <ArrowRightLeft className="h-6 w-6 text-white" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-rose-100 font-bold text-xs tracking-wide uppercase mb-1">Hutang Pribadi (Belum Bayar)</span> <span className="mb-1 text-xs font-bold tracking-wide text-rose-100 uppercase">
<h2 className="text-2xl font-black leading-none mb-1">{formatRupiah(totalHutang)}</h2> Hutang Pribadi (Belum Bayar)
</span>
<h2 className="mb-1 text-2xl leading-none font-black">
{formatRupiah(totalHutang)}
</h2>
<p className="text-xs text-rose-100"> <p className="text-xs text-rose-100">
{submittorData.filter(s => s.payment_status !== 'LUNAS').length} tagihan masih gantung. {submittorData.filter((s) => s.payment_status !== 'LUNAS').length} tagihan masih
gantung.
</p> </p>
</div> </div>
</div> </div>
@@ -233,30 +269,42 @@ export default function ReportsPage() {
{/* ── CREATOR TAB ── */} {/* ── CREATOR TAB ── */}
{!loading && activeTab === 'CREATOR' && ( {!loading && activeTab === 'CREATOR' && (
<div className="space-y-6 animate-in slide-in-from-right-2 duration-300"> <div className="animate-in slide-in-from-right-2 space-y-6 duration-300">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card className="rounded-2xl border-none bg-gradient-to-br from-[#1B2CC1] to-[#121E85] text-white shadow-lg shadow-[#1B2CC1]/20"> <Card className="rounded-2xl border-none bg-gradient-to-br from-[#1B2CC1] to-[#121E85] text-white shadow-lg shadow-[#1B2CC1]/20">
<div className="p-4 flex items-center gap-4"> <div className="flex items-center gap-4 p-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center backdrop-blur-sm shrink-0"> <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
<TrendingUp className="w-6 h-6 text-white" /> <TrendingUp className="h-6 w-6 text-white" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-blue-200 font-bold text-xs tracking-wide uppercase mb-1">Total Omzet Dikelola</span> <span className="mb-1 text-xs font-bold tracking-wide text-blue-200 uppercase">
<h2 className="text-2xl font-black leading-none mb-1">{formatRupiah(totalOmzet)}</h2> Total Omzet Dikelola
<p className="text-xs text-blue-200">Dari {creatorData.length} PO yang Anda buat.</p> </span>
<h2 className="mb-1 text-2xl leading-none font-black">
{formatRupiah(totalOmzet)}
</h2>
<p className="text-xs text-blue-200">
Dari {creatorData.length} PO yang Anda buat.
</p>
</div> </div>
</div> </div>
</Card> </Card>
<Card className="rounded-2xl border-none bg-gradient-to-br from-amber-500 to-amber-600 text-white shadow-lg shadow-amber-500/20"> <Card className="rounded-2xl border-none bg-gradient-to-br from-amber-500 to-amber-600 text-white shadow-lg shadow-amber-500/20">
<div className="p-4 flex items-center gap-4"> <div className="flex items-center gap-4 p-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center backdrop-blur-sm shrink-0"> <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
<ArrowRightLeft className="w-6 h-6 text-white" /> <ArrowRightLeft className="h-6 w-6 text-white" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-amber-100 font-bold text-xs tracking-wide uppercase mb-1">Total Piutang (Belum Dibayar)</span> <span className="mb-1 text-xs font-bold tracking-wide text-amber-100 uppercase">
<h2 className="text-2xl font-black leading-none mb-1">{formatRupiah(totalPiutang)}</h2> Total Piutang (Belum Dibayar)
<p className="text-xs text-amber-100">Uang Anda yang masih tertahan di teman-teman.</p> </span>
<h2 className="mb-1 text-2xl leading-none font-black">
{formatRupiah(totalPiutang)}
</h2>
<p className="text-xs text-amber-100">
Uang Anda yang masih tertahan di teman-teman.
</p>
</div> </div>
</div> </div>
</Card> </Card>
@@ -265,15 +313,15 @@ export default function ReportsPage() {
<ReportCharts type="CREATOR" data={creatorData} /> <ReportCharts type="CREATOR" data={creatorData} />
{/* Sub-Tabs */} {/* Sub-Tabs */}
<div className="flex items-center bg-slate-100 dark:bg-slate-800/80 p-1 rounded-xl text-xs font-bold w-full sm:w-auto"> <div className="flex w-full items-center rounded-xl bg-slate-100 p-1 text-xs font-bold sm:w-auto dark:bg-slate-800/80">
{([ 'BY_PERSON','BY_ORDER',] as const).map(tab => ( {(['BY_PERSON', 'BY_ORDER'] as const).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setCreatorTab(tab)} onClick={() => setCreatorTab(tab)}
className={cn( className={cn(
'flex-1 sm:flex-none px-5 py-2 rounded-lg transition-all duration-200 flex items-center justify-center gap-1.5', 'flex flex-1 items-center justify-center gap-1.5 rounded-lg px-5 py-2 transition-all duration-200 sm:flex-none',
creatorTab === tab creatorTab === tab
? 'bg-white dark:bg-slate-900 text-[#1B2CC1] dark:text-white shadow-sm font-black' ? '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' : 'text-slate-500 hover:text-slate-900 dark:hover:text-white'
)} )}
> >
@@ -289,7 +337,9 @@ export default function ReportsPage() {
data={creatorData} data={creatorData}
balancesData={creatorBalances} balancesData={creatorBalances}
creatorId={userId!} creatorId={userId!}
onUpdate={() => { if (userId) loadData(userId) }} onUpdate={() => {
if (userId) loadData(userId)
}}
/> />
)} )}
</div> </div>
+114 -44
View File
@@ -8,14 +8,18 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
export default function IntegrationsPage() { export default function IntegrationsPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
MATTERMOST_NOTIF_ENABLED: 'false',
MATTERMOST_BOT_TOKEN: '',
MATTERMOST_API_URL: '',
MATTERMOST_WEBHOOK_URL: '', MATTERMOST_WEBHOOK_URL: '',
MATTERMOST_TEMPLATE: '', MATTERMOST_TEMPLATE: '',
WHATSAPP_TEMPLATE: '' WHATSAPP_TEMPLATE: '',
}) })
useEffect(() => { useEffect(() => {
@@ -23,9 +27,16 @@ export default function IntegrationsPage() {
const data = await getSettings() const data = await getSettings()
if (data) { if (data) {
setSettings({ setSettings({
MATTERMOST_NOTIF_ENABLED: data.MATTERMOST_NOTIF_ENABLED || 'false',
MATTERMOST_BOT_TOKEN: data.MATTERMOST_BOT_TOKEN || '',
MATTERMOST_API_URL: data.MATTERMOST_API_URL || '',
MATTERMOST_WEBHOOK_URL: data.MATTERMOST_WEBHOOK_URL || '', MATTERMOST_WEBHOOK_URL: data.MATTERMOST_WEBHOOK_URL || '',
MATTERMOST_TEMPLATE: data.MATTERMOST_TEMPLATE || 'Halo tim! Lagi ada orderan open nih untuk {title}. Mumpung belum di-checkout, yang mau ikutan nitip bisa langsung cek ke sini ya: {url}', MATTERMOST_TEMPLATE:
WHATSAPP_TEMPLATE: data.WHATSAPP_TEMPLATE || 'Halo tim! Lagi ada orderan open nih untuk {title}. Mumpung belum di-checkout, yang mau ikutan nitip bisa langsung cek ke sini ya: {url}' data.MATTERMOST_TEMPLATE ||
'Halo tim! Lagi ada orderan open nih untuk {title}. Mumpung belum di-checkout, yang mau ikutan nitip bisa langsung cek ke sini ya: {url}',
WHATSAPP_TEMPLATE:
data.WHATSAPP_TEMPLATE ||
'Halo tim! Lagi ada orderan open nih untuk {title}. Mumpung belum di-checkout, yang mau ikutan nitip bisa langsung cek ke sini ya: {url}',
}) })
} }
setLoading(false) setLoading(false)
@@ -34,7 +45,7 @@ export default function IntegrationsPage() {
}, []) }, [])
const handleChange = (key: string, value: string) => { const handleChange = (key: string, value: string) => {
setSettings(prev => ({ ...prev, [key]: value })) setSettings((prev) => ({ ...prev, [key]: value }))
} }
const handleSave = async () => { const handleSave = async () => {
@@ -43,12 +54,12 @@ export default function IntegrationsPage() {
if (res.success) { if (res.success) {
toast.success('Berhasil Disimpan', { toast.success('Berhasil Disimpan', {
description: 'Pengaturan integrasi berhasil disimpan!', description: 'Pengaturan integrasi berhasil disimpan!',
duration: 3000 duration: 3000,
}) })
} else { } else {
toast.error('Gagal', { toast.error('Gagal', {
description: 'Gagal menyimpan pengaturan', description: 'Gagal menyimpan pengaturan',
duration: 3000 duration: 3000,
}) })
} }
setSaving(false) setSaving(false)
@@ -56,73 +67,132 @@ export default function IntegrationsPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex justify-center items-center h-64"> <div className="flex h-64 items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-[#1B2CC1]" /> <Loader2 className="h-8 w-8 animate-spin text-[#1B2CC1]" />
</div> </div>
) )
} }
return ( return (
<div className="p-6 space-y-8"> <div className="space-y-8 p-6">
<div> <div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Integrasi & Sharing</h2> <h2 className="text-xl font-bold text-slate-900 dark:text-white">Integrasi & Sharing</h2>
<p className="text-xs text-slate-500 mt-1">Konfigurasi endpoint API dan template pesan otomatis.</p> <p className="mt-1 text-xs text-slate-500">
Konfigurasi endpoint API dan template pesan otomatis.
</p>
</div> </div>
<div className="space-y-6 max-w-3xl"> <div className="max-w-3xl space-y-6">
{/* Mattermost Section */} {/* Mattermost Section */}
<div className="p-5 rounded-2xl border border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 space-y-4"> <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 text-indigo-600 dark:text-indigo-400 font-black"> <div className="flex items-center gap-2 font-black text-indigo-600 dark:text-indigo-400">
<MessageSquare className="w-5 h-5" /> <MessageSquare className="h-5 w-5" />
<h3>Mattermost Webhook</h3> <h3>Konfigurasi Mattermost</h3>
</div> </div>
<p className="text-xs text-slate-500">Gunakan URL Incoming Webhook dari Mattermost untuk mengirim broadcast PO baru secara otomatis.</p> <p className="text-xs text-slate-500">
Atur integrasi Mattermost. Bagian atas untuk Notifikasi Personal (DM) ke Kreator, sedangkan bagian bawah untuk fitur Share (Broadcast) PO menggunakan Webhook.
<div className="space-y-1.5"> </p>
<Label className="text-xs font-bold uppercase text-slate-700">Webhook URL</Label>
<Input <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">
value={settings.MATTERMOST_WEBHOOK_URL} <Checkbox
onChange={e => handleChange('MATTERMOST_WEBHOOK_URL', e.target.value)} id="mattermostNotifEnabled"
className="h-11 rounded-xl bg-white dark:bg-slate-950" checked={settings.MATTERMOST_NOTIF_ENABLED === 'true'}
placeholder="https://mattermost.yourdomain.com/hooks/xxx" 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}
onChange={(e) => handleChange('MATTERMOST_WEBHOOK_URL', e.target.value)}
className="h-11 rounded-xl bg-white dark:bg-slate-950"
placeholder="https://mattermost.yourdomain.com/hooks/xxx"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-bold uppercase text-slate-700">Pesan Template</Label> <Label className="text-xs font-bold text-slate-700 uppercase">Pesan Template</Label>
<Textarea <Textarea
value={settings.MATTERMOST_TEMPLATE} value={settings.MATTERMOST_TEMPLATE}
onChange={e => handleChange('MATTERMOST_TEMPLATE', e.target.value)} onChange={(e) => handleChange('MATTERMOST_TEMPLATE', e.target.value)}
className="rounded-xl min-h-[100px] bg-white dark:bg-slate-950" className="min-h-[100px] rounded-xl bg-white dark:bg-slate-950"
placeholder="Template pesan..." placeholder="Template pesan..."
/> />
<p className="text-[10px] text-slate-500 flex items-center gap-1"><AlertCircle className="w-3 h-3"/> Gunakan variabel: {'{title}'}, {'{url}'}</p> <p className="flex items-center gap-1 text-[10px] text-slate-500">
<AlertCircle className="h-3 w-3" /> Gunakan variabel: {'{title}'}, {'{url}'}
</p>
</div> </div>
</div> </div>
{/* WhatsApp Section */} {/* WhatsApp Section */}
<div className="p-5 rounded-2xl border border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 space-y-4"> <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 text-emerald-600 dark:text-emerald-400 font-black"> <div className="flex items-center gap-2 font-black text-emerald-600 dark:text-emerald-400">
<MessageCircle className="w-5 h-5" /> <MessageCircle className="h-5 w-5" />
<h3>WhatsApp Share Template</h3> <h3>WhatsApp Share Template</h3>
</div> </div>
<p className="text-xs text-slate-500">Atur template teks default saat user menekan tombol share ke WhatsApp.</p> <p className="text-xs text-slate-500">
Atur template teks default saat user menekan tombol share ke WhatsApp.
</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-bold uppercase text-slate-700">Pesan Template</Label> <Label className="text-xs font-bold text-slate-700 uppercase">Pesan Template</Label>
<Textarea <Textarea
value={settings.WHATSAPP_TEMPLATE} value={settings.WHATSAPP_TEMPLATE}
onChange={e => handleChange('WHATSAPP_TEMPLATE', e.target.value)} onChange={(e) => handleChange('WHATSAPP_TEMPLATE', e.target.value)}
className="rounded-xl min-h-[100px] bg-white dark:bg-slate-950" className="min-h-[100px] rounded-xl bg-white dark:bg-slate-950"
placeholder="Template pesan..." placeholder="Template pesan..."
/> />
<p className="text-[10px] text-slate-500 flex items-center gap-1"><AlertCircle className="w-3 h-3"/> Gunakan variabel: {'{title}'}, {'{url}'}</p> <p className="flex items-center gap-1 text-[10px] text-slate-500">
<AlertCircle className="h-3 w-3" /> Gunakan variabel: {'{title}'}, {'{url}'}
</p>
</div> </div>
</div> </div>
<div className="pt-2"> <div className="pt-2">
<Button onClick={handleSave} disabled={saving} className="bg-[#1B2CC1] hover:bg-[#15229E] text-white rounded-xl h-11 px-8 font-bold shadow-md w-full sm:w-auto"> <Button
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Save className="w-4 h-4 mr-2" />} onClick={handleSave}
disabled={saving}
className="h-11 w-full rounded-xl bg-[#1B2CC1] px-8 font-bold text-white shadow-md hover:bg-[#15229E] sm:w-auto"
>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Simpan Konfigurasi Simpan Konfigurasi
</Button> </Button>
</div> </div>
+9 -9
View File
@@ -14,10 +14,10 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
] ]
return ( return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12"> <div className="animate-in fade-in space-y-6 pb-12 duration-500">
{/* Settings Navigation Tabs */} {/* Settings Navigation Tabs */}
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 bg-white dark:bg-slate-900 p-4 rounded-2xl border border-slate-200/80 dark:border-slate-800 shadow-sm overflow-x-auto"> <div className="flex flex-col items-start gap-4 overflow-x-auto 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 items-center bg-slate-100 dark:bg-slate-800/80 p-1 rounded-xl text-xs font-bold w-full md:w-auto"> <div className="flex w-full items-center rounded-xl bg-slate-100 p-1 text-xs font-bold md:w-auto dark:bg-slate-800/80">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = pathname === tab.href const isActive = pathname === tab.href
const Icon = tab.icon const Icon = tab.icon
@@ -26,13 +26,13 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
key={tab.href} key={tab.href}
href={tab.href} href={tab.href}
className={cn( className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 whitespace-nowrap", 'flex items-center gap-2 rounded-lg px-4 py-2 whitespace-nowrap transition-all duration-200',
isActive isActive
? "bg-white dark:bg-slate-900 text-[#1B2CC1] dark:text-blue-400 shadow-sm font-black" ? 'bg-white font-black text-[#1B2CC1] shadow-sm dark:bg-slate-900 dark:text-blue-400'
: "text-slate-500 hover:text-slate-900 dark:hover:text-white" : 'text-slate-500 hover:text-slate-900 dark:hover:text-white'
)} )}
> >
<Icon className="w-4 h-4" /> <Icon className="h-4 w-4" />
{tab.name} {tab.name}
</Link> </Link>
) )
@@ -41,7 +41,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
</div> </div>
{/* Settings Content */} {/* Settings Content */}
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200/80 dark:border-slate-800 shadow-sm overflow-hidden"> <div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
{children} {children}
</div> </div>
</div> </div>
+249 -151
View File
@@ -1,14 +1,26 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { getUsers, createUserByAdmin, toggleUserActive, deleteUser, resetUserPassword } from '@/app/actions' import {
getUsers,
createUserByAdmin,
toggleUserActive,
deleteUser,
resetUserPassword,
} from '@/app/actions'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Loader2, Plus, KeyRound, Ban, Trash2, CheckCircle2, ShieldCheck, User } from 'lucide-react' import { Loader2, Plus, KeyRound, Ban, Trash2, CheckCircle2, ShieldCheck, User } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export default function UsersPage() { export default function UsersPage() {
const [users, setUsers] = useState<any[]>([]) const [users, setUsers] = useState<any[]>([])
@@ -28,7 +40,7 @@ export default function UsersPage() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [role, setRole] = useState('user') const [role, setRole] = useState('user')
const [newPass, setNewPass] = useState('') const [newPass, setNewPass] = useState('')
const [actionLoading, setActionLoading] = useState(false) const [actionLoading, setActionLoading] = useState(false)
useEffect(() => { useEffect(() => {
@@ -47,7 +59,7 @@ export default function UsersPage() {
const handleCreateUser = async (e: React.FormEvent) => { const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!name || !username || !password) return alert('Lengkapi semua field') if (!name || !username || !password) return alert('Lengkapi semua field')
setActionLoading(true) setActionLoading(true)
const res = await createUserByAdmin(name, username, password, role) const res = await createUserByAdmin(name, username, password, role)
if (res.success) { if (res.success) {
@@ -66,7 +78,7 @@ export default function UsersPage() {
const handleResetPassword = async (e: React.FormEvent) => { const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!newPass || newPass.length < 6) return alert('Password minimal 6 karakter') if (!newPass || newPass.length < 6) return alert('Password minimal 6 karakter')
setActionLoading(true) setActionLoading(true)
const res = await resetUserPassword(selectedUser.id, newPass) const res = await resetUserPassword(selectedUser.id, newPass)
if (res.success) { if (res.success) {
@@ -80,7 +92,8 @@ export default function UsersPage() {
} }
const handleToggleActive = async (id: string, currentStatus: boolean) => { const handleToggleActive = async (id: string, currentStatus: boolean) => {
if (!confirm(`Yakin ingin ${currentStatus ? 'menonaktifkan' : 'mengaktifkan'} user ini?`)) return if (!confirm(`Yakin ingin ${currentStatus ? 'menonaktifkan' : 'mengaktifkan'} user ini?`))
return
const res = await toggleUserActive(id, !currentStatus) const res = await toggleUserActive(id, !currentStatus)
if (res.success) { if (res.success) {
loadUsers(page) loadUsers(page)
@@ -101,187 +114,251 @@ export default function UsersPage() {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6"> <div className="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div> <div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Manajemen Pengguna</h2> <h2 className="text-xl font-bold text-slate-900 dark:text-white">Manajemen Pengguna</h2>
<p className="text-xs text-slate-500 mt-1">Kelola data seluruh pengguna TitipIn.</p> <p className="mt-1 text-xs text-slate-500">Kelola data seluruh pengguna TitipIn.</p>
</div> </div>
<Button onClick={() => setIsCreateOpen(true)} className="bg-[#1B2CC1] hover:bg-[#15229E] text-white rounded-xl h-10 px-4 shadow-sm text-xs font-bold"> <Button
<Plus className="w-4 h-4 mr-1.5" /> Tambah User onClick={() => setIsCreateOpen(true)}
className="h-10 rounded-xl bg-[#1B2CC1] px-4 text-xs font-bold text-white shadow-sm hover:bg-[#15229E]"
>
<Plus className="mr-1.5 h-4 w-4" /> Tambah User
</Button> </Button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-left text-sm">
<thead className="bg-slate-50/50 dark:bg-slate-800/50 border-b border-slate-100 dark:border-slate-800 text-xs uppercase font-bold text-slate-500"> <thead className="border-b border-slate-100 bg-slate-50/50 text-xs font-bold text-slate-500 uppercase dark:border-slate-800 dark:bg-slate-800/50">
<tr>
<th className="px-6 py-4">Pengguna</th>
<th className="px-6 py-4">Role</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4">Terdaftar</th>
<th className="px-6 py-4 text-right">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{loading ? (
<tr> <tr>
<th className="px-6 py-4">Pengguna</th> <td colSpan={5} className="px-6 py-12 text-center">
<th className="px-6 py-4">Role</th> <Loader2 className="mx-auto h-6 w-6 animate-spin text-[#1B2CC1]" />
<th className="px-6 py-4">Status</th> </td>
<th className="px-6 py-4">Terdaftar</th>
<th className="px-6 py-4 text-right">Aksi</th>
</tr> </tr>
</thead> ) : users.length === 0 ? (
<tbody className="divide-y divide-slate-100 dark:divide-slate-800"> <tr>
{loading ? ( <td colSpan={5} className="px-6 py-12 text-center text-slate-500">
<tr> Belum ada pengguna lain.
<td colSpan={5} className="px-6 py-12 text-center"> </td>
<Loader2 className="w-6 h-6 animate-spin mx-auto text-[#1B2CC1]" /> </tr>
) : (
users.map((u) => (
<tr
key={u.id}
className="transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/20"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{u.photo ? (
<img
src={u.photo}
alt={u.name}
className="h-9 w-9 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-bold text-blue-700 uppercase">
{u.name.charAt(0)}
</div>
)}
<div>
<div className="font-bold text-slate-900 dark:text-slate-100">{u.name}</div>
<div className="text-xs text-slate-500">@{u.username}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
{u.role === 'superadmin' ? (
<span className="inline-flex items-center gap-1 rounded-md bg-purple-100 px-2 py-1 text-[10px] font-bold text-purple-700 uppercase">
<ShieldCheck className="h-3 w-3" /> Superadmin
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-md bg-slate-100 px-2 py-1 text-[10px] font-bold text-slate-700 uppercase">
<User className="h-3 w-3" /> User
</span>
)}
</td>
<td className="px-6 py-4">
{u.is_active ? (
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-100 px-2 py-1 text-[10px] font-bold text-emerald-700 uppercase">
<CheckCircle2 className="h-3 w-3" /> Aktif
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-md bg-red-100 px-2 py-1 text-[10px] font-bold text-red-700 uppercase">
<Ban className="h-3 w-3" /> Nonaktif
</span>
)}
</td>
<td className="px-6 py-4 text-xs text-slate-500">
{new Date(u.created_at).toLocaleDateString('id-ID')}
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedUser(u)
setIsPassOpen(true)
}}
className="h-8 rounded-lg bg-slate-100 px-2 font-medium text-slate-700 hover:bg-slate-200 hover:text-slate-900"
title="Ubah Password"
>
<KeyRound className="h-4 w-4" />
</Button>
{u.role !== 'superadmin' && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => handleToggleActive(u.id, u.is_active)}
className={`h-8 rounded-lg px-2 font-medium ${u.is_active ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 hover:text-amber-900' : 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 hover:text-emerald-900'}`}
title={u.is_active ? 'Nonaktifkan' : 'Aktifkan'}
>
{u.is_active ? (
<Ban className="h-4 w-4" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(u.id)}
className="h-8 rounded-lg bg-rose-100 px-2 font-medium text-rose-700 hover:bg-rose-200 hover:text-rose-900"
title="Hapus"
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</td> </td>
</tr> </tr>
) : users.length === 0 ? ( ))
<tr> )}
<td colSpan={5} className="px-6 py-12 text-center text-slate-500">Belum ada pengguna lain.</td> </tbody>
</tr> </table>
) : ( </div>
users.map(u => (
<tr key={u.id} className="hover:bg-slate-50/50 dark:hover:bg-slate-800/20 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{u.photo ? (
<img src={u.photo} alt={u.name} className="w-9 h-9 rounded-full object-cover shrink-0" />
) : (
<div className="w-9 h-9 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center font-bold text-xs uppercase shrink-0">
{u.name.charAt(0)}
</div>
)}
<div>
<div className="font-bold text-slate-900 dark:text-slate-100">{u.name}</div>
<div className="text-xs text-slate-500">@{u.username}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
{u.role === 'superadmin' ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-md bg-purple-100 text-purple-700 uppercase">
<ShieldCheck className="w-3 h-3" /> Superadmin
</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-md bg-slate-100 text-slate-700 uppercase">
<User className="w-3 h-3" /> User
</span>
)}
</td>
<td className="px-6 py-4">
{u.is_active ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-md bg-emerald-100 text-emerald-700 uppercase">
<CheckCircle2 className="w-3 h-3" /> Aktif
</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-md bg-red-100 text-red-700 uppercase">
<Ban className="w-3 h-3" /> Nonaktif
</span>
)}
</td>
<td className="px-6 py-4 text-slate-500 text-xs">
{new Date(u.created_at).toLocaleDateString('id-ID')}
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedUser(u)
setIsPassOpen(true)
}}
className="h-8 px-2 rounded-lg bg-slate-100 text-slate-700 hover:bg-slate-200 hover:text-slate-900 font-medium"
title="Ubah Password"
>
<KeyRound className="w-4 h-4" />
</Button>
{u.role !== 'superadmin' && ( {totalPages > 1 && (
<> <div className="flex items-center justify-between border-t border-slate-100 bg-slate-50/50 p-4 dark:border-slate-800">
<Button <span className="text-xs text-slate-500">
size="sm" Halaman {page} dari {totalPages}
variant="ghost" </span>
onClick={() => handleToggleActive(u.id, u.is_active)} <div className="flex gap-2">
className={`h-8 px-2 rounded-lg font-medium ${u.is_active ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 hover:text-amber-900' : 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 hover:text-emerald-900'}`} <Button
title={u.is_active ? 'Nonaktifkan' : 'Aktifkan'} disabled={page === 1}
> onClick={() => setPage((p) => p - 1)}
{u.is_active ? <Ban className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />} variant="outline"
</Button> size="sm"
<Button className="h-8 rounded-lg text-xs"
size="sm" >
variant="ghost" Sebelumnya
onClick={() => handleDelete(u.id)} </Button>
className="h-8 px-2 rounded-lg bg-rose-100 text-rose-700 hover:bg-rose-200 hover:text-rose-900 font-medium" <Button
title="Hapus" disabled={page === totalPages}
> onClick={() => setPage((p) => p + 1)}
<Trash2 className="w-4 h-4" /> variant="outline"
</Button> size="sm"
</> className="h-8 rounded-lg text-xs"
)} >
</div> Selanjutnya
</td> </Button>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="p-4 border-t border-slate-100 dark:border-slate-800 flex justify-between items-center bg-slate-50/50">
<span className="text-xs text-slate-500">Halaman {page} dari {totalPages}</span>
<div className="flex gap-2">
<Button disabled={page === 1} onClick={() => setPage(p => p - 1)} variant="outline" size="sm" className="h-8 rounded-lg text-xs">Sebelumnya</Button>
<Button disabled={page === totalPages} onClick={() => setPage(p => p + 1)} variant="outline" size="sm" className="h-8 rounded-lg text-xs">Selanjutnya</Button>
</div>
</div> </div>
)} </div>
)}
{/* CREATE MODAL */} {/* CREATE MODAL */}
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}> <Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogContent className="sm:max-w-md rounded-3xl p-0 overflow-hidden border-0"> <DialogContent className="overflow-hidden rounded-3xl border-0 p-0 sm:max-w-md">
<div className="px-6 pt-6 pb-4 border-b border-slate-100"> <div className="border-b border-slate-100 px-6 pt-6 pb-4">
<DialogTitle className="text-xl font-black">Tambah User Baru</DialogTitle> <DialogTitle className="text-xl font-black">Tambah User Baru</DialogTitle>
</div> </div>
<form onSubmit={handleCreateUser} className="px-6 py-4 space-y-4"> <form onSubmit={handleCreateUser} className="space-y-4 px-6 py-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-bold uppercase text-slate-700">Nama Lengkap</Label> <Label className="text-xs font-bold text-slate-700 uppercase">Nama Lengkap</Label>
<Input required value={name} onChange={e => setName(e.target.value)} className="h-11 rounded-xl" placeholder="Masukkan nama lengkap" /> <Input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="h-11 rounded-xl"
placeholder="Masukkan nama lengkap"
/>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-bold uppercase text-slate-700">Username</Label> <Label className="text-xs font-bold text-slate-700 uppercase">Username</Label>
<Input required value={username} onChange={e => setUsername(e.target.value)} className="h-11 rounded-xl" placeholder="Masukkan username unik" /> <Input
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="h-11 rounded-xl"
placeholder="Masukkan username unik"
/>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-bold uppercase text-slate-700">Password</Label> <Label className="text-xs font-bold text-slate-700 uppercase">Password</Label>
<Input required type="password" value={password} onChange={e => setPassword(e.target.value)} className="h-11 rounded-xl" placeholder="Buat kata sandi (min. 6 karakter)" /> <Input
required
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-xl"
placeholder="Buat kata sandi (min. 6 karakter)"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-bold uppercase text-slate-700">Role Akses</Label> <Label className="text-xs font-bold text-slate-700 uppercase">Role Akses</Label>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
onClick={() => setRole('user')} onClick={() => setRole('user')}
className={`h-11 rounded-xl border flex items-center justify-center gap-2 text-sm font-bold transition-all ${ className={`flex h-11 items-center justify-center gap-2 rounded-xl border text-sm font-bold transition-all ${
role === 'user' role === 'user'
? 'border-[#1B2CC1] bg-[#1B2CC1]/5 text-[#1B2CC1] ring-1 ring-[#1B2CC1]/20' ? 'border-[#1B2CC1] bg-[#1B2CC1]/5 text-[#1B2CC1] ring-1 ring-[#1B2CC1]/20'
: 'border-slate-200 text-slate-500 hover:bg-slate-50' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`} }`}
> >
<User className="w-4 h-4" /> User Biasa <User className="h-4 w-4" /> User Biasa
</button> </button>
<button <button
type="button" type="button"
onClick={() => setRole('superadmin')} onClick={() => setRole('superadmin')}
className={`h-11 rounded-xl border flex items-center justify-center gap-2 text-sm font-bold transition-all ${ className={`flex h-11 items-center justify-center gap-2 rounded-xl border text-sm font-bold transition-all ${
role === 'superadmin' role === 'superadmin'
? 'border-purple-500 bg-purple-50 text-purple-700 ring-1 ring-purple-500/20' ? 'border-purple-500 bg-purple-50 text-purple-700 ring-1 ring-purple-500/20'
: 'border-slate-200 text-slate-500 hover:bg-slate-50' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`} }`}
> >
<ShieldCheck className="w-4 h-4" /> Superadmin <ShieldCheck className="h-4 w-4" /> Superadmin
</button> </button>
</div> </div>
</div> </div>
<div className="pt-4 flex gap-3"> <div className="flex gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => setIsCreateOpen(false)} className="flex-1 h-11 rounded-xl">Batal</Button> <Button
<Button type="submit" disabled={actionLoading} className="flex-1 h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white"> type="button"
{actionLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Simpan User'} variant="outline"
onClick={() => setIsCreateOpen(false)}
className="h-11 flex-1 rounded-xl"
>
Batal
</Button>
<Button
type="submit"
disabled={actionLoading}
className="h-11 flex-1 rounded-xl bg-[#1B2CC1] text-white hover:bg-[#15229E]"
>
{actionLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : 'Simpan User'}
</Button> </Button>
</div> </div>
</form> </form>
@@ -290,20 +367,41 @@ export default function UsersPage() {
{/* RESET PASSWORD MODAL */} {/* RESET PASSWORD MODAL */}
<Dialog open={isPassOpen} onOpenChange={setIsPassOpen}> <Dialog open={isPassOpen} onOpenChange={setIsPassOpen}>
<DialogContent className="sm:max-w-md rounded-3xl p-0 overflow-hidden border-0"> <DialogContent className="overflow-hidden rounded-3xl border-0 p-0 sm:max-w-md">
<div className="px-6 pt-6 pb-4 border-b border-slate-100"> <div className="border-b border-slate-100 px-6 pt-6 pb-4">
<DialogTitle className="text-xl font-black">Ubah Password</DialogTitle> <DialogTitle className="text-xl font-black">Ubah Password</DialogTitle>
<p className="text-xs text-slate-500 mt-1">Ubah password untuk user <span className="font-bold text-slate-900">@{selectedUser?.username}</span></p> <p className="mt-1 text-xs text-slate-500">
Ubah password untuk user{' '}
<span className="font-bold text-slate-900">@{selectedUser?.username}</span>
</p>
</div> </div>
<form onSubmit={handleResetPassword} className="px-6 py-4 space-y-4"> <form onSubmit={handleResetPassword} className="space-y-4 px-6 py-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-bold uppercase text-slate-700">Password Baru</Label> <Label className="text-xs font-bold text-slate-700 uppercase">Password Baru</Label>
<Input required type="password" value={newPass} onChange={e => setNewPass(e.target.value)} className="h-11 rounded-xl" placeholder="Minimal 6 karakter" /> <Input
required
type="password"
value={newPass}
onChange={(e) => setNewPass(e.target.value)}
className="h-11 rounded-xl"
placeholder="Minimal 6 karakter"
/>
</div> </div>
<div className="pt-4 flex gap-3"> <div className="flex gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => setIsPassOpen(false)} className="flex-1 h-11 rounded-xl">Batal</Button> <Button
<Button type="submit" disabled={actionLoading} className="flex-1 h-11 rounded-xl bg-slate-900 hover:bg-slate-800 text-white"> type="button"
{actionLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Simpan Password'} variant="outline"
onClick={() => setIsPassOpen(false)}
className="h-11 flex-1 rounded-xl"
>
Batal
</Button>
<Button
type="submit"
disabled={actionLoading}
className="h-11 flex-1 rounded-xl bg-slate-900 text-white hover:bg-slate-800"
>
{actionLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : 'Simpan Password'}
</Button> </Button>
</div> </div>
</form> </form>
+1 -5
View File
@@ -1,7 +1,3 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) { export default function AuthLayout({ children }: { children: React.ReactNode }) {
return ( return <div className="min-h-screen bg-[#F4F6FB] dark:bg-[#0B0F19]">{children}</div>
<div className="min-h-screen bg-[#F4F6FB] dark:bg-[#0B0F19]">
{children}
</div>
);
} }
+64 -46
View File
@@ -41,121 +41,139 @@ export default function LoginPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4 lg:p-6 relative overflow-hidden"> <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-50 p-4 lg:p-6 dark:bg-slate-950">
{/* Elegant Ambient Background */} {/* Elegant Ambient Background */}
<div className="absolute inset-0 z-0 pointer-events-none"> <div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] bg-[size:24px_24px]"></div>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 h-[400px] w-[600px] rounded-full bg-[#1B2CC1] opacity-10 dark:opacity-20 blur-[120px]"></div> <div className="absolute top-1/2 left-1/2 h-[400px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#1B2CC1] opacity-10 blur-[120px] dark:opacity-20"></div>
</div> </div>
<div className="w-full max-w-5xl bg-white dark:bg-slate-900 lg:rounded-[2.5rem] rounded-3xl shadow-2xl shadow-[#1B2CC1]/10 flex overflow-hidden border border-slate-100 dark:border-slate-800 relative z-10"> <div className="relative z-10 flex w-full max-w-5xl overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-2xl shadow-[#1B2CC1]/10 lg:rounded-[2.5rem] dark:border-slate-800 dark:bg-slate-900">
{/* Left Side: Branding (Hidden on Mobile) */} {/* Left Side: Branding (Hidden on Mobile) */}
<div className="hidden lg:flex w-1/2 bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-12 flex-col justify-between relative overflow-hidden"> <div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-12 lg:flex">
<div className="absolute top-0 left-0 w-full h-full pointer-events-none"> <div className="pointer-events-none absolute top-0 left-0 h-full w-full">
<div className="absolute -top-[20%] -left-[10%] w-[60%] h-[60%] rounded-full bg-white/10 blur-3xl" /> <div className="absolute -top-[20%] -left-[10%] h-[60%] w-[60%] rounded-full bg-white/10 blur-3xl" />
<div className="absolute top-[60%] -right-[10%] w-[70%] h-[70%] rounded-full bg-blue-400/20 blur-3xl" /> <div className="absolute top-[60%] -right-[10%] h-[70%] w-[70%] rounded-full bg-blue-400/20 blur-3xl" />
</div> </div>
<div className="relative z-10"> <div className="relative z-10">
<div className="inline-flex items-center justify-center p-3 bg-white/10 rounded-2xl backdrop-blur-md border border-white/20 mb-8 shadow-sm"> <div className="mb-8 inline-flex items-center justify-center rounded-2xl border border-white/20 bg-white/10 p-3 shadow-sm backdrop-blur-md">
<LogIn className="w-8 h-8 text-white" /> <LogIn className="h-8 w-8 text-white" />
</div> </div>
<h1 className="text-4xl font-black text-white leading-tight"> <h1 className="text-4xl leading-tight font-black text-white">
Selamat Datang <br /> di TitipIn Selamat Datang <br /> di TitipIn
</h1> </h1>
<p className="text-blue-100 mt-4 text-base max-w-sm leading-relaxed"> <p className="mt-4 max-w-sm text-base leading-relaxed text-blue-100">
Platform modern dan terpercaya untuk mengelola pesanan jasa titip Anda dengan rapi, efisien, dan transparan. Platform modern dan terpercaya untuk mengelola pesanan jasa titip Anda dengan rapi,
efisien, dan transparan.
</p> </p>
</div> </div>
<div className="relative z-10 flex items-center gap-4 bg-white/10 p-4 rounded-2xl backdrop-blur-sm border border-white/10 w-fit"> <div className="relative z-10 flex w-fit items-center gap-4 rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur-sm">
<div className="flex -space-x-3"> <div className="flex -space-x-3">
<div className="w-10 h-10 rounded-full bg-blue-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-blue-700">A</div> <div className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-[#121E85] bg-blue-200 text-[10px] font-bold text-blue-700">
<div className="w-10 h-10 rounded-full bg-purple-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-purple-700">B</div> A
<div className="w-10 h-10 rounded-full bg-amber-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-amber-700">C</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-[#121E85] bg-purple-200 text-[10px] font-bold text-purple-700">
B
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-[#121E85] bg-amber-200 text-[10px] font-bold text-amber-700">
C
</div>
</div> </div>
<p className="text-xs text-blue-100 font-medium">Bergabung dengan ribuan<br/>pengguna lainnya.</p> <p className="text-xs font-medium text-blue-100">
Bergabung dengan ribuan
<br />
pengguna lainnya.
</p>
</div> </div>
</div> </div>
{/* Right Side: Form */} {/* Right Side: Form */}
<div className="w-full lg:w-1/2 p-8 lg:p-14 flex flex-col justify-center"> <div className="flex w-full flex-col justify-center p-8 lg:w-1/2 lg:p-14">
<div className="lg:hidden flex flex-col items-center mb-8"> <div className="mb-8 flex flex-col items-center lg:hidden">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#1B2CC1] to-[#121E85] flex items-center justify-center text-white shadow-lg shadow-[#1B2CC1]/25 mb-4"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#1B2CC1] to-[#121E85] text-white shadow-lg shadow-[#1B2CC1]/25">
<LogIn className="w-8 h-8" /> <LogIn className="h-8 w-8" />
</div> </div>
<h1 className="text-2xl font-black text-slate-900 dark:text-white">TitipIn</h1> <h1 className="text-2xl font-black text-slate-900 dark:text-white">TitipIn</h1>
</div> </div>
<div className="mb-8 text-center lg:text-left"> <div className="mb-8 text-center lg:text-left">
<h2 className="text-2xl font-black text-slate-900 dark:text-white hidden lg:block mb-2">Masuk ke Akun</h2> <h2 className="mb-2 hidden text-2xl font-black text-slate-900 lg:block dark:text-white">
<p className="text-sm text-slate-500">Silakan masukkan kredensial Anda untuk melanjutkan</p> Masuk ke Akun
</h2>
<p className="text-sm text-slate-500">
Silakan masukkan kredensial Anda untuk melanjutkan
</p>
</div> </div>
{error && ( {error && (
<div className="bg-rose-50 text-rose-600 p-3 rounded-xl text-sm font-medium mb-6 border border-rose-100 text-center"> <div className="mb-6 rounded-xl border border-rose-100 bg-rose-50 p-3 text-center text-sm font-medium text-rose-600">
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Username</label> <label className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
Username
</label>
<div className="relative"> <div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <User className="absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input <Input
name="username" name="username"
className="pl-10 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]" className="h-12 rounded-xl border-slate-200 bg-slate-50 pl-10 focus-visible:ring-[#1B2CC1] dark:bg-slate-800/50"
placeholder="Masukkan username" placeholder="Masukkan username"
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Password</label> <label className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
Password
</label>
<div className="relative flex items-center"> <div className="relative flex items-center">
<KeyRound className="absolute left-3.5 w-4 h-4 text-slate-400" /> <KeyRound className="absolute left-3.5 h-4 w-4 text-slate-400" />
<Input <Input
name="password" name="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
className="pl-10 pr-12 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]" className="h-12 rounded-xl border-slate-200 bg-slate-50 pr-12 pl-10 focus-visible:ring-[#1B2CC1] dark:bg-slate-800/50"
placeholder="Masukkan password" placeholder="Masukkan password"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors cursor-pointer" className="absolute right-3.5 cursor-pointer text-slate-400 transition-colors hover:text-slate-600 dark:hover:text-slate-200"
aria-label={showPassword ? 'Sembunyikan password' : 'Tampilkan password'} aria-label={showPassword ? 'Sembunyikan password' : 'Tampilkan password'}
> >
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />} {showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button> </button>
</div> </div>
</div> </div>
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full h-12 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold text-base shadow-md shadow-[#1B2CC1]/20 transition-all active:scale-[0.98] mt-2 cursor-pointer" className="mt-2 h-12 w-full cursor-pointer rounded-xl bg-[#1B2CC1] text-base font-bold text-white shadow-md shadow-[#1B2CC1]/20 transition-all hover:bg-[#15229E] active:scale-[0.98]"
> >
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Masuk Sekarang'} {loading ? <Loader2 className="h-5 w-5 animate-spin" /> : 'Masuk Sekarang'}
</Button> </Button>
</form> </form>
<p className="text-center text-sm text-slate-500 mt-8"> <p className="mt-8 text-center text-sm text-slate-500">
Belum punya akun?{' '} Belum punya akun?{' '}
<button <button
onClick={() => { onClick={() => {
const search = window.location.search const search = window.location.search
router.push(`/register${search}`) router.push(`/register${search}`)
}} }}
className="font-bold text-[#1B2CC1] hover:underline cursor-pointer" className="cursor-pointer font-bold text-[#1B2CC1] hover:underline"
> >
Daftar di sini Daftar di sini
</button> </button>
</p> </p>
<p className="text-center text-xs text-slate-400 mt-12 font-medium"> <p className="mt-12 text-center text-xs font-medium text-slate-400">
&copy; {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan &copy; {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan
</p> </p>
</div> </div>
+74 -55
View File
@@ -56,154 +56,173 @@ export default function RegisterPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4 lg:p-6 relative overflow-hidden"> <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-50 p-4 lg:p-6 dark:bg-slate-950">
{/* Elegant Ambient Background */} {/* Elegant Ambient Background */}
<div className="absolute inset-0 z-0 pointer-events-none"> <div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] bg-[size:24px_24px]"></div>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 h-[400px] w-[600px] rounded-full bg-[#121E85] opacity-10 dark:opacity-20 blur-[120px]"></div> <div className="absolute top-1/2 left-1/2 h-[400px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#121E85] opacity-10 blur-[120px] dark:opacity-20"></div>
</div> </div>
<div className="w-full max-w-5xl bg-white dark:bg-slate-900 lg:rounded-[2.5rem] rounded-3xl shadow-2xl shadow-[#1B2CC1]/10 flex overflow-hidden border border-slate-100 dark:border-slate-800 relative z-10"> <div className="relative z-10 flex w-full max-w-5xl overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-2xl shadow-[#1B2CC1]/10 lg:rounded-[2.5rem] dark:border-slate-800 dark:bg-slate-900">
{/* Left Side: Branding (Hidden on Mobile) */} {/* Left Side: Branding (Hidden on Mobile) */}
<div className="hidden lg:flex w-1/2 bg-gradient-to-br from-[#121E85] to-[#1B2CC1] p-12 flex-col justify-between relative overflow-hidden"> <div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-gradient-to-br from-[#121E85] to-[#1B2CC1] p-12 lg:flex">
<div className="absolute top-0 left-0 w-full h-full pointer-events-none"> <div className="pointer-events-none absolute top-0 left-0 h-full w-full">
<div className="absolute -top-[20%] -left-[10%] w-[60%] h-[60%] rounded-full bg-white/10 blur-3xl" /> <div className="absolute -top-[20%] -left-[10%] h-[60%] w-[60%] rounded-full bg-white/10 blur-3xl" />
<div className="absolute top-[60%] -right-[10%] w-[70%] h-[70%] rounded-full bg-indigo-400/20 blur-3xl" /> <div className="absolute top-[60%] -right-[10%] h-[70%] w-[70%] rounded-full bg-indigo-400/20 blur-3xl" />
</div> </div>
<div className="relative z-10"> <div className="relative z-10">
<div className="inline-flex items-center justify-center p-3 bg-white/10 rounded-2xl backdrop-blur-md border border-white/20 mb-8 shadow-sm"> <div className="mb-8 inline-flex items-center justify-center rounded-2xl border border-white/20 bg-white/10 p-3 shadow-sm backdrop-blur-md">
<UserPlus className="w-8 h-8 text-white" /> <UserPlus className="h-8 w-8 text-white" />
</div> </div>
<h1 className="text-4xl font-black text-white leading-tight"> <h1 className="text-4xl leading-tight font-black text-white">
Mulai Perjalanan <br /> Anda di TitipIn Mulai Perjalanan <br /> Anda di TitipIn
</h1> </h1>
<p className="text-indigo-100 mt-4 text-base max-w-sm leading-relaxed"> <p className="mt-4 max-w-sm text-base leading-relaxed text-indigo-100">
Buat akun secara gratis dan nikmati kemudahan mengelola PO jasa titip tanpa ribet. Buat akun secara gratis dan nikmati kemudahan mengelola PO jasa titip tanpa ribet.
</p> </p>
</div> </div>
<div className="relative z-10 flex items-center gap-4 bg-white/10 p-4 rounded-2xl backdrop-blur-sm border border-white/10 w-fit"> <div className="relative z-10 flex w-fit items-center gap-4 rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur-sm">
<div className="flex -space-x-3"> <div className="flex -space-x-3">
<div className="w-10 h-10 rounded-full bg-emerald-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-emerald-700">âś“</div> <div className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-[#121E85] bg-emerald-200 text-[10px] font-bold text-emerald-700">
<div className="w-10 h-10 rounded-full bg-cyan-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-cyan-700">âś“</div> âś“
<div className="w-10 h-10 rounded-full bg-rose-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-rose-700">âś“</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-[#121E85] bg-cyan-200 text-[10px] font-bold text-cyan-700">
âś“
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-[#121E85] bg-rose-200 text-[10px] font-bold text-rose-700">
âś“
</div>
</div> </div>
<p className="text-xs text-indigo-100 font-medium">Aman, Cepat, dan<br/>Mudah digunakan.</p> <p className="text-xs font-medium text-indigo-100">
Aman, Cepat, dan
<br />
Mudah digunakan.
</p>
</div> </div>
</div> </div>
{/* Right Side: Form */} {/* Right Side: Form */}
<div className="w-full lg:w-1/2 p-8 lg:p-14 flex flex-col justify-center"> <div className="flex w-full flex-col justify-center p-8 lg:w-1/2 lg:p-14">
<div className="lg:hidden flex flex-col items-center mb-8"> <div className="mb-8 flex flex-col items-center lg:hidden">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#1B2CC1] to-[#121E85] flex items-center justify-center text-white shadow-lg shadow-[#1B2CC1]/25 mb-4"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#1B2CC1] to-[#121E85] text-white shadow-lg shadow-[#1B2CC1]/25">
<UserPlus className="w-8 h-8" /> <UserPlus className="h-8 w-8" />
</div> </div>
<h1 className="text-2xl font-black text-slate-900 dark:text-white">Daftar TitipIn</h1> <h1 className="text-2xl font-black text-slate-900 dark:text-white">Daftar TitipIn</h1>
</div> </div>
<div className="mb-8 text-center lg:text-left"> <div className="mb-8 text-center lg:text-left">
<h2 className="text-2xl font-black text-slate-900 dark:text-white hidden lg:block mb-2">Buat Akun Baru</h2> <h2 className="mb-2 hidden text-2xl font-black text-slate-900 lg:block dark:text-white">
Buat Akun Baru
</h2>
<p className="text-sm text-slate-500">Lengkapi form di bawah untuk membuat akun baru</p> <p className="text-sm text-slate-500">Lengkapi form di bawah untuk membuat akun baru</p>
</div> </div>
{error && ( {error && (
<div className="bg-rose-50 text-rose-600 p-3 rounded-xl text-sm font-medium mb-6 border border-rose-100 text-center"> <div className="mb-6 rounded-xl border border-rose-100 bg-rose-50 p-3 text-center text-sm font-medium text-rose-600">
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Nama Lengkap</label> <label className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
Nama Lengkap
</label>
<div className="relative"> <div className="relative">
<BadgeCheck className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <BadgeCheck className="absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input <Input
name="name" name="name"
className="pl-10 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]" className="h-12 rounded-xl border-slate-200 bg-slate-50 pl-10 focus-visible:ring-[#1B2CC1] dark:bg-slate-800/50"
placeholder="Masukkan nama lengkap" placeholder="Masukkan nama lengkap"
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Username</label> <label className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
Username
</label>
<div className="relative"> <div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <User className="absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input <Input
name="username" name="username"
className="pl-10 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]" className="h-12 rounded-xl border-slate-200 bg-slate-50 pl-10 focus-visible:ring-[#1B2CC1] dark:bg-slate-800/50"
placeholder="Buat username unik" placeholder="Buat username unik"
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Password</label> <label className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
Password
</label>
<div className="relative flex items-center"> <div className="relative flex items-center">
<KeyRound className="absolute left-3.5 w-4 h-4 text-slate-400" /> <KeyRound className="absolute left-3.5 h-4 w-4 text-slate-400" />
<Input <Input
name="password" name="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
className="pl-10 pr-12 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]" className="h-12 rounded-xl border-slate-200 bg-slate-50 pr-12 pl-10 focus-visible:ring-[#1B2CC1] dark:bg-slate-800/50"
placeholder="Minimal 6 karakter" placeholder="Minimal 6 karakter"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors cursor-pointer" className="absolute right-3.5 cursor-pointer text-slate-400 transition-colors hover:text-slate-600 dark:hover:text-slate-200"
aria-label={showPassword ? 'Sembunyikan password' : 'Tampilkan password'} aria-label={showPassword ? 'Sembunyikan password' : 'Tampilkan password'}
> >
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />} {showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button> </button>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Konfirmasi Password</label> <label className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
Konfirmasi Password
</label>
<div className="relative flex items-center"> <div className="relative flex items-center">
<KeyRound className="absolute left-3.5 w-4 h-4 text-slate-400" /> <KeyRound className="absolute left-3.5 h-4 w-4 text-slate-400" />
<Input <Input
name="confirm" name="confirm"
type={showConfirm ? 'text' : 'password'} type={showConfirm ? 'text' : 'password'}
className="pl-10 pr-12 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]" className="h-12 rounded-xl border-slate-200 bg-slate-50 pr-12 pl-10 focus-visible:ring-[#1B2CC1] dark:bg-slate-800/50"
placeholder="Ketik ulang password" placeholder="Ketik ulang password"
/> />
<button <button
type="button" type="button"
onClick={() => setShowConfirm(!showConfirm)} onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors cursor-pointer" className="absolute right-3.5 cursor-pointer text-slate-400 transition-colors hover:text-slate-600 dark:hover:text-slate-200"
aria-label={showConfirm ? 'Sembunyikan password' : 'Tampilkan password'} aria-label={showConfirm ? 'Sembunyikan password' : 'Tampilkan password'}
> >
{showConfirm ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />} {showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button> </button>
</div> </div>
</div> </div>
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full h-12 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold text-base shadow-md shadow-[#1B2CC1]/20 transition-all active:scale-[0.98] mt-2 cursor-pointer" className="mt-2 h-12 w-full cursor-pointer rounded-xl bg-[#1B2CC1] text-base font-bold text-white shadow-md shadow-[#1B2CC1]/20 transition-all hover:bg-[#15229E] active:scale-[0.98]"
> >
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Buat Akun'} {loading ? <Loader2 className="h-5 w-5 animate-spin" /> : 'Buat Akun'}
</Button> </Button>
</form> </form>
<p className="text-center text-sm text-slate-500 mt-8"> <p className="mt-8 text-center text-sm text-slate-500">
Sudah punya akun?{' '} Sudah punya akun?{' '}
<button <button
onClick={() => { onClick={() => {
const search = window.location.search const search = window.location.search
router.push(`/login${search}`) router.push(`/login${search}`)
}} }}
className="font-bold text-[#1B2CC1] hover:underline cursor-pointer" className="cursor-pointer font-bold text-[#1B2CC1] hover:underline"
> >
Masuk di sini Masuk di sini
</button> </button>
</p> </p>
<p className="text-center text-xs text-slate-400 mt-12 font-medium"> <p className="mt-12 text-center text-xs font-medium text-slate-400">
&copy; {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan &copy; {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan
</p> </p>
</div> </div>
+563 -254
View File
File diff suppressed because it is too large Load Diff
+86 -56
View File
@@ -1,15 +1,21 @@
@import "tailwindcss"; @import 'tailwindcss';
@import "tw-animate-css"; @import 'tw-animate-css';
@import "shadcn/tailwind.css"; @import 'shadcn/tailwind.css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-roboto), var(--font-open-sans), -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", "Open Sans", system-ui, sans-serif; --font-sans:
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; var(--font-roboto), var(--font-open-sans), -apple-system, BlinkMacSystemFont, 'SF Pro Text',
--font-heading: var(--font-roboto), -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif; 'SF Pro Display', 'Segoe UI', Roboto, 'Helvetica Neue', 'Open Sans', system-ui, sans-serif;
--font-mono:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
--font-heading:
var(--font-roboto), -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto,
sans-serif;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -44,62 +50,62 @@
} }
:root { :root {
--background: #F4F6FB; --background: #f4f6fb;
--foreground: #0F172A; --foreground: #0f172a;
--card: #FFFFFF; --card: #ffffff;
--card-foreground: #0F172A; --card-foreground: #0f172a;
--popover: #FFFFFF; --popover: #ffffff;
--popover-foreground: #0F172A; --popover-foreground: #0f172a;
--primary: #1B2CC1; --primary: #1b2cc1;
--primary-foreground: #FFFFFF; --primary-foreground: #ffffff;
--secondary: #EEF1FC; --secondary: #eef1fc;
--secondary-foreground: #1B2CC1; --secondary-foreground: #1b2cc1;
--muted: #F1F5F9; --muted: #f1f5f9;
--muted-foreground: #64748B; --muted-foreground: #64748b;
--accent: #EEF2FF; --accent: #eef2ff;
--accent-foreground: #1B2CC1; --accent-foreground: #1b2cc1;
--destructive: #EF4444; --destructive: #ef4444;
--border: #E2E8F0; --border: #e2e8f0;
--input: #E2E8F0; --input: #e2e8f0;
--ring: #1B2CC1; --ring: #1b2cc1;
--radius: 0.75rem; --radius: 0.75rem;
--sidebar: #FFFFFF; --sidebar: #ffffff;
--sidebar-foreground: #334155; --sidebar-foreground: #334155;
--sidebar-primary: #1B2CC1; --sidebar-primary: #1b2cc1;
--sidebar-primary-foreground: #FFFFFF; --sidebar-primary-foreground: #ffffff;
--sidebar-accent: #F1F5F9; --sidebar-accent: #f1f5f9;
--sidebar-accent-foreground: #0F172A; --sidebar-accent-foreground: #0f172a;
--sidebar-border: #E2E8F0; --sidebar-border: #e2e8f0;
--sidebar-ring: #1B2CC1; --sidebar-ring: #1b2cc1;
} }
.dark { .dark {
--background: #0B0F19; --background: #0b0f19;
--foreground: #F8FAFC; --foreground: #f8fafc;
--card: #111827; --card: #111827;
--card-foreground: #F8FAFC; --card-foreground: #f8fafc;
--popover: #111827; --popover: #111827;
--popover-foreground: #F8FAFC; --popover-foreground: #f8fafc;
--primary: #3B82F6; --primary: #3b82f6;
--primary-foreground: #FFFFFF; --primary-foreground: #ffffff;
--secondary: #1E293B; --secondary: #1e293b;
--secondary-foreground: #93C5FD; --secondary-foreground: #93c5fd;
--muted: #1E293B; --muted: #1e293b;
--muted-foreground: #94A3B8; --muted-foreground: #94a3b8;
--accent: #1E293B; --accent: #1e293b;
--accent-foreground: #93C5FD; --accent-foreground: #93c5fd;
--destructive: #EF4444; --destructive: #ef4444;
--border: #1F2937; --border: #1f2937;
--input: #1F2937; --input: #1f2937;
--ring: #3B82F6; --ring: #3b82f6;
--sidebar: #111827; --sidebar: #111827;
--sidebar-foreground: #CBD5E1; --sidebar-foreground: #cbd5e1;
--sidebar-primary: #3B82F6; --sidebar-primary: #3b82f6;
--sidebar-primary-foreground: #FFFFFF; --sidebar-primary-foreground: #ffffff;
--sidebar-accent: #1E293B; --sidebar-accent: #1e293b;
--sidebar-accent-foreground: #F8FAFC; --sidebar-accent-foreground: #f8fafc;
--sidebar-border: #1F2937; --sidebar-border: #1f2937;
--sidebar-ring: #3B82F6; --sidebar-ring: #3b82f6;
} }
@layer base { @layer base {
@@ -108,12 +114,36 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: var(--font-roboto), var(--font-open-sans), -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", "Open Sans", system-ui, sans-serif; font-family:
var(--font-roboto),
var(--font-open-sans),
-apple-system,
BlinkMacSystemFont,
'SF Pro Text',
'SF Pro Display',
'Segoe UI',
Roboto,
'Helvetica Neue',
'Open Sans',
system-ui,
sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
html { html {
font-family: var(--font-roboto), var(--font-open-sans), -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", "Open Sans", system-ui, sans-serif; font-family:
var(--font-roboto),
var(--font-open-sans),
-apple-system,
BlinkMacSystemFont,
'SF Pro Text',
'SF Pro Display',
'Segoe UI',
Roboto,
'Helvetica Neue',
'Open Sans',
system-ui,
sans-serif;
} }
} }
button { button {
+28 -30
View File
@@ -8,37 +8,35 @@ export const contentType = 'image/png'
export default function Icon() { export default function Icon() {
return new ImageResponse( return new ImageResponse(
( <div
<div style={{
style={{ width: '100%',
width: '100%', height: '100%',
height: '100%', display: 'flex',
display: 'flex', alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', background: 'linear-gradient(to bottom right, #1B2CC1, #121E85)',
background: 'linear-gradient(to bottom right, #1B2CC1, #121E85)', borderRadius: '8px',
borderRadius: '8px', }}
}} >
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
> >
<svg <path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
xmlns="http://www.w3.org/2000/svg" <path d="M20 3v4" />
width="20" <path d="M22 5h-4" />
height="20" <path d="M4 17v2" />
viewBox="0 0 24 24" <path d="M5 18H3" />
fill="none" </svg>
stroke="white" </div>,
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
<path d="M20 3v4" />
<path d="M22 5h-4" />
<path d="M4 17v2" />
<path d="M5 18H3" />
</svg>
</div>
),
{ {
...size, ...size,
} }
+30 -32
View File
@@ -1,58 +1,57 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from 'next'
import { Roboto, Open_Sans } from "next/font/google"; import { Roboto, Open_Sans } from 'next/font/google'
import "./globals.css"; import './globals.css'
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from '@/components/ui/sonner'
const roboto = Roboto({ const roboto = Roboto({
weight: ["300", "400", "500", "700", "900"], weight: ['300', '400', '500', '700', '900'],
subsets: ["latin"], subsets: ['latin'],
variable: "--font-roboto", variable: '--font-roboto',
display: "swap", display: 'swap',
}); })
const openSans = Open_Sans({ const openSans = Open_Sans({
subsets: ["latin"], subsets: ['latin'],
variable: "--font-open-sans", variable: '--font-open-sans',
display: "swap", display: 'swap',
}); })
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: "#1B2CC1", themeColor: '#1B2CC1',
width: "device-width", width: 'device-width',
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 1,
userScalable: false, userScalable: false,
}; }
export const metadata: Metadata = { export const metadata: Metadata = {
title: "TitipIn - Sistem Titip Pesanan", title: 'TitipIn - Sistem Titip Pesanan',
description: "Platform jasa titip pesanan bersama yang modern, cepat, dan transparan.", description: 'Platform jasa titip pesanan bersama yang modern, cepat, dan transparan.',
manifest: "/manifest.json", manifest: '/manifest.json',
appleWebApp: { appleWebApp: {
capable: true, capable: true,
statusBarStyle: "black-translucent", statusBarStyle: 'black-translucent',
title: "TitipIn", title: 'TitipIn',
}, },
formatDetection: { formatDetection: {
telephone: false, telephone: false,
}, },
icons: { icons: {
icon: [ icon: [
{ url: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" }, { url: '/icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
{ url: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png" }, { url: '/icons/icon-512x512.png', sizes: '512x512', type: 'image/png' },
], ],
apple: [ apple: [
{ url: "/icons/icon-152x152.png", sizes: "152x152", type: "image/png" }, { url: '/icons/icon-152x152.png', sizes: '152x152', type: 'image/png' },
{ url: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" }, { url: '/icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
], ],
}, },
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode
}>) { }>) {
return ( return (
<html <html
@@ -60,14 +59,13 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
className={`${roboto.variable} ${openSans.variable} h-full antialiased`} className={`${roboto.variable} ${openSans.variable} h-full antialiased`}
> >
<body <body
suppressHydrationWarning suppressHydrationWarning
className="min-h-full flex flex-col bg-[#F4F6FB] dark:bg-[#0B0F19] font-sans antialiased" className="flex min-h-full flex-col bg-[#F4F6FB] font-sans antialiased dark:bg-[#0B0F19]"
> >
{children} {children}
<Toaster position="top-center" /> <Toaster position="top-center" />
</body> </body>
</html> </html>
); )
} }
+19 -20
View File
@@ -4,45 +4,44 @@ import { SearchX, ArrowLeft, Home } from 'lucide-react'
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4 relative overflow-hidden"> <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-50 p-4 dark:bg-slate-950">
{/* Background ambient effects */} {/* Background ambient effects */}
<div className="absolute inset-0 z-0 pointer-events-none"> <div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] bg-[size:24px_24px]"></div>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 h-[400px] w-[600px] rounded-full bg-[#1B2CC1] opacity-10 dark:opacity-20 blur-[120px]"></div> <div className="absolute top-1/2 left-1/2 h-[400px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#1B2CC1] opacity-10 blur-[120px] dark:opacity-20"></div>
</div> </div>
<div className="relative z-10 w-full max-w-lg"> <div className="relative z-10 w-full max-w-lg">
<div className="bg-white dark:bg-slate-900 rounded-[2rem] shadow-2xl shadow-[#1B2CC1]/10 border border-slate-100 dark:border-slate-800 p-8 sm:p-12 text-center animate-in fade-in zoom-in duration-500"> <div className="animate-in fade-in zoom-in rounded-[2rem] border border-slate-100 bg-white p-8 text-center shadow-2xl shadow-[#1B2CC1]/10 duration-500 sm:p-12 dark:border-slate-800 dark:bg-slate-900">
<div className="mx-auto mb-6 flex h-24 w-24 rotate-3 items-center justify-center rounded-[2rem] border-4 border-white bg-blue-50 shadow-inner sm:h-32 sm:w-32 dark:border-slate-800 dark:bg-blue-950/50">
<div className="w-24 h-24 sm:w-32 sm:h-32 mx-auto bg-blue-50 dark:bg-blue-950/50 rounded-[2rem] border-4 border-white dark:border-slate-800 flex items-center justify-center mb-6 shadow-inner rotate-3"> <SearchX className="h-12 w-12 text-[#1B2CC1] sm:h-16 sm:w-16" />
<SearchX className="w-12 h-12 sm:w-16 sm:h-16 text-[#1B2CC1]" />
</div> </div>
<h1 className="text-6xl font-black text-slate-900 dark:text-white mb-2 tracking-tighter"> <h1 className="mb-2 text-6xl font-black tracking-tighter text-slate-900 dark:text-white">
4<span className="text-[#1B2CC1]">0</span>4 4<span className="text-[#1B2CC1]">0</span>4
</h1> </h1>
<h2 className="text-xl sm:text-2xl font-bold text-slate-800 dark:text-slate-200 mb-4"> <h2 className="mb-4 text-xl font-bold text-slate-800 sm:text-2xl dark:text-slate-200">
Halaman Tidak Ditemukan Halaman Tidak Ditemukan
</h2> </h2>
<p className="text-sm sm:text-base text-slate-500 dark:text-slate-400 mb-8 max-w-sm mx-auto leading-relaxed"> <p className="mx-auto mb-8 max-w-sm text-sm leading-relaxed text-slate-500 sm:text-base dark:text-slate-400">
Waduh, sepertinya halaman yang Anda cari sedang jalan-jalan atau memang tidak pernah ada. Mari kita kembali ke jalan yang benar! Waduh, sepertinya halaman yang Anda cari sedang jalan-jalan atau memang tidak pernah
ada. Mari kita kembali ke jalan yang benar!
</p> </p>
<div className="flex flex-col sm:flex-row gap-3 justify-center"> <div className="flex flex-col justify-center gap-3 sm:flex-row">
<Link href="/" className="w-full sm:w-auto"> <Link href="/" className="w-full sm:w-auto">
<Button className="w-full h-12 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold px-6 shadow-md shadow-[#1B2CC1]/20 gap-2 transition-all"> <Button className="h-12 w-full gap-2 rounded-xl bg-[#1B2CC1] px-6 font-bold text-white shadow-md shadow-[#1B2CC1]/20 transition-all hover:bg-[#15229E]">
<Home className="w-4 h-4" /> <Home className="h-4 w-4" />
Kembali ke Beranda Kembali ke Beranda
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
{/* Footer info */} {/* Footer info */}
<p className="text-center text-xs text-slate-400 font-medium mt-8"> <p className="mt-8 text-center text-xs font-medium text-slate-400">
TitipIn &copy; {new Date().getFullYear()} - Sistem Titip Pesanan TitipIn &copy; {new Date().getFullYear()} - Sistem Titip Pesanan
</p> </p>
</div> </div>
+3 -5
View File
@@ -8,16 +8,14 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
return ( return (
<div className="flex min-h-screen bg-[#F4F6FB] dark:bg-[#0B0F19] text-slate-900 dark:text-slate-100 font-sans"> <div className="flex min-h-screen bg-[#F4F6FB] font-sans text-slate-900 dark:bg-[#0B0F19] dark:text-slate-100">
{/* Sidebar */} {/* Sidebar */}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} /> <Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex min-w-0 flex-1 flex-col">
<Header onMenuClick={() => setSidebarOpen(true)} /> <Header onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 p-4 sm:p-6 lg:p-8 max-w-7xl w-full mx-auto"> <main className="mx-auto w-full max-w-7xl flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
{children}
</main>
</div> </div>
</div> </div>
) )
+35 -22
View File
@@ -2,7 +2,17 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { Menu, Calendar, Store, ClipboardList, Package, User, FileText, BarChart2, Settings } from 'lucide-react' import {
Menu,
Calendar,
Store,
ClipboardList,
Package,
User,
FileText,
BarChart2,
Settings,
} from 'lucide-react'
import { buttonVariants } from '@/components/ui/button' import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import Link from 'next/link' import Link from 'next/link'
@@ -24,35 +34,35 @@ export function Header({ onMenuClick }: { onMenuClick: () => void }) {
title: 'Open Order Hari Ini', title: 'Open Order Hari Ini',
subtitle: 'Daftar pesanan aktif yang siap kamu titip.', subtitle: 'Daftar pesanan aktif yang siap kamu titip.',
badge: 'Live PO', badge: 'Live PO',
Icon: Store Icon: Store,
} }
case '/my-orders': case '/my-orders':
return { return {
title: 'Jasa Order Saya', title: 'Jasa Order Saya',
subtitle: 'Kelola PO yang Anda buka untuk teman-teman.', subtitle: 'Kelola PO yang Anda buka untuk teman-teman.',
badge: 'Manajemen PO', badge: 'Manajemen PO',
Icon: ClipboardList Icon: ClipboardList,
} }
case '/my-purchases': case '/my-purchases':
return { return {
title: 'Pesanan Saya', title: 'Pesanan Saya',
subtitle: 'Pantau barang yang Anda titip beserta status tagihannya.', subtitle: 'Pantau barang yang Anda titip beserta status tagihannya.',
badge: 'Riwayat Titipan', badge: 'Riwayat Titipan',
Icon: Package Icon: Package,
} }
case '/profile': case '/profile':
return { return {
title: 'Pengaturan Profil', title: 'Pengaturan Profil',
subtitle: 'Kelola identitas dan preferensi akun Anda.', subtitle: 'Kelola identitas dan preferensi akun Anda.',
badge: 'Akun', badge: 'Akun',
Icon: User Icon: User,
} }
case '/reports': case '/reports':
return { return {
title: 'Laporan Keuangan', title: 'Laporan Keuangan',
subtitle: 'Pantau omzet, pengeluaran, dan hutang piutang Anda.', subtitle: 'Pantau omzet, pengeluaran, dan hutang piutang Anda.',
badge: 'Laporan', badge: 'Laporan',
Icon: BarChart2 Icon: BarChart2,
} }
case '/settings/users': case '/settings/users':
case '/settings/integrations': case '/settings/integrations':
@@ -60,7 +70,7 @@ export function Header({ onMenuClick }: { onMenuClick: () => void }) {
title: 'Pengaturan Aplikasi', title: 'Pengaturan Aplikasi',
subtitle: 'Konfigurasi integrasi, role, dan sistem.', subtitle: 'Konfigurasi integrasi, role, dan sistem.',
badge: 'Superadmin', badge: 'Superadmin',
Icon: Settings Icon: Settings,
} }
default: default:
if (pathname.startsWith('/my-orders/')) { if (pathname.startsWith('/my-orders/')) {
@@ -68,14 +78,14 @@ export function Header({ onMenuClick }: { onMenuClick: () => void }) {
title: 'Detail & Rekap Order', title: 'Detail & Rekap Order',
subtitle: 'Rincian pesanan, tagihan pemesan, dan ringkasan belanja.', subtitle: 'Rincian pesanan, tagihan pemesan, dan ringkasan belanja.',
badge: 'Detail PO', badge: 'Detail PO',
Icon: FileText Icon: FileText,
} }
} }
return { return {
title: 'TitipIn', title: 'TitipIn',
subtitle: 'Sistem Titip Pesanan Bersama', subtitle: 'Sistem Titip Pesanan Bersama',
badge: 'App', badge: 'App',
Icon: Store Icon: Store,
} }
} }
} }
@@ -83,31 +93,34 @@ export function Header({ onMenuClick }: { onMenuClick: () => void }) {
const { title, subtitle, badge, Icon } = getPageInfo() const { title, subtitle, badge, Icon } = getPageInfo()
return ( return (
<header className="sticky top-0 z-30 bg-white/90 dark:bg-slate-900/90 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800 px-6 py-4"> <header className="sticky top-0 z-30 border-b border-slate-200/80 bg-white/90 px-6 py-4 backdrop-blur-md dark:border-slate-800 dark:bg-slate-900/90">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
{/* Left: Mobile hamburger & Page Title */} {/* Left: Mobile hamburger & Page Title */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={onMenuClick} onClick={onMenuClick}
className="lg:hidden p-2 rounded-xl border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" className="rounded-xl border border-slate-200 p-2 text-slate-600 transition-colors hover:bg-slate-100 lg:hidden dark:border-slate-800 dark:text-slate-300 dark:hover:bg-slate-800"
> >
<Menu className="w-5 h-5" /> <Menu className="h-5 w-5" />
</button> </button>
<div className="flex items-center gap-3 sm:gap-4"> <div className="flex items-center gap-3 sm:gap-4">
<div className="hidden sm:flex items-center justify-center w-11 h-11 md:w-12 md:h-12 rounded-xl border border-blue-100 dark:border-blue-900/50 bg-gradient-to-br from-blue-50 to-[#1B2CC1]/10 dark:from-[#1B2CC1]/20 dark:to-[#121E85]/20 shadow-inner shrink-0"> <div className="hidden h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-blue-100 bg-gradient-to-br from-blue-50 to-[#1B2CC1]/10 shadow-inner sm:flex md:h-12 md:w-12 dark:border-blue-900/50 dark:from-[#1B2CC1]/20 dark:to-[#121E85]/20">
<Icon className="w-5 h-5 md:w-6 md:h-6 text-[#1B2CC1] dark:text-blue-400" strokeWidth={2.5} /> <Icon
className="h-5 w-5 text-[#1B2CC1] md:h-6 md:w-6 dark:text-blue-400"
strokeWidth={2.5}
/>
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-xl sm:text-2xl font-black tracking-tight text-slate-900 dark:text-white"> <h1 className="text-xl font-black tracking-tight text-slate-900 sm:text-2xl dark:text-white">
{title} {title}
</h1> </h1>
<span className="hidden sm:inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-bold bg-[#1B2CC1]/10 text-[#1B2CC1] dark:bg-blue-900/40 dark:text-blue-300"> <span className="hidden items-center rounded-full bg-[#1B2CC1]/10 px-2 py-0.5 text-[11px] font-bold text-[#1B2CC1] sm:inline-flex dark:bg-blue-900/40 dark:text-blue-300">
{badge} {badge}
</span> </span>
</div> </div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 hidden sm:block"> <p className="mt-0.5 hidden text-xs text-slate-500 sm:block dark:text-slate-400">
{subtitle} {subtitle}
</p> </p>
</div> </div>
@@ -117,9 +130,9 @@ export function Header({ onMenuClick }: { onMenuClick: () => void }) {
{/* Right: Date info & Quick Action */} {/* Right: Date info & Quick Action */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{todayStr && ( {todayStr && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-100/70 dark:bg-slate-800/60 border border-slate-200/60 dark:border-slate-800 text-[10px] sm:text-xs font-medium text-slate-600 dark:text-slate-300 animate-in fade-in font-bold"> <div className="animate-in fade-in flex items-center gap-2 rounded-xl border border-slate-200/60 bg-slate-100/70 px-3 py-1.5 text-[10px] font-bold font-medium text-slate-600 sm:text-xs dark:border-slate-800 dark:bg-slate-800/60 dark:text-slate-300">
<Calendar className="w-3.5 h-3.5 text-[#1B2CC1] shrink-0" /> <Calendar className="h-3.5 w-3.5 shrink-0 text-[#1B2CC1]" />
<span className='font-bold whitespace-nowrap'>{todayStr}</span> <span className="font-bold whitespace-nowrap">{todayStr}</span>
</div> </div>
)} )}
</div> </div>
+22 -22
View File
@@ -18,23 +18,23 @@ export function Navbar() {
const pathname = usePathname() const pathname = usePathname()
return ( return (
<nav className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <nav className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 w-full border-b backdrop-blur">
<div className="container flex h-16 items-center px-4 mx-auto max-w-5xl"> <div className="container mx-auto flex h-16 max-w-5xl items-center px-4">
<div className="mr-8 flex items-center gap-2"> <div className="mr-8 flex items-center gap-2">
<PackageOpen className="h-6 w-6 text-primary" /> <PackageOpen className="text-primary h-6 w-6" />
<Link href="/" className="font-bold text-xl tracking-tight text-primary"> <Link href="/" className="text-primary text-xl font-bold tracking-tight">
TitipIn TitipIn
</Link> </Link>
</div> </div>
<div className="hidden md:flex flex-1 items-center justify-between text-sm font-medium"> <div className="hidden flex-1 items-center justify-between text-sm font-medium md:flex">
<div className="flex gap-6"> <div className="flex gap-6">
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={cn( className={cn(
"transition-colors hover:text-foreground/80", 'hover:text-foreground/80 transition-colors',
pathname === item.href ? "text-foreground" : "text-foreground/60" pathname === item.href ? 'text-foreground' : 'text-foreground/60'
)} )}
> >
{item.name} {item.name}
@@ -43,21 +43,21 @@ export function Navbar() {
</div> </div>
</div> </div>
{/* Mobile Navigation */} {/* Mobile Navigation */}
<div className="flex flex-1 items-center justify-end md:hidden overflow-hidden"> <div className="flex flex-1 items-center justify-end overflow-hidden md:hidden">
<div className="flex gap-4 overflow-x-auto text-sm font-medium pb-1 no-scrollbar w-full"> <div className="no-scrollbar flex w-full gap-4 overflow-x-auto pb-1 text-sm font-medium">
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={cn( className={cn(
"whitespace-nowrap transition-colors", 'whitespace-nowrap transition-colors',
pathname === item.href ? "text-foreground" : "text-foreground/60" pathname === item.href ? 'text-foreground' : 'text-foreground/60'
)} )}
> >
{item.name} {item.name}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
+63 -40
View File
@@ -3,12 +3,27 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Button, buttonVariants } from '@/components/ui/button' import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { submitOrder, getUserSubmission, getSessionUser } from '@/app/actions' import { submitOrder, getUserSubmission, getSessionUser } from '@/app/actions'
import { PlusCircle, MinusCircle, User, CheckCircle2, ShoppingBag, Sparkles, Users, Eye } from 'lucide-react' import {
PlusCircle,
MinusCircle,
User,
CheckCircle2,
ShoppingBag,
Sparkles,
Users,
Eye,
} from 'lucide-react'
import { OrderFormModal } from '@/components/OrderFormModal' import { OrderFormModal } from '@/components/OrderFormModal'
import { ShareButton } from '@/components/ShareButton' import { ShareButton } from '@/components/ShareButton'
import Link from 'next/link' import Link from 'next/link'
@@ -18,51 +33,56 @@ import { id as idLocale } from 'date-fns/locale'
export function OrderCard({ order }: { order: any }) { export function OrderCard({ order }: { order: any }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const activeItems = order.available_items.filter((i: any) => !i.is_sold_out) const activeItems = order.available_items.filter((i: any) => !i.is_sold_out)
return ( return (
<div className="flex flex-col md:flex-row h-full md:h-auto rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm hover:shadow-xl hover:border-[#1B2CC1]/40 dark:hover:border-blue-500/40 transition-all duration-300 group overflow-hidden items-start md:items-stretch"> <div className="group flex h-full flex-col items-start overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-sm transition-all duration-300 hover:border-[#1B2CC1]/40 hover:shadow-xl md:h-auto md:flex-row md:items-stretch dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-500/40">
{/* Left: Info */} {/* Left: Info */}
<div className="flex-[1.2] p-5 md:border-r border-slate-100 dark:border-slate-800/80 flex flex-col justify-center min-w-0 w-full"> <div className="flex w-full min-w-0 flex-[1.2] flex-col justify-center border-slate-100 p-5 md:border-r dark:border-slate-800/80">
<div className="flex justify-between items-start gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<h3 className="text-lg font-bold text-slate-900 dark:text-white line-clamp-2 leading-snug group-hover:text-[#1B2CC1] dark:group-hover:text-blue-400 transition-colors"> <h3 className="line-clamp-2 text-lg leading-snug font-bold text-slate-900 transition-colors group-hover:text-[#1B2CC1] dark:text-white dark:group-hover:text-blue-400">
{order.title} {order.title}
</h3> </h3>
{order.description && ( {order.description && (
<p className="text-xs text-slate-500 mt-1 line-clamp-2 font-medium"> <p className="mt-1 line-clamp-2 text-xs font-medium text-slate-500">
{order.description} {order.description}
</p> </p>
)} )}
</div> </div>
<div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 shrink-0"> <div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
<span className="text-[10px] sm:text-[11px] text-slate-500 font-semibold bg-slate-100/80 dark:bg-slate-800 px-2.5 py-1 rounded-full whitespace-nowrap"> <span className="rounded-full bg-slate-100/80 px-2.5 py-1 text-[10px] font-semibold whitespace-nowrap text-slate-500 sm:text-[11px] dark:bg-slate-800">
{format(new Date(order.date), 'EEEE, dd MMM yyyy', { locale: idLocale })} {format(new Date(order.date), 'EEEE, dd MMM yyyy', { locale: idLocale })}
</span> </span>
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] sm:text-[11px] font-black tracking-wide bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400 border border-emerald-200/60 dark:border-emerald-900/50"> <span className="inline-flex items-center rounded-full border border-emerald-200/60 bg-emerald-50 px-2.5 py-1 text-[10px] font-black tracking-wide text-emerald-700 sm:text-[11px] dark:border-emerald-900/50 dark:bg-emerald-950/50 dark:text-emerald-400">
â—Ź OPEN â—Ź OPEN
</span> </span>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3 mt-3 pt-3 border-t border-slate-100 dark:border-slate-800/60"> <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-2"> <div className="flex items-center gap-2">
{order.creator.photo ? ( {order.creator.photo ? (
<img src={order.creator.photo} alt={order.creator.name} className="w-5 h-5 rounded-full object-cover ring-2 ring-slate-100 dark:ring-slate-800" /> <img
src={order.creator.photo}
alt={order.creator.name}
className="h-5 w-5 rounded-full object-cover ring-2 ring-slate-100 dark:ring-slate-800"
/>
) : ( ) : (
<div className="w-5 h-5 rounded-full bg-[#1B2CC1]/10 dark:bg-blue-900/40 text-[#1B2CC1] dark:text-blue-300 flex items-center justify-center font-bold text-[10px]"> <div className="flex h-5 w-5 items-center justify-center rounded-full bg-[#1B2CC1]/10 text-[10px] font-bold text-[#1B2CC1] dark:bg-blue-900/40 dark:text-blue-300">
{order.creator.name.charAt(0).toUpperCase()} {order.creator.name.charAt(0).toUpperCase()}
</div> </div>
)} )}
<span className="text-xs font-bold text-slate-700 dark:text-slate-300">{order.creator.name}</span> <span className="text-xs font-bold text-slate-700 dark:text-slate-300">
{order.creator.name}
</span>
</div> </div>
<div className="w-1 h-1 rounded-full bg-slate-300 dark:bg-slate-600 hidden sm:block"></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-1.5"> <div className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 text-[#1B2CC1]" /> <Users className="h-3.5 w-3.5 text-[#1B2CC1]" />
<span className="text-xs font-bold text-slate-600 dark:text-slate-400"> <span className="text-xs font-bold text-slate-600 dark:text-slate-400">
{order.submissions.length} Orang Menitip {order.submissions.length} Orang Menitip
</span> </span>
@@ -71,31 +91,34 @@ export function OrderCard({ order }: { order: any }) {
</div> </div>
{/* Middle: Items List */} {/* Middle: Items List */}
<div className="w-full md:w-64 p-5 md:border-r border-slate-100 dark:border-slate-800/80 flex flex-col justify-center border-t md:border-t-0"> <div className="flex w-full flex-col justify-center border-t border-slate-100 p-5 md:w-64 md:border-t-0 md:border-r dark:border-slate-800/80">
<div className="flex items-center justify-between mb-2"> <div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 block"> <span className="block text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Item Tersedia ({activeItems.length}) Item Tersedia ({activeItems.length})
</span> </span>
{order.allow_custom && ( {order.allow_custom && (
<div className="inline-flex items-center gap-1 text-[9px] font-semibold text-[#1B2CC1] bg-blue-50 dark:bg-blue-950/40 px-1.5 py-0.5 rounded-md"> <div className="inline-flex items-center gap-1 rounded-md bg-blue-50 px-1.5 py-0.5 text-[9px] font-semibold text-[#1B2CC1] dark:bg-blue-950/40">
<Sparkles className="w-3 h-3" /> Kustom <Sparkles className="h-3 w-3" /> Kustom
</div> </div>
)} )}
</div> </div>
<div className="bg-slate-50 dark:bg-slate-800/40 p-3 rounded-xl border border-slate-200/70 dark:border-slate-800 max-h-24 overflow-y-auto scrollbar-thin"> <div className="max-h-24 scrollbar-thin overflow-y-auto rounded-xl border border-slate-200/70 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-800/40">
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{activeItems.map((item: any) => ( {activeItems.map((item: any) => (
<li key={item.id} className="text-[11px] flex items-start gap-2 text-slate-700 dark:text-slate-300 font-medium"> <li
<div className="w-3.5 h-3.5 rounded-full bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center shrink-0 mt-0.5"> key={item.id}
<CheckCircle2 className="w-2.5 h-2.5" /> className="flex items-start gap-2 text-[11px] font-medium text-slate-700 dark:text-slate-300"
>
<div className="mt-0.5 flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-[#1B2CC1]/10 text-[#1B2CC1]">
<CheckCircle2 className="h-2.5 w-2.5" />
</div> </div>
<span className="truncate leading-snug">{item.name}</span> <span className="truncate leading-snug">{item.name}</span>
</li> </li>
))} ))}
{activeItems.length === 0 && ( {activeItems.length === 0 && (
<li className="text-[10px] text-slate-400 italic"> <li className="text-[10px] text-slate-400 italic">
{order.allow_custom ? "Hanya menerima kustom." : "Semua item habis."} {order.allow_custom ? 'Hanya menerima kustom.' : 'Semua item habis.'}
</li> </li>
)} )}
</ul> </ul>
@@ -103,28 +126,28 @@ export function OrderCard({ order }: { order: any }) {
</div> </div>
{/* Right: Actions */} {/* Right: Actions */}
<div className="w-full md:w-56 p-5 bg-slate-50/60 dark:bg-slate-800/40 flex flex-col items-center justify-center gap-2.5 shrink-0"> <div className="flex w-full shrink-0 flex-col items-center justify-center gap-2.5 bg-slate-50/60 p-5 md:w-56 dark:bg-slate-800/40">
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="w-full flex items-center justify-center h-10 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold text-xs shadow-md shadow-[#1B2CC1]/20 gap-1.5 transition-all cursor-pointer px-2"> <DialogTrigger className="flex h-10 w-full cursor-pointer items-center justify-center gap-1.5 rounded-xl bg-[#1B2CC1] px-2 text-xs font-bold text-white shadow-md shadow-[#1B2CC1]/20 transition-all hover:bg-[#15229E]">
<ShoppingBag className="w-4 h-4 shrink-0" /> <ShoppingBag className="h-4 w-4 shrink-0" />
<span className="truncate">Titip Sekarang</span> <span className="truncate">Titip Sekarang</span>
</DialogTrigger> </DialogTrigger>
{open && <OrderFormModal order={order} onSuccess={() => setOpen(false)} />} {open && <OrderFormModal order={order} onSuccess={() => setOpen(false)} />}
</Dialog> </Dialog>
<div className="flex w-full gap-2"> <div className="flex w-full gap-2">
<Link <Link
href={`/order/${order.id}`} href={`/order/${order.id}`}
className="flex-1 flex items-center justify-center h-9 rounded-xl bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300 font-bold text-xs shadow-sm gap-1.5 transition-all cursor-pointer px-1 border border-slate-200 dark:border-slate-700" className="flex h-9 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-white px-1 text-xs font-bold text-slate-700 shadow-sm transition-all hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
> >
<Eye className="w-3.5 h-3.5 shrink-0" /> <Eye className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Detail</span> <span className="truncate">Detail</span>
</Link> </Link>
<ShareButton <ShareButton
orderId={order.id} orderId={order.id}
orderTitle={order.title} orderTitle={order.title}
className="flex-1 h-9 rounded-xl font-bold text-xs gap-1.5 shadow-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 transition-all text-slate-700 dark:text-slate-300 px-1" className="h-9 flex-1 gap-1.5 rounded-xl border border-slate-200 bg-white px-1 text-xs font-bold text-slate-700 shadow-sm transition-all hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
showText={true} showText={true}
/> />
</div> </div>
+205 -115
View File
@@ -10,11 +10,15 @@ import { Input } from '@/components/ui/input'
import { submitOrder, getUserSubmission, getSessionUser, getOrderDetail } from '@/app/actions' import { submitOrder, getUserSubmission, getSessionUser, getOrderDetail } from '@/app/actions'
import { PlusCircle, MinusCircle, CheckCircle2 } from 'lucide-react' import { PlusCircle, MinusCircle, CheckCircle2 } from 'lucide-react'
export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: () => void }) { export function OrderFormModal({ order, onSuccess }: { order: any; onSuccess: () => void }) {
const [liveOrder, setLiveOrder] = useState<any>(order) const [liveOrder, setLiveOrder] = useState<any>(order)
const [userId, setUserId] = useState<string>('') const [userId, setUserId] = useState<string>('')
const [items, setItems] = useState<Record<string, { selected: boolean, qty: number }>>({}) const [items, setItems] = useState<
const [customItems, setCustomItems] = useState<Array<{ id: string, name: string, qty: number }>>([]) Record<string, { selected: boolean; qty: number; note?: string }>
>({})
const [customItems, setCustomItems] = useState<
Array<{ id: string; name: string; qty: number; note?: string }>
>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -22,7 +26,7 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
const init = async () => { const init = async () => {
const freshOrder = await getOrderDetail(order.id) const freshOrder = await getOrderDetail(order.id)
if (freshOrder) setLiveOrder(freshOrder) if (freshOrder) setLiveOrder(freshOrder)
const user = await getSessionUser() const user = await getSessionUser()
if (user?.id) { if (user?.id) {
setUserId(user.id) setUserId(user.id)
@@ -37,15 +41,20 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
if (sub) { if (sub) {
const freshItems: any = {} const freshItems: any = {}
const newCustoms: any[] = [] const newCustoms: any[] = []
sub.items.forEach((item: any) => { sub.items.forEach((item: any) => {
if (!item.is_custom) { if (!item.is_custom) {
const stdItem = currentOrder.available_items.find((ai: any) => ai.name === item.name) const stdItem = currentOrder.available_items.find((ai: any) => ai.name === item.name)
if (stdItem) { if (stdItem) {
freshItems[stdItem.id] = { selected: true, qty: item.qty } freshItems[stdItem.id] = { selected: true, qty: item.qty, note: item.note || '' }
} }
} else { } else {
newCustoms.push({ id: Math.random().toString(), name: item.name, qty: item.qty }) newCustoms.push({
id: Math.random().toString(36).substr(2, 9),
name: item.name,
qty: item.qty,
note: item.note || '',
})
} }
}) })
setItems(freshItems) setItems(freshItems)
@@ -53,56 +62,92 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
} }
} }
const handleStandardItemToggle = (itemId: string, checked: boolean) => { const handleStandardItemToggle = (id: string, checked: boolean) => {
setItems(prev => ({ setItems((prev) => ({
...prev, ...prev,
[itemId]: { selected: checked, qty: checked ? 1 : 0 } [id]: { selected: checked, qty: checked ? 1 : 0, note: prev[id]?.note || '' },
})) }))
} }
const handleStandardItemQty = (itemId: string, qty: number) => { const handleStandardItemQty = (id: string, qty: number) => {
if (qty < 1) { if (qty < 1) {
handleStandardItemToggle(itemId, false) handleStandardItemToggle(id, false)
return return
} }
setItems(prev => ({ setItems((prev) => ({
...prev, ...prev,
[itemId]: { selected: true, qty } [id]: { ...prev[id], qty },
}))
}
const handleStandardItemNote = (id: string, note: string) => {
setItems((prev) => ({
...prev,
[id]: { ...prev[id], note },
})) }))
} }
const addCustomItem = () => { const addCustomItem = () => {
setCustomItems([...customItems, { id: Math.random().toString(), name: '', qty: 1 }]) setCustomItems((prev) => [
...prev,
{ id: Math.random().toString(36).substr(2, 9), name: '', qty: 1, note: '' },
])
} }
const updateCustomItem = (id: string, field: 'name' | 'qty', value: any) => { const handleCustomItemChange = (
setCustomItems(customItems.map(c => c.id === id ? { ...c, [field]: value } : c)) id: string,
field: 'name' | 'qty' | 'note',
value: string | number
) => {
setCustomItems((prev) =>
prev.map((item) => {
if (item.id === id) {
return { ...item, [field]: value }
}
return item
})
)
}
const handleCustomItemQty = (id: string, qty: number) => {
if (qty < 1) return
handleCustomItemChange(id, 'qty', qty)
} }
const removeCustomItem = (id: string) => { const removeCustomItem = (id: string) => {
setCustomItems(customItems.filter(c => c.id !== id)) setCustomItems(customItems.filter((c) => c.id !== id))
} }
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
setLoading(true) setLoading(true)
const payloadItems: any[] = [] const payloadItems: any[] = []
liveOrder.available_items.forEach((ai: any) => { liveOrder.available_items.forEach((ai: any) => {
const state = items[ai.id] const selectedItem = items[ai.id]
if (state?.selected && state.qty > 0) { if (selectedItem?.selected && selectedItem.qty > 0) {
payloadItems.push({ name: ai.name, qty: state.qty, is_custom: false }) payloadItems.push({
name: ai.name,
qty: selectedItem.qty,
is_custom: false,
note: selectedItem.note?.trim() || undefined,
})
} }
}) })
customItems.forEach(ci => { customItems.forEach((ci) => {
if (ci.name.trim() && ci.qty > 0) { if (ci.name.trim() && ci.qty > 0) {
payloadItems.push({ name: ci.name.trim(), qty: ci.qty, is_custom: true }) payloadItems.push({
name: ci.name.trim(),
qty: ci.qty,
is_custom: true,
note: ci.note?.trim() || undefined,
})
} }
}) })
if (payloadItems.length === 0) { if (payloadItems.length === 0) {
setError('Harap pilih minimal 1 item atau tambahkan item lainnya.') setError('Harap pilih minimal 1 item atau tambahkan item lainnya.')
setLoading(false) setLoading(false)
@@ -112,7 +157,7 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
const res = await submitOrder({ const res = await submitOrder({
order_id: liveOrder.id, order_id: liveOrder.id,
user_id: userId, user_id: userId,
items: payloadItems items: payloadItems,
}) })
if (res.success) { if (res.success) {
@@ -124,21 +169,26 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
} }
return ( return (
<DialogContent className="sm:max-w-[520px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl max-h-[90vh] flex flex-col"> <DialogContent className="flex max-h-[90vh] flex-col overflow-hidden rounded-3xl border-slate-200/90 p-0 shadow-2xl sm:max-w-[520px] dark:border-slate-800">
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white"> <div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white">
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-200">Form Titipan</span> <span className="text-[11px] font-bold tracking-wider text-blue-200 uppercase">
<DialogTitle className="text-2xl font-black tracking-tight text-white mt-1"> Form Titipan
</span>
<DialogTitle className="mt-1 text-2xl font-black tracking-tight text-white">
{liveOrder.title} {liveOrder.title}
</DialogTitle> </DialogTitle>
<p className="text-xs text-blue-100 mt-1"> <p className="mt-1 text-xs text-blue-100">
Pilih menu yang tersedia di bawah atau tambahkan item khusus jika diizinkan. Pilih menu yang tersedia di bawah atau tambahkan item khusus jika diizinkan.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-6 flex-1 bg-white dark:bg-slate-900"> <form
onSubmit={handleSubmit}
className="flex-1 space-y-6 overflow-y-auto bg-white p-6 dark:bg-slate-900"
>
{/* Standard Items */} {/* Standard Items */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-400"> <Label className="text-xs font-bold tracking-wider text-slate-400 uppercase">
Daftar Menu Tersedia Daftar Menu Tersedia
</Label> </Label>
<div className="space-y-2"> <div className="space-y-2">
@@ -146,60 +196,87 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
const isSelected = items[item.id]?.selected || false const isSelected = items[item.id]?.selected || false
const qty = items[item.id]?.qty || 0 const qty = items[item.id]?.qty || 0
return ( return (
<div <div
key={item.id} key={item.id}
className={cn( className={cn(
"flex items-center justify-between p-3.5 rounded-xl border transition-all", 'flex flex-col gap-2 rounded-xl border p-3.5 transition-all',
isSelected isSelected
? "border-[#1B2CC1] bg-[#1B2CC1]/5 dark:bg-blue-950/20 shadow-sm" ? 'border-[#1B2CC1]/40 bg-blue-50/20 shadow-sm dark:bg-blue-950/10'
: "border-slate-200/80 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50" : 'border-slate-200/90 bg-slate-50/70 dark:border-slate-800 dark:bg-slate-800/40'
)} )}
> >
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center justify-between">
<Checkbox <div className="flex min-w-0 flex-1 items-center gap-3">
id={`item-${item.id}`} <Checkbox
checked={isSelected && !item.is_sold_out} id={`item-${item.id}`}
disabled={item.is_sold_out} checked={isSelected && !item.is_sold_out}
onCheckedChange={(c) => !item.is_sold_out && handleStandardItemToggle(item.id, c as boolean)} disabled={item.is_sold_out || loading}
className="rounded-lg data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1] disabled:opacity-50" onCheckedChange={(c) =>
/> !item.is_sold_out && handleStandardItemToggle(item.id, c as boolean)
<Label htmlFor={`item-${item.id}`} className={cn("text-sm font-bold truncate flex items-center gap-2", item.is_sold_out ? "text-slate-400 dark:text-slate-500 cursor-not-allowed" : "text-slate-800 dark:text-slate-200 cursor-pointer")}> }
<span className={item.is_sold_out ? "line-through" : ""}>{item.name}</span> className="rounded-lg disabled:opacity-50 data-[state=checked]:border-[#1B2CC1] data-[state=checked]:bg-[#1B2CC1]"
{item.is_sold_out && ( />
<span className="text-[9px] bg-rose-100 dark:bg-rose-950/30 text-rose-600 dark:text-rose-400 px-1.5 py-0.5 rounded font-black uppercase tracking-wider border border-rose-200 dark:border-rose-900/50"> <Label
Sold Out htmlFor={`item-${item.id}`}
className={cn(
'flex items-center gap-2 truncate text-sm font-bold',
item.is_sold_out
? 'cursor-not-allowed text-slate-400 dark:text-slate-500'
: 'cursor-pointer text-slate-800 dark:text-slate-200'
)}
>
<span className={item.is_sold_out ? 'line-through' : ''}>{item.name}</span>
{item.is_sold_out && (
<span className="rounded border border-rose-200 bg-rose-100 px-1.5 py-0.5 text-[9px] font-black tracking-wider text-rose-600 uppercase dark:border-rose-900/50 dark:bg-rose-950/30 dark:text-rose-400">
Sold Out
</span>
)}
</Label>
</div>
{isSelected && (
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm dark:border-slate-700 dark:bg-slate-800">
<Button
type="button"
variant="ghost"
size="icon"
disabled={loading}
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty - 1)}
>
<MinusCircle className="h-4 w-4" />
</Button>
<span className="w-7 text-center text-xs font-extrabold text-[#1B2CC1] dark:text-blue-400">
{qty}
</span> </span>
)} <Button
</Label> type="button"
variant="ghost"
size="icon"
disabled={loading}
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty + 1)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</div>
)}
</div> </div>
{isSelected && ( {isSelected && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-0.5 shadow-sm"> <div className="mt-1 pl-7">
<Button <Input
type="button" placeholder="Catatan (opsional), misal: 1/2 porsi"
variant="ghost" className="h-8 border-slate-200/60 bg-slate-50/50 text-xs dark:border-slate-700/60 dark:bg-slate-900/50"
size="icon" value={items[item.id]?.note || ''}
className="h-7 w-7 text-slate-500 hover:text-slate-800" disabled={loading}
onClick={() => handleStandardItemQty(item.id, qty - 1)} onChange={(e) => handleStandardItemNote(item.id, e.target.value)}
> />
<MinusCircle className="h-4 w-4" />
</Button>
<span className="w-7 text-center text-xs font-extrabold text-[#1B2CC1] dark:text-blue-400">{qty}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty + 1)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</div> </div>
)} )}
</div> </div>
) )
})} })}
{liveOrder.available_items.length === 0 && ( {liveOrder.available_items.length === 0 && (
<p className="text-xs text-slate-400 text-center py-3 bg-slate-50 dark:bg-slate-800/40 rounded-xl border border-dashed border-slate-200 dark:border-slate-800"> <p className="rounded-xl border border-dashed border-slate-200 bg-slate-50 py-3 text-center text-xs text-slate-400 dark:border-slate-800 dark:bg-slate-800/40">
Tidak ada menu standar yang ditentukan. Tidak ada menu standar yang ditentukan.
</p> </p>
)} )}
@@ -210,44 +287,53 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
{liveOrder.allow_custom && ( {liveOrder.allow_custom && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-400"> <Label className="text-xs font-bold tracking-wider text-slate-400 uppercase">
Item Tambahan (Kustom) Item Tambahan (Kustom)
</Label> </Label>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={addCustomItem} disabled={loading}
className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/40 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5" onClick={addCustomItem}
className="h-8 gap-1.5 rounded-lg border-dashed border-[#1B2CC1]/40 text-xs font-bold text-[#1B2CC1] hover:bg-[#1B2CC1]/10"
> >
<PlusCircle className="h-3.5 w-3.5" /> Tambah Kustom <PlusCircle className="h-3.5 w-3.5" /> Tambah Kustom
</Button> </Button>
</div> </div>
{customItems.length > 0 ? ( {customItems.length > 0 ? (
<div className="space-y-2.5"> <div className="space-y-2.5">
{customItems.map((ci, idx) => ( {customItems.map((ci, idx) => (
<div key={ci.id} className="flex gap-2 items-center p-3 rounded-xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40"> <div
<span className="text-xs font-bold text-slate-400 w-4">{idx + 1}.</span> key={ci.id}
<Input className="flex items-center gap-2 rounded-xl border border-slate-200/90 bg-slate-50/70 p-3 dark:border-slate-800 dark:bg-slate-800/40"
placeholder="Nama Menu / Catatan Khusus" >
value={ci.name} <span className="w-4 text-xs font-bold text-slate-400">{idx + 1}.</span>
onChange={(e) => updateCustomItem(ci.id, 'name', e.target.value)} <Input
className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium" placeholder="Nama Menu Kustom"
value={ci.name}
disabled={loading}
onChange={(e) => handleCustomItemChange(ci.id, 'name', e.target.value)}
className="h-9 flex-1 rounded-lg bg-white text-xs font-medium dark:bg-slate-900"
/> />
<Input <Input
type="number" type="number"
min="1" min="1"
value={ci.qty} value={ci.qty}
onChange={(e) => updateCustomItem(ci.id, 'qty', parseInt(e.target.value) || 1)} disabled={loading}
className="w-16 h-9 rounded-lg bg-white dark:bg-slate-900 text-center font-bold text-xs" onChange={(e) =>
handleCustomItemChange(ci.id, 'qty', parseInt(e.target.value) || 1)
}
className="h-9 w-16 rounded-lg bg-white text-center text-xs font-bold dark:bg-slate-900"
/> />
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => removeCustomItem(ci.id)} disabled={loading}
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg" onClick={() => removeCustomItem(ci.id)}
className="h-8 w-8 shrink-0 rounded-lg text-red-500 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/30"
> >
<MinusCircle className="h-4 w-4" /> <MinusCircle className="h-4 w-4" />
</Button> </Button>
@@ -255,29 +341,33 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
))} ))}
</div> </div>
) : ( ) : (
<div <div
onClick={addCustomItem} onClick={addCustomItem}
className="p-4 border-2 border-dashed border-slate-200 dark:border-slate-800 hover:border-[#1B2CC1]/50 rounded-2xl text-center bg-slate-50/50 dark:bg-slate-800/30 cursor-pointer transition-all group" className="group cursor-pointer rounded-2xl border-2 border-dashed border-slate-200 bg-slate-50/50 p-4 text-center transition-all hover:border-[#1B2CC1]/50 dark:border-slate-800 dark:bg-slate-800/30"
> >
<PlusCircle className="w-5 h-5 text-slate-400 group-hover:text-[#1B2CC1] mx-auto mb-1 transition-colors" /> <PlusCircle className="mx-auto mb-1 h-5 w-5 text-slate-400 transition-colors group-hover:text-[#1B2CC1]" />
<p className="text-xs font-bold text-slate-600 dark:text-slate-300">Klik untuk Tambah Item Custom</p> <p className="text-xs font-bold text-slate-600 dark:text-slate-300">
<p className="text-[11px] text-slate-400 mt-0.5">Ingin titip menu lain? Masukkan nama dan kuantitasnya di sini.</p> Klik untuk Tambah Item Custom
</p>
<p className="mt-0.5 text-[11px] text-slate-400">
Ingin titip menu lain? Masukkan nama dan kuantitasnya di sini.
</p>
</div> </div>
)} )}
</div> </div>
)} )}
{error && ( {error && (
<div className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold"> <div className="rounded-xl border border-red-200 bg-red-50 p-3 text-xs font-semibold text-red-600 dark:border-red-900 dark:bg-red-950/30 dark:text-red-400">
{error} {error}
</div> </div>
)} )}
<div className="flex justify-end gap-3 pt-2 border-t border-slate-100 dark:border-slate-800"> <div className="flex justify-end gap-3 border-t border-slate-100 pt-2 dark:border-slate-800">
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/25" className="h-11 w-full rounded-xl bg-[#1B2CC1] font-bold text-white shadow-md shadow-[#1B2CC1]/25 hover:bg-[#15229E]"
> >
{loading ? 'Menyimpan Titipan...' : 'Kirim Titip Pesanan'} {loading ? 'Menyimpan Titipan...' : 'Kirim Titip Pesanan'}
</Button> </Button>
+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>
)
}
+237 -108
View File
@@ -2,25 +2,61 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { ChevronDown, ChevronUp, Users, CheckCircle2, Package, InboxIcon, ChevronLeft, ChevronRight, Search } from 'lucide-react' import {
ChevronDown,
ChevronUp,
Users,
CheckCircle2,
Package,
InboxIcon,
ChevronLeft,
ChevronRight,
Search,
} from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog' import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { processBulkPayment } from '@/app/actions' import { processBulkPayment } from '@/app/actions'
import { format } from 'date-fns' import { format } from 'date-fns'
const ITEMS_PER_PAGE = 5 const ITEMS_PER_PAGE = 5
const formatRupiah = (value: number) => const formatRupiah = (value: number) =>
new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(value) new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(value)
export default function ReportByPersonGrid({ data, balancesData = [], creatorId, onUpdate }: { data: any[]; balancesData?: any[]; creatorId: string; onUpdate: () => void }) { export default function ReportByPersonGrid({
data,
balancesData = [],
creatorId,
onUpdate,
}: {
data: any[]
balancesData?: any[]
creatorId: string
onUpdate: () => void
}) {
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({}) const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({})
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [saving, setSaving] = 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 [target, setTarget] = useState<{
ids: string[]
label: string
amount: number
userId: string
}>({ ids: [], label: '', amount: 0, userId: '' })
const [cashInput, setCashInput] = useState<string>('') const [cashInput, setCashInput] = useState<string>('')
// Reset page + search when data changes (e.g. filter changed) // Reset page + search when data changes (e.g. filter changed)
@@ -35,48 +71,59 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
setCurrentPage(1) setCurrentPage(1)
}, [searchQuery]) }, [searchQuery])
const toggleRow = (id: string) => const toggleRow = (id: string) => setExpandedRows((prev) => ({ ...prev, [id]: !prev[id] }))
setExpandedRows(prev => ({ ...prev, [id]: !prev[id] }))
// Group by user // Group by user
const usersMap = new Map<string, any>() const usersMap = new Map<string, any>()
data.forEach(order => { data.forEach((order) => {
order.submissions.forEach((sub: any) => { order.submissions.forEach((sub: any) => {
if (!usersMap.has(sub.user.id)) { if (!usersMap.has(sub.user.id)) {
usersMap.set(sub.user.id, { usersMap.set(sub.user.id, {
user: sub.user, user: sub.user,
totalPiutang: 0, totalPiutang: 0,
totalPaid: 0, totalPaid: 0,
submissions: [] submissions: [],
}) })
} }
const obj = usersMap.get(sub.user.id) const obj = usersMap.get(sub.user.id)
const amount = Number(sub.bill) || 0 const bill = Number(sub.bill) || 0
if (sub.payment_status !== 'LUNAS') obj.totalPiutang += amount const paid =
else obj.totalPaid += amount (sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? bill : 0)) + (sub.saldo_used || 0)
obj.submissions.push({ ...sub, orderTitle: order.title, orderDate: order.date }) const unpaid = Math.max(0, bill - paid)
obj.totalPiutang += unpaid
obj.totalPaid += paid
obj.submissions.push({ ...sub, orderTitle: order.title, orderDate: order.date, paid, unpaid })
}) })
}) })
const userList = Array.from(usersMap.values()) const userList = Array.from(usersMap.values())
.map(obj => ({ .map((obj) => ({
...obj, ...obj,
// Sort each person's PO list by order date, newest first // Sort each person's PO list by order date, newest first
submissions: [...obj.submissions].sort( submissions: [...obj.submissions].sort(
(a: any, b: any) => new Date(b.orderDate).getTime() - new Date(a.orderDate).getTime() (a: any, b: any) => new Date(b.orderDate).getTime() - new Date(a.orderDate).getTime()
) ),
})) }))
.sort((a, b) => b.totalPiutang - a.totalPiutang) .sort((a, b) => b.totalPiutang - a.totalPiutang)
const q = searchQuery.toLowerCase().trim() const q = searchQuery.toLowerCase().trim()
const filteredUserList = q const filteredUserList = q
? userList.filter(u => u.user.name?.toLowerCase().includes(q)) ? userList.filter((u) => u.user.name?.toLowerCase().includes(q))
: userList : userList
const totalPages = Math.ceil(filteredUserList.length / ITEMS_PER_PAGE) const totalPages = Math.ceil(filteredUserList.length / ITEMS_PER_PAGE)
const paginatedList = filteredUserList.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE) const paginatedList = filteredUserList.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
)
const openModal = (e: React.MouseEvent, ids: string[], label: string, amount: number, userId: string) => { const openModal = (
e: React.MouseEvent,
ids: string[],
label: string,
amount: number,
userId: string
) => {
e.stopPropagation() e.stopPropagation()
setTarget({ ids, label, amount, userId }) setTarget({ ids, label, amount, userId })
setCashInput('') setCashInput('')
@@ -92,7 +139,7 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
cash_amount: cash, cash_amount: cash,
use_balance: true, use_balance: true,
creator_id: creatorId, creator_id: creatorId,
user_id: target.userId user_id: target.userId,
}) })
setSaving(false) setSaving(false)
if (res.success) { if (res.success) {
@@ -101,81 +148,95 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
} }
} }
const targetUserBalanceObj = balancesData.find(b => b.user.id === target.userId) const targetUserBalanceObj = balancesData.find((b) => b.user.id === target.userId)
const targetUserBalance = targetUserBalanceObj ? targetUserBalanceObj.amount : 0 const targetUserBalance = targetUserBalanceObj ? targetUserBalanceObj.amount : 0
return ( return (
<> <>
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden"> <Card className="overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="bg-slate-50/50 dark:bg-slate-800/40 px-4 py-3 border-b border-slate-100 dark:border-slate-800 flex flex-col sm:flex-row sm:items-center gap-3"> <div className="flex flex-col gap-3 border-b border-slate-100 bg-slate-50/50 px-4 py-3 sm:flex-row sm:items-center dark:border-slate-800 dark:bg-slate-800/40">
<div className="flex items-center gap-2 flex-1"> <div className="flex flex-1 items-center gap-2">
<Users className="w-4 h-4 text-[#1B2CC1] shrink-0" /> <Users className="h-4 w-4 shrink-0 text-[#1B2CC1]" />
<h3 className="text-sm font-black text-slate-800 dark:text-slate-200">Detail Piutang Berdasarkan Orang</h3> <h3 className="text-sm font-black text-slate-800 dark:text-slate-200">
Detail Piutang Berdasarkan Orang
</h3>
</div> </div>
<div className="flex items-center gap-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg px-3 py-1.5 w-full sm:w-48"> <div className="flex w-full items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 sm:w-48 dark:border-slate-700 dark:bg-slate-900">
<Search className="w-3.5 h-3.5 text-slate-400 shrink-0" /> <Search className="h-3.5 w-3.5 shrink-0 text-slate-400" />
<input <input
type="text" type="text"
placeholder="Cari nama pemesan..." placeholder="Cari nama pemesan..."
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="text-xs font-medium bg-transparent border-none outline-none w-full text-slate-700 dark:text-slate-200 placeholder:text-slate-400" className="w-full border-none bg-transparent text-xs font-medium text-slate-700 outline-none placeholder:text-slate-400 dark:text-slate-200"
/> />
</div> </div>
</div> </div>
{userList.length === 0 ? ( {userList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-slate-400"> <div className="flex flex-col items-center justify-center gap-3 py-16 text-slate-400">
<InboxIcon className="w-10 h-10 opacity-40" /> <InboxIcon className="h-10 w-10 opacity-40" />
<p className="text-sm font-semibold">Tidak ada data pemesan di periode ini.</p> <p className="text-sm font-semibold">Tidak ada data pemesan di periode ini.</p>
</div> </div>
) : filteredUserList.length === 0 ? ( ) : filteredUserList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-slate-400"> <div className="flex flex-col items-center justify-center gap-3 py-16 text-slate-400">
<Search className="w-10 h-10 opacity-40" /> <Search className="h-10 w-10 opacity-40" />
<p className="text-sm font-semibold">Tidak ada hasil untuk &quot;{searchQuery}&quot;.</p> <p className="text-sm font-semibold">
Tidak ada hasil untuk &quot;{searchQuery}&quot;.
</p>
</div> </div>
) : ( ) : (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-left text-sm">
<thead className="text-xs text-slate-500 uppercase bg-slate-50/30 dark:bg-slate-900/50"> <thead className="bg-slate-50/30 text-xs text-slate-500 uppercase dark:bg-slate-900/50">
<tr> <tr>
<th className="px-6 py-4 font-bold w-8"></th> <th className="w-8 px-6 py-4 font-bold"></th>
<th className="px-6 py-4 font-bold">Nama Pemesan</th> <th className="px-6 py-4 font-bold">Nama Pemesan</th>
<th className="px-6 py-4 font-bold text-center">Total PO</th> <th className="px-6 py-4 text-center font-bold">Total PO</th>
<th className="px-6 py-4 font-bold text-right">Total Piutang</th> <th className="px-6 py-4 text-right font-bold">Total Piutang</th>
<th className="px-6 py-4 font-bold text-center">Aksi</th> {/* <th className="px-6 py-4 font-bold text-center">Aksi</th> */}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800"> <tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{paginatedList.map((userObj) => { {paginatedList.map((userObj) => {
const isExpanded = !!expandedRows[userObj.user.id] const isExpanded = !!expandedRows[userObj.user.id]
const unpaid = userObj.submissions.filter((s: any) => s.payment_status !== 'LUNAS') const unpaid = userObj.submissions.filter((s: any) => s.unpaid > 0)
return ( return (
<React.Fragment key={userObj.user.id}> <React.Fragment key={userObj.user.id}>
<tr <tr
onClick={() => toggleRow(userObj.user.id)} onClick={() => toggleRow(userObj.user.id)}
className={cn( className={cn(
'hover:bg-slate-50/50 dark:hover:bg-slate-800/40 cursor-pointer transition-colors group', 'group cursor-pointer transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/40',
isExpanded && 'bg-slate-50/30 dark:bg-slate-800/20' isExpanded && 'bg-slate-50/30 dark:bg-slate-800/20'
)} )}
> >
<td className="px-6 py-4"> <td className="px-6 py-4">
<button className="text-slate-400 group-hover:text-[#1B2CC1] transition-colors p-1 rounded-full hover:bg-blue-50 dark:hover:bg-blue-900/30"> <button className="rounded-full p-1 text-slate-400 transition-colors group-hover:text-[#1B2CC1] hover:bg-blue-50 dark:hover:bg-blue-900/30">
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} {isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button> </button>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{userObj.user.photo ? ( {userObj.user.photo ? (
<img src={userObj.user.photo} alt={userObj.user.name} className="w-8 h-8 rounded-full object-cover ring-2 ring-slate-100 dark:ring-slate-800" /> <img
src={userObj.user.photo}
alt={userObj.user.name}
className="h-8 w-8 rounded-full object-cover ring-2 ring-slate-100 dark:ring-slate-800"
/>
) : ( ) : (
<div className="w-8 h-8 rounded-full bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center font-bold text-xs ring-2 ring-slate-100 dark:ring-slate-800"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#1B2CC1]/10 text-xs font-bold text-[#1B2CC1] ring-2 ring-slate-100 dark:ring-slate-800">
{userObj.user.name.charAt(0).toUpperCase()} {userObj.user.name.charAt(0).toUpperCase()}
</div> </div>
)} )}
<p className="font-bold text-slate-800 dark:text-slate-200">{userObj.user.name}</p> <p className="font-bold text-slate-800 dark:text-slate-200">
{userObj.user.name}
</p>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-center font-semibold text-slate-600 dark:text-slate-400"> <td className="px-6 py-4 text-center font-semibold text-slate-600 dark:text-slate-400">
@@ -183,15 +244,21 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
{userObj.totalPiutang > 0 ? ( {userObj.totalPiutang > 0 ? (
<p className="font-black text-rose-600 dark:text-rose-400">{formatRupiah(userObj.totalPiutang)}</p> <p className="font-black text-rose-600 dark:text-rose-400">
{formatRupiah(userObj.totalPiutang)}
</p>
) : ( ) : (
<p className="font-bold text-emerald-600 dark:text-emerald-400">Lunas Semua</p> <p className="font-bold text-emerald-600 dark:text-emerald-400">
Lunas Semua
</p>
)} )}
{userObj.totalPaid > 0 && ( {userObj.totalPaid > 0 && (
<p className="text-[10px] text-slate-500 font-semibold mt-0.5">Lunas: {formatRupiah(userObj.totalPaid)}</p> <p className="mt-0.5 text-[10px] font-semibold text-slate-500">
Lunas: {formatRupiah(userObj.totalPaid)}
</p>
)} )}
</td> </td>
<td className="px-6 py-4 text-center"> {/* <td className="px-6 py-4 text-center">
{unpaid.length > 0 ? ( {unpaid.length > 0 ? (
<Button <Button
onClick={(e) => openModal(e, unpaid.map((s: any) => s.id), `Semua tagihan ${userObj.user.name}`, userObj.totalPiutang, userObj.user.id)} onClick={(e) => openModal(e, unpaid.map((s: any) => s.id), `Semua tagihan ${userObj.user.name}`, userObj.totalPiutang, userObj.user.id)}
@@ -201,50 +268,70 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
<CheckCircle2 className="w-3.5 h-3.5" /> Lunasi Semua <CheckCircle2 className="w-3.5 h-3.5" /> Lunasi Semua
</Button> </Button>
):( <span className="text-[10px] font-bold text-slate-400">Selesai</span>)} ):( <span className="text-[10px] font-bold text-slate-400">Selesai</span>)}
</td> </td> */}
</tr> </tr>
{isExpanded && ( {isExpanded && (
<tr className="bg-slate-50 dark:bg-slate-900/60"> <tr className="bg-slate-50 dark:bg-slate-900/60">
<td colSpan={5} className="px-6 py-5 border-l-4 border-l-[#1B2CC1]"> <td colSpan={5} className="border-l-4 border-l-[#1B2CC1] px-6 py-5">
<div className="pl-8 space-y-3"> <div className="space-y-3 pl-8">
<h4 className="text-[11px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1.5"> <h4 className="flex items-center gap-1.5 text-[11px] font-bold tracking-wider text-slate-500 uppercase">
<Package className="w-3.5 h-3.5" /> Rincian Hutang per PO <Package className="h-3.5 w-3.5" /> Rincian Hutang per PO
</h4> </h4>
<div className="overflow-hidden border border-slate-200/80 dark:border-slate-700 rounded-xl bg-white dark:bg-slate-800"> <div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white dark:border-slate-700 dark:bg-slate-800">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="bg-slate-100/50 dark:bg-slate-700/30 text-slate-500 uppercase font-semibold"> <thead className="bg-slate-100/50 font-semibold text-slate-500 uppercase dark:bg-slate-700/30">
<tr> <tr>
<th className="px-4 py-2.5 text-left">Judul PO</th> <th className="px-4 py-2.5 text-left">Judul PO</th>
<th className="px-4 py-2.5 text-left">Tgl Order</th> <th className="px-4 py-2.5 text-left">Tgl Order</th>
<th className="px-4 py-2.5 text-left">Status</th> <th className="px-4 py-2.5 text-left">Status</th>
<th className="px-4 py-2.5 text-right">Tagihan</th> <th className="px-4 py-2.5 text-right">Tagihan</th>
<th className="px-4 py-2.5 text-center">Aksi</th> {/* <th className="px-4 py-2.5 text-center">Aksi</th> */}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700/50"> <tbody className="divide-y divide-slate-100 dark:divide-slate-700/50">
{userObj.submissions.map((sub: any) => { {userObj.submissions.map((sub: any) => {
const isUnpaid = sub.payment_status !== 'LUNAS' const isUnpaid = sub.unpaid > 0
return ( return (
<tr key={sub.id}> <tr key={sub.id}>
<td className="px-4 py-2.5 font-bold text-slate-800 dark:text-slate-200">{sub.orderTitle}</td> <td className="px-4 py-2.5 font-bold text-slate-800 dark:text-slate-200">
<td className="px-4 py-2.5 text-slate-500 font-medium"> {sub.orderTitle}
</td>
<td className="px-4 py-2.5 font-medium text-slate-500">
{format(new Date(sub.orderDate), 'dd MMM yyyy')} {format(new Date(sub.orderDate), 'dd MMM yyyy')}
</td> </td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<span className={cn( <span
'text-[9px] font-bold px-1.5 py-0.5 rounded uppercase', className={cn(
!isUnpaid 'rounded px-1.5 py-0.5 text-[9px] font-bold uppercase',
? 'text-emerald-700 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-950/40' !isUnpaid
: 'text-rose-700 bg-rose-100 dark:text-rose-400 dark:bg-rose-950/40' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
)}> : 'bg-rose-100 text-rose-700 dark:bg-rose-950/40 dark:text-rose-400'
)}
>
{isUnpaid ? 'Belum' : 'Lunas'} {isUnpaid ? 'Belum' : 'Lunas'}
</span> </span>
</td> </td>
<td className={cn('px-4 py-2.5 text-right font-black', isUnpaid ? 'text-rose-600 dark:text-rose-400' : 'text-emerald-600 dark:text-emerald-400')}> <td
{sub.bill ? formatRupiah(sub.bill) : 'Rp0'} className={cn(
'flex flex-col px-4 py-2.5 text-right',
isUnpaid
? 'text-rose-600 dark:text-rose-400'
: 'text-emerald-600 dark:text-emerald-400'
)}
>
<span className="font-black">
{sub.bill ? formatRupiah(sub.bill) : 'Rp0'}
</span>
{sub.unpaid > 0 && sub.paid > 0 && (
<div className="mt-0.5 text-[10px] leading-tight font-medium text-slate-500">
Masuk: {formatRupiah(sub.paid)}
<br />
Sisa: {formatRupiah(sub.unpaid)}
</div>
)}
</td> </td>
<td className="px-4 py-2.5 text-center"> {/* <td className="px-4 py-2.5 text-center">
{isUnpaid ? ( {isUnpaid ? (
<Button <Button
onClick={(e) => openModal(e, [sub.id], `PO: ${sub.orderTitle}`, Number(sub.bill) || 0, sub.user.id)} onClick={(e) => openModal(e, [sub.id], `PO: ${sub.orderTitle}`, Number(sub.bill) || 0, sub.user.id)}
@@ -257,7 +344,7 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
) : ( ) : (
<span className="text-[10px] font-bold text-slate-400">Selesai</span> <span className="text-[10px] font-bold text-slate-400">Selesai</span>
)} )}
</td> </td> */}
</tr> </tr>
) )
})} })}
@@ -277,17 +364,29 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 bg-slate-50/50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800"> <div className="flex items-center justify-between border-t border-slate-100 bg-slate-50/50 px-6 py-4 dark:border-slate-800 dark:bg-slate-900/50">
<p className="text-xs font-medium text-slate-500"> <p className="text-xs font-medium text-slate-500">
Halaman <span className="font-bold text-slate-700 dark:text-slate-300">{currentPage}</span> dari{' '} Halaman{' '}
<span className="font-bold text-slate-700 dark:text-slate-300">
{currentPage}
</span>{' '}
dari{' '}
<span className="font-bold text-slate-700 dark:text-slate-300">{totalPages}</span> <span className="font-bold text-slate-700 dark:text-slate-300">{totalPages}</span>
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-700 disabled:opacity-40 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"> <button
<ChevronLeft className="w-3.5 h-3.5" /> onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="rounded-lg border border-slate-200 p-1.5 transition-colors hover:bg-slate-100 disabled:opacity-40 dark:border-slate-700 dark:hover:bg-slate-800"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button> </button>
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-700 disabled:opacity-40 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"> <button
<ChevronRight className="w-3.5 h-3.5" /> onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="rounded-lg border border-slate-200 p-1.5 transition-colors hover:bg-slate-100 disabled:opacity-40 dark:border-slate-700 dark:hover:bg-slate-800"
>
<ChevronRight className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
@@ -298,72 +397,93 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
{/* Confirmation Modal */} {/* Confirmation Modal */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}> <Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="sm:max-w-md rounded-2xl"> <DialogContent className="rounded-2xl sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl font-black text-slate-900 dark:text-white flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 text-xl font-black text-slate-900 dark:text-white">
<CheckCircle2 className="w-6 h-6 text-emerald-500" /> <CheckCircle2 className="h-6 w-6 text-emerald-500" />
Konfirmasi Pelunasan Konfirmasi Pelunasan
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-500 pt-2"> <DialogDescription className="pt-2 text-slate-500">
Tandai <strong className="text-slate-800 dark:text-slate-200">{target.label}</strong> sebagai <strong>LUNAS</strong>? Tandai <strong className="text-slate-800 dark:text-slate-200">{target.label}</strong>{' '}
sebagai <strong>LUNAS</strong>?
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="bg-slate-50 dark:bg-slate-900/50 p-4 rounded-xl border border-slate-100 dark:border-slate-800 my-2"> <div className="my-2 rounded-xl border border-slate-100 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/50">
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Total Tagihan</p> <p className="mb-1 text-xs font-bold tracking-wider text-slate-500 uppercase">
<p className="text-2xl font-black text-rose-600 dark:text-rose-400">{formatRupiah(target.amount)}</p> Total Tagihan
</p>
<div className="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700 flex flex-col gap-3"> <p className="text-2xl font-black text-rose-600 dark:text-rose-400">
{formatRupiah(target.amount)}
</p>
<div className="mt-4 flex flex-col gap-3 border-t border-slate-200 pt-4 dark:border-slate-700">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-500">Nominal Dibayar (Cash/Transfer)</label> <label className="text-xs font-bold text-slate-500">
Nominal Dibayar (Cash/Transfer)
</label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-bold text-slate-500">Rp</span> <span className="absolute top-1/2 left-3 -translate-y-1/2 text-sm font-bold text-slate-500">
<input Rp
type="text" </span>
<input
type="text"
value={cashInput} value={cashInput}
onChange={(e) => { onChange={(e) => {
const val = e.target.value.replace(/\D/g, '') const val = e.target.value.replace(/\D/g, '')
setCashInput(val ? new Intl.NumberFormat('id-ID').format(Number(val)) : '') setCashInput(val ? new Intl.NumberFormat('id-ID').format(Number(val)) : '')
}} }}
placeholder="0" placeholder="0"
className="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg h-10 pl-9 pr-3 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-[#1B2CC1]" className="h-10 w-full rounded-lg border border-slate-200 bg-white pr-3 pl-9 text-sm font-bold focus:ring-2 focus:ring-[#1B2CC1] focus:outline-none dark:border-slate-700 dark:bg-slate-900"
/> />
</div> </div>
</div> </div>
{targetUserBalance > 0 && ( {targetUserBalance > 0 && (
<div className="flex justify-between items-center p-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-emerald-50/50 dark:bg-emerald-900/20"> <div className="flex items-center justify-between rounded-lg border border-slate-200 bg-emerald-50/50 p-2 dark:border-slate-700 dark:bg-emerald-900/20">
<span className="text-sm font-bold text-slate-600 dark:text-slate-300">Dipotong dari Saldo (Otomatis)</span> <span className="text-sm font-bold text-slate-600 dark:text-slate-300">
<span className="text-sm font-black text-emerald-600 dark:text-emerald-400">{formatRupiah(targetUserBalance)}</span> Dipotong dari Saldo (Otomatis)
</span>
<span className="text-sm font-black text-emerald-600 dark:text-emerald-400">
{formatRupiah(targetUserBalance)}
</span>
</div> </div>
)} )}
<div className="bg-white dark:bg-slate-800 p-3 rounded-lg border border-slate-200 dark:border-slate-700 mt-1 flex justify-between items-center shadow-sm"> <div className="mt-1 flex items-center justify-between rounded-lg border border-slate-200 bg-white p-3 shadow-sm dark:border-slate-700 dark:bg-slate-800">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs font-bold text-slate-500">Total Pembayaran</span> <span className="text-xs font-bold text-slate-500">Total Pembayaran</span>
{targetUserBalance > 0 && ( {targetUserBalance > 0 && (
<span className="text-[10px] font-medium text-slate-400 leading-none mt-0.5">(Nominal Dibayar + Saldo)</span> <span className="mt-0.5 text-[10px] leading-none font-medium text-slate-400">
(Nominal Dibayar + Saldo)
</span>
)} )}
</div> </div>
<span className="text-lg font-black text-[#1B2CC1] dark:text-blue-400"> <span className="text-lg font-black text-[#1B2CC1] dark:text-blue-400">
{formatRupiah((Number(cashInput.replace(/\D/g, '')) || 0) + targetUserBalance)} {formatRupiah((Number(cashInput.replace(/\D/g, '')) || 0) + targetUserBalance)}
</span> </span>
</div> </div>
{(() => { {(() => {
const totalBayar = (Number(cashInput.replace(/\D/g, '')) || 0) + targetUserBalance const totalBayar = (Number(cashInput.replace(/\D/g, '')) || 0) + targetUserBalance
const kurang = target.amount - totalBayar const kurang = target.amount - totalBayar
if (kurang > 0) { if (kurang > 0) {
return ( return (
<div className="flex justify-between items-center px-2 py-1"> <div className="flex items-center justify-between px-2 py-1">
<span className="text-xs font-bold text-rose-500">Masih Kurang (Sisa Hutang)</span> <span className="text-xs font-bold text-rose-500">
<span className="text-xs font-black text-rose-500">{formatRupiah(kurang)}</span> Masih Kurang (Sisa Hutang)
</span>
<span className="text-xs font-black text-rose-500">
{formatRupiah(kurang)}
</span>
</div> </div>
) )
} else if (target.amount > 0) { } else if (target.amount > 0) {
return ( return (
<div className="flex justify-between items-center px-2 py-1"> <div className="flex items-center justify-between px-2 py-1">
<span className="text-xs font-bold text-emerald-500">Status</span> <span className="text-xs font-bold text-emerald-500">Status</span>
<span className="text-xs font-black text-emerald-500">Akan Lunas Sepenuhnya</span> <span className="text-xs font-black text-emerald-500">
Akan Lunas Sepenuhnya
</span>
</div> </div>
) )
} }
@@ -371,11 +491,20 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
})()} })()}
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0 mt-2"> <DialogFooter className="mt-2 gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setModalOpen(false)} disabled={saving} className="rounded-xl font-bold"> <Button
variant="outline"
onClick={() => setModalOpen(false)}
disabled={saving}
className="rounded-xl font-bold"
>
Batal Batal
</Button> </Button>
<Button onClick={handleConfirm} disabled={saving} className="rounded-xl font-bold bg-[#1B2CC1] hover:bg-[#121E85] text-white shadow-md shadow-[#1B2CC1]/20"> <Button
onClick={handleConfirm}
disabled={saving}
className="rounded-xl bg-[#1B2CC1] font-bold text-white shadow-md shadow-[#1B2CC1]/20 hover:bg-[#121E85]"
>
{saving ? 'Memproses...' : 'Proses Pelunasan'} {saving ? 'Memproses...' : 'Proses Pelunasan'}
</Button> </Button>
</DialogFooter> </DialogFooter>
+80 -39
View File
@@ -1,12 +1,30 @@
'use client' 'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Cell } from 'recharts' import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
Cell,
} from 'recharts'
export default function ReportCharts({ type, data }: { type: 'SUBMITTOR' | 'CREATOR', data: any[] }) { export default function ReportCharts({
type,
data,
}: {
type: 'SUBMITTOR' | 'CREATOR'
data: any[]
}) {
const formatRupiah = (value: number) => { const formatRupiah = (value: number) => {
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(value) return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(value)
} }
// Prepare chart data based on type // Prepare chart data based on type
@@ -14,25 +32,36 @@ export default function ReportCharts({ type, data }: { type: 'SUBMITTOR' | 'CREA
if (type === 'SUBMITTOR') { if (type === 'SUBMITTOR') {
chartData = [...data] chartData = [...data]
.filter(sub => sub.order.status === 'CLOSE') .filter((sub) => sub.order.status === 'CLOSE')
.sort((a, b) => new Date(a.order.date).getTime() - new Date(b.order.date).getTime()) // oldest → newest .sort((a, b) => new Date(a.order.date).getTime() - new Date(b.order.date).getTime()) // oldest → newest
.map(sub => ({ .map((sub) => {
name: sub.order.title.length > 15 ? sub.order.title.substring(0, 15) + '...' : sub.order.title, const bill = sub.bill || 0
fullTitle: sub.order.title, const paid =
Total: sub.bill || 0, (sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? bill : 0)) + (sub.saldo_used || 0)
status: sub.payment_status const unpaid = Math.max(0, bill - paid)
})) return {
name:
sub.order.title.length > 15
? sub.order.title.substring(0, 15) + '...'
: sub.order.title,
fullTitle: sub.order.title,
Total: bill,
Paid: paid,
Unpaid: unpaid,
status: unpaid > 0 ? (paid > 0 ? 'SEBAGIAN' : 'BELUM BAYAR') : 'LUNAS',
}
})
} else { } else {
chartData = [...data] chartData = [...data]
.filter(order => order.status === 'CLOSE') .filter((order) => order.status === 'CLOSE')
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) // oldest → newest .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) // oldest → newest
.map(order => { .map((order) => {
const total = order.submissions.reduce((acc: number, sub: any) => acc + (sub.bill || 0), 0) const total = order.submissions.reduce((acc: number, sub: any) => acc + (sub.bill || 0), 0)
return { return {
name: order.title.length > 15 ? order.title.substring(0, 15) + '...' : order.title, name: order.title.length > 15 ? order.title.substring(0, 15) + '...' : order.title,
fullTitle: order.title, fullTitle: order.title,
Total: total, Total: total,
status: order.status status: order.status,
} }
}) })
} }
@@ -40,9 +69,11 @@ export default function ReportCharts({ type, data }: { type: 'SUBMITTOR' | 'CREA
// If no data to show // If no data to show
if (chartData.length === 0) { if (chartData.length === 0) {
return ( return (
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm"> <Card className="rounded-2xl border border-slate-200/90 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<CardContent className="p-12 text-center"> <CardContent className="p-12 text-center">
<p className="text-slate-500 text-xs font-medium">Belum ada data transaksi yang cukup untuk ditampilkan di grafik.</p> <p className="text-xs font-medium text-slate-500">
Belum ada data transaksi yang cukup untuk ditampilkan di grafik.
</p>
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -50,14 +81,27 @@ export default function ReportCharts({ type, data }: { type: 'SUBMITTOR' | 'CREA
const CustomTooltip = ({ active, payload, label }: any) => { const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const data = payload[0].payload
return ( return (
<div className="bg-white dark:bg-slate-900 p-3 border border-slate-200 dark:border-slate-800 rounded-xl shadow-lg"> <div className="rounded-xl border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-800 dark:bg-slate-900">
<p className="font-bold text-xs text-slate-800 dark:text-slate-200 mb-1">{payload[0].payload.fullTitle}</p> <p className="mb-1 text-xs font-bold text-slate-800 dark:text-slate-200">
<p className="text-sm font-black text-[#1B2CC1]"> {data.fullTitle}
{formatRupiah(payload[0].value)}
</p> </p>
<p className="text-[10px] text-slate-500 mt-1 uppercase font-bold tracking-wider"> <p className="text-sm font-black text-[#1B2CC1]">Total: {formatRupiah(data.Total)}</p>
Status: {payload[0].payload.status} {type === 'SUBMITTOR' && (
<>
<p className="mt-1 text-[11px] font-bold text-emerald-600">
Dibayar: {formatRupiah(data.Paid)}
</p>
{data.Unpaid > 0 && (
<p className="text-[11px] font-bold text-rose-600">
Kurang: {formatRupiah(data.Unpaid)}
</p>
)}
</>
)}
<p className="mt-1 text-[10px] font-bold tracking-wider text-slate-500 uppercase">
Status: {data.status}
</p> </p>
</div> </div>
) )
@@ -66,41 +110,38 @@ export default function ReportCharts({ type, data }: { type: 'SUBMITTOR' | 'CREA
} }
return ( return (
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden"> <Card className="overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<CardHeader className="bg-slate-50/50 dark:bg-slate-800/40 border-b border-slate-100 dark:border-slate-800 p-4"> <CardHeader className="border-b border-slate-100 bg-slate-50/50 p-4 dark:border-slate-800 dark:bg-slate-800/40">
<CardTitle className="text-sm font-black text-slate-800 dark:text-slate-200"> <CardTitle className="text-sm font-black text-slate-800 dark:text-slate-200">
{type === 'SUBMITTOR' ? 'Grafik Pengeluaran per PO' : 'Grafik Omzet per PO'} {type === 'SUBMITTOR' ? 'Grafik Pengeluaran per PO' : 'Grafik Omzet per PO'}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-4 sm:p-6 h-[300px] w-full"> <CardContent className="h-[300px] w-full p-4 sm:p-6">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}> <BarChart data={chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
<XAxis <XAxis
dataKey="name" dataKey="name"
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fontSize: 10, fill: '#64748b' }} tick={{ fontSize: 10, fill: '#64748b' }}
dy={10} dy={10}
/> />
<YAxis <YAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fontSize: 10, fill: '#64748b' }} tick={{ fontSize: 10, fill: '#64748b' }}
tickFormatter={(value) => `Rp${value / 1000}k`} tickFormatter={(value) => `Rp${value / 1000}k`}
/> />
<Tooltip cursor={{ fill: 'rgba(27, 44, 193, 0.05)' }} content={<CustomTooltip />} /> <Tooltip cursor={{ fill: 'rgba(27, 44, 193, 0.05)' }} content={<CustomTooltip />} />
<Bar dataKey="Total" radius={[4, 4, 0, 0]}> {type === 'SUBMITTOR' ? (
{chartData.map((entry, index) => ( <>
<Cell <Bar dataKey="Paid" stackId="a" fill="#10b981" />
key={`cell-${index}`} <Bar dataKey="Unpaid" stackId="a" fill="#f43f5e" radius={[4, 4, 0, 0]} />
fill={type === 'SUBMITTOR' </>
? (entry.status === 'LUNAS' ? '#10b981' : '#f43f5e') // Emerald or Rose ) : (
: '#1B2CC1' // Primary for creator <Bar dataKey="Total" radius={[4, 4, 0, 0]} fill="#1B2CC1" />
} )}
/>
))}
</Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</CardContent> </CardContent>
+219 -96
View File
@@ -2,34 +2,55 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { ChevronDown, ChevronUp, Package, Users, Receipt, Calendar, Wallet, TrendingUp, InboxIcon, ChevronLeft, ChevronRight, Search } from 'lucide-react' import {
ChevronDown,
ChevronUp,
Package,
Users,
Receipt,
Calendar,
Wallet,
TrendingUp,
InboxIcon,
ChevronLeft,
ChevronRight,
Search,
} from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { format } from 'date-fns' import { format } from 'date-fns'
const ITEMS_PER_PAGE = 5 const ITEMS_PER_PAGE = 5
function PaginationBar({ currentPage, totalPages, onPage }: { currentPage: number; totalPages: number; onPage: (p: number) => void }) { function PaginationBar({
currentPage,
totalPages,
onPage,
}: {
currentPage: number
totalPages: number
onPage: (p: number) => void
}) {
if (totalPages <= 1) return null if (totalPages <= 1) return null
return ( return (
<div className="flex items-center justify-between px-6 py-4 bg-slate-50/50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800"> <div className="flex items-center justify-between border-t border-slate-100 bg-slate-50/50 px-6 py-4 dark:border-slate-800 dark:bg-slate-900/50">
<p className="text-xs font-medium text-slate-500"> <p className="text-xs font-medium text-slate-500">
Halaman <span className="font-bold text-slate-700 dark:text-slate-300">{currentPage}</span> dari{' '} Halaman <span className="font-bold text-slate-700 dark:text-slate-300">{currentPage}</span>{' '}
<span className="font-bold text-slate-700 dark:text-slate-300">{totalPages}</span> dari <span className="font-bold text-slate-700 dark:text-slate-300">{totalPages}</span>
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={() => onPage(Math.max(1, currentPage - 1))} onClick={() => onPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-700 disabled:opacity-40 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" className="rounded-lg border border-slate-200 p-1.5 transition-colors hover:bg-slate-100 disabled:opacity-40 dark:border-slate-700 dark:hover:bg-slate-800"
> >
<ChevronLeft className="w-3.5 h-3.5" /> <ChevronLeft className="h-3.5 w-3.5" />
</button> </button>
<button <button
onClick={() => onPage(Math.min(totalPages, currentPage + 1))} onClick={() => onPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-700 disabled:opacity-40 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" className="rounded-lg border border-slate-200 p-1.5 transition-colors hover:bg-slate-100 disabled:opacity-40 dark:border-slate-700 dark:hover:bg-slate-800"
> >
<ChevronRight className="w-3.5 h-3.5" /> <ChevronRight className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
@@ -38,17 +59,27 @@ function PaginationBar({ currentPage, totalPages, onPage }: { currentPage: numbe
function EmptyState({ label }: { label: string }) { function EmptyState({ label }: { label: string }) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-slate-400"> <div className="flex flex-col items-center justify-center gap-3 py-16 text-slate-400">
<InboxIcon className="w-10 h-10 opacity-40" /> <InboxIcon className="h-10 w-10 opacity-40" />
<p className="text-sm font-semibold">{label}</p> <p className="text-sm font-semibold">{label}</p>
</div> </div>
) )
} }
const formatRupiah = (value: number) => const formatRupiah = (value: number) =>
new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(value) new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(value)
export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CREATOR'; data: any[] }) { export default function ReportDataGrid({
type,
data,
}: {
type: 'SUBMITTOR' | 'CREATOR'
data: any[]
}) {
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({}) const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({})
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -68,7 +99,7 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
const q = searchQuery.toLowerCase().trim() const q = searchQuery.toLowerCase().trim()
const filteredData = q const filteredData = q
? data.filter(item => { ? data.filter((item) => {
if (type === 'SUBMITTOR') { if (type === 'SUBMITTOR') {
return ( return (
item.order?.title?.toLowerCase().includes(q) || item.order?.title?.toLowerCase().includes(q) ||
@@ -81,28 +112,32 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
: data : data
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE) const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE)
const paginatedData = filteredData.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE) const paginatedData = filteredData.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
)
const toggleRow = (id: string) => const toggleRow = (id: string) => setExpandedRows((prev) => ({ ...prev, [id]: !prev[id] }))
setExpandedRows(prev => ({ ...prev, [id]: !prev[id] }))
/* ─────────────── SUBMITTOR TABLE ─────────────── */ /* ─────────────── SUBMITTOR TABLE ─────────────── */
if (type === 'SUBMITTOR') { if (type === 'SUBMITTOR') {
return ( return (
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden"> <Card className="overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="bg-slate-50/50 dark:bg-slate-800/40 px-4 py-3 border-b border-slate-100 dark:border-slate-800 flex flex-col sm:flex-row sm:items-center gap-3"> <div className="flex flex-col gap-3 border-b border-slate-100 bg-slate-50/50 px-4 py-3 sm:flex-row sm:items-center dark:border-slate-800 dark:bg-slate-800/40">
<div className="flex items-center gap-2 flex-1"> <div className="flex flex-1 items-center gap-2">
<Wallet className="w-4 h-4 text-[#1B2CC1] shrink-0" /> <Wallet className="h-4 w-4 shrink-0 text-[#1B2CC1]" />
<h3 className="text-sm font-black text-slate-800 dark:text-slate-200">Detail Pengeluaran Anda</h3> <h3 className="text-sm font-black text-slate-800 dark:text-slate-200">
Detail Pengeluaran Anda
</h3>
</div> </div>
<div className="flex items-center gap-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg px-3 py-1.5 w-full sm:w-52"> <div className="flex w-full items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 sm:w-52 dark:border-slate-700 dark:bg-slate-900">
<Search className="w-3.5 h-3.5 text-slate-400 shrink-0" /> <Search className="h-3.5 w-3.5 shrink-0 text-slate-400" />
<input <input
type="text" type="text"
placeholder="Cari judul PO atau kreator..." placeholder="Cari judul PO atau kreator..."
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="text-xs font-medium bg-transparent border-none outline-none w-full text-slate-700 dark:text-slate-200 placeholder:text-slate-400" className="w-full border-none bg-transparent text-xs font-medium text-slate-700 outline-none placeholder:text-slate-400 dark:text-slate-200"
/> />
</div> </div>
</div> </div>
@@ -114,14 +149,14 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
) : ( ) : (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-left text-sm">
<thead className="text-xs text-slate-500 uppercase bg-slate-50/30 dark:bg-slate-900/50"> <thead className="bg-slate-50/30 text-xs text-slate-500 uppercase dark:bg-slate-900/50">
<tr> <tr>
<th className="px-6 py-4 font-bold w-8"></th> <th className="w-8 px-6 py-4 font-bold"></th>
<th className="px-6 py-4 font-bold">Judul PO & Kreator</th> <th className="px-6 py-4 font-bold">Judul PO & Kreator</th>
<th className="px-6 py-4 font-bold">Tanggal</th> <th className="px-6 py-4 font-bold">Tanggal</th>
<th className="px-6 py-4 font-bold">Status Bayar</th> <th className="px-6 py-4 font-bold">Status Bayar</th>
<th className="px-6 py-4 font-bold text-right">Total Tagihan</th> <th className="px-6 py-4 text-right font-bold">Total Tagihan</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800"> <tbody className="divide-y divide-slate-100 dark:divide-slate-800">
@@ -132,51 +167,83 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
<tr <tr
onClick={() => toggleRow(sub.id)} onClick={() => toggleRow(sub.id)}
className={cn( className={cn(
'hover:bg-slate-50/50 dark:hover:bg-slate-800/40 cursor-pointer transition-colors group', 'group cursor-pointer transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/40',
isExpanded && 'bg-slate-50/30 dark:bg-slate-800/20' isExpanded && 'bg-slate-50/30 dark:bg-slate-800/20'
)} )}
> >
<td className="px-6 py-4"> <td className="px-6 py-4">
<button className="text-slate-400 group-hover:text-[#1B2CC1] transition-colors p-1 rounded-full hover:bg-blue-50 dark:hover:bg-blue-900/30"> <button className="rounded-full p-1 text-slate-400 transition-colors group-hover:text-[#1B2CC1] hover:bg-blue-50 dark:hover:bg-blue-900/30">
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} {isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button> </button>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<p className="font-bold text-slate-800 dark:text-slate-200">{sub.order.title}</p> <p className="font-bold text-slate-800 dark:text-slate-200">
<p className="text-[11px] text-slate-500 flex items-center gap-1 mt-0.5"> {sub.order.title}
<Users className="w-3 h-3" /> {sub.order.creator.name} </p>
<p className="mt-0.5 flex items-center gap-1 text-[11px] text-slate-500">
<Users className="h-3 w-3" /> {sub.order.creator.name}
</p> </p>
</td> </td>
<td className="px-6 py-4 text-xs text-slate-600 dark:text-slate-400"> <td className="px-6 py-4 text-xs text-slate-600 dark:text-slate-400">
{format(new Date(sub.order.date), 'dd MMM yyyy')} {format(new Date(sub.order.date), 'dd MMM yyyy')}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className={cn( <span
'inline-flex px-2 py-0.5 rounded-full text-[10px] font-black tracking-wide', className={cn(
sub.payment_status === 'LUNAS' 'inline-flex rounded-full px-2 py-0.5 text-[10px] font-black tracking-wide',
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400 border border-emerald-200/80' sub.payment_status === 'LUNAS'
: 'bg-rose-50 text-rose-700 dark:bg-rose-950/50 dark:text-rose-400 border border-rose-200/80' ? 'border border-emerald-200/80 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400'
)}> : 'border border-rose-200/80 bg-rose-50 text-rose-700 dark:bg-rose-950/50 dark:text-rose-400'
)}
>
{sub.payment_status === 'LUNAS' ? 'LUNAS' : 'BELUM BAYAR'} {sub.payment_status === 'LUNAS' ? 'LUNAS' : 'BELUM BAYAR'}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-right font-black text-slate-900 dark:text-white"> <td className="flex flex-col px-6 py-4 text-right font-black text-slate-900 dark:text-white">
{sub.bill ? formatRupiah(sub.bill) : '-'} <span>{sub.bill ? formatRupiah(sub.bill) : '-'}</span>
{(() => {
const bill = Number(sub.bill) || 0
const paid =
(sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? bill : 0)) +
(sub.saldo_used || 0)
const unpaid = Math.max(0, bill - paid)
if (unpaid > 0 && paid > 0) {
return (
<div className="mt-1 text-[10px] leading-tight font-medium text-slate-500">
Dibayar: {formatRupiah(paid)}
<br />
Sisa: {formatRupiah(unpaid)}
</div>
)
}
return null
})()}
</td> </td>
</tr> </tr>
{isExpanded && ( {isExpanded && (
<tr className="bg-slate-50 dark:bg-slate-900/60"> <tr className="bg-slate-50 dark:bg-slate-900/60">
<td colSpan={5} className="px-6 py-5 border-l-4 border-l-[#1B2CC1]"> <td colSpan={5} className="border-l-4 border-l-[#1B2CC1] px-6 py-5">
<div className="pl-8"> <div className="pl-8">
<h4 className="text-[11px] font-bold uppercase tracking-wider text-slate-500 mb-3 flex items-center gap-1.5"> <h4 className="mb-3 flex items-center gap-1.5 text-[11px] font-bold tracking-wider text-slate-500 uppercase">
<Package className="w-3.5 h-3.5" /> Item yang Anda Pesan <Package className="h-3.5 w-3.5" /> Item yang Anda Pesan
</h4> </h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{sub.items.map((item: any) => ( {sub.items.map((item: any) => (
<div key={item.id} className="bg-white dark:bg-slate-800 border border-slate-200/60 dark:border-slate-700 px-3 py-2 rounded-xl flex items-center justify-between shadow-sm"> <div
<span className="text-xs font-semibold text-slate-700 dark:text-slate-300 pr-2">{item.name}</span> key={item.id}
<span className="text-xs font-black text-[#1B2CC1] bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 rounded-md">{item.qty}x</span> className="flex items-center justify-between rounded-xl border border-slate-200/60 bg-white px-3 py-2 shadow-sm dark:border-slate-700 dark:bg-slate-800"
>
<span className="pr-2 text-xs font-semibold text-slate-700 dark:text-slate-300">
{item.name}
</span>
<span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-black text-[#1B2CC1] dark:bg-blue-900/30">
{item.qty}x
</span>
</div> </div>
))} ))}
</div> </div>
@@ -190,7 +257,11 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
</tbody> </tbody>
</table> </table>
</div> </div>
<PaginationBar currentPage={currentPage} totalPages={totalPages} onPage={setCurrentPage} /> <PaginationBar
currentPage={currentPage}
totalPages={totalPages}
onPage={setCurrentPage}
/>
</> </>
)} )}
</Card> </Card>
@@ -199,20 +270,22 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
/* ─────────────── CREATOR TABLE ─────────────── */ /* ─────────────── CREATOR TABLE ─────────────── */
return ( return (
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden"> <Card className="overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="bg-slate-50/50 dark:bg-slate-800/40 px-4 py-3 border-b border-slate-100 dark:border-slate-800 flex flex-col sm:flex-row sm:items-center gap-3"> <div className="flex flex-col gap-3 border-b border-slate-100 bg-slate-50/50 px-4 py-3 sm:flex-row sm:items-center dark:border-slate-800 dark:bg-slate-800/40">
<div className="flex items-center gap-2 flex-1"> <div className="flex flex-1 items-center gap-2">
<TrendingUp className="w-4 h-4 text-[#1B2CC1] shrink-0" /> <TrendingUp className="h-4 w-4 shrink-0 text-[#1B2CC1]" />
<h3 className="text-sm font-black text-slate-800 dark:text-slate-200">Riwayat Omzet per PO</h3> <h3 className="text-sm font-black text-slate-800 dark:text-slate-200">
Riwayat Omzet per PO
</h3>
</div> </div>
<div className="flex items-center gap-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg px-3 py-1.5 w-full sm:w-52"> <div className="flex w-full items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 sm:w-52 dark:border-slate-700 dark:bg-slate-900">
<Search className="w-3.5 h-3.5 text-slate-400 shrink-0" /> <Search className="h-3.5 w-3.5 shrink-0 text-slate-400" />
<input <input
type="text" type="text"
placeholder="Cari judul PO..." placeholder="Cari judul PO..."
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="text-xs font-medium bg-transparent border-none outline-none w-full text-slate-700 dark:text-slate-200 placeholder:text-slate-400" className="w-full border-none bg-transparent text-xs font-medium text-slate-700 outline-none placeholder:text-slate-400 dark:text-slate-200"
/> />
</div> </div>
</div> </div>
@@ -224,47 +297,67 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
) : ( ) : (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-left text-sm">
<thead className="text-xs text-slate-500 uppercase bg-slate-50/30 dark:bg-slate-900/50"> <thead className="bg-slate-50/30 text-xs text-slate-500 uppercase dark:bg-slate-900/50">
<tr> <tr>
<th className="px-6 py-4 font-bold w-8"></th> <th className="w-8 px-6 py-4 font-bold"></th>
<th className="px-6 py-4 font-bold">Judul PO</th> <th className="px-6 py-4 font-bold">Judul PO</th>
<th className="px-6 py-4 font-bold">Status</th> <th className="px-6 py-4 font-bold">Status</th>
<th className="px-6 py-4 font-bold text-center">Peserta</th> <th className="px-6 py-4 text-center font-bold">Peserta</th>
<th className="px-6 py-4 font-bold text-right">Total Omzet PO</th> <th className="px-6 py-4 text-right font-bold">Total Omzet PO</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800"> <tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{paginatedData.map((order: any) => { {paginatedData.map((order: any) => {
const isExpanded = !!expandedRows[order.id] const isExpanded = !!expandedRows[order.id]
const totalOmzet = order.submissions.reduce((acc: number, sub: any) => acc + (Number(sub.bill) || 0), 0) const totalOmzet = order.submissions.reduce(
const totalPiutang = order.submissions.reduce((acc: number, sub: any) => acc + (sub.payment_status !== 'LUNAS' ? (Number(sub.bill) || 0) : 0), 0) (acc: number, sub: any) => acc + (Number(sub.bill) || 0),
0
)
const totalPiutang = order.submissions.reduce((acc: number, sub: any) => {
const bill = Number(sub.bill) || 0
const paid =
(sub.paid_amount ?? (sub.payment_status === 'LUNAS' ? bill : 0)) +
(sub.saldo_used || 0)
return acc + Math.max(0, bill - paid)
}, 0)
return ( return (
<React.Fragment key={order.id}> <React.Fragment key={order.id}>
<tr <tr
onClick={() => toggleRow(order.id)} onClick={() => toggleRow(order.id)}
className={cn( className={cn(
'hover:bg-slate-50/50 dark:hover:bg-slate-800/40 cursor-pointer transition-colors group', 'group cursor-pointer transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/40',
isExpanded && 'bg-slate-50/30 dark:bg-slate-800/20' isExpanded && 'bg-slate-50/30 dark:bg-slate-800/20'
)} )}
> >
<td className="px-6 py-4"> <td className="px-6 py-4">
<button className="text-slate-400 group-hover:text-[#1B2CC1] transition-colors p-1 rounded-full hover:bg-blue-50 dark:hover:bg-blue-900/30"> <button className="rounded-full p-1 text-slate-400 transition-colors group-hover:text-[#1B2CC1] hover:bg-blue-50 dark:hover:bg-blue-900/30">
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} {isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button> </button>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<p className="font-bold text-slate-800 dark:text-slate-200">{order.title}</p> <p className="font-bold text-slate-800 dark:text-slate-200">
<p className="text-[11px] text-slate-500 flex items-center gap-1 mt-0.5"> {order.title}
<Calendar className="w-3 h-3" /> {format(new Date(order.date), 'dd MMM yyyy')} </p>
<p className="mt-0.5 flex items-center gap-1 text-[11px] text-slate-500">
<Calendar className="h-3 w-3" />{' '}
{format(new Date(order.date), 'dd MMM yyyy')}
</p> </p>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className={cn( <span
'inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black tracking-wide', className={cn(
order.status === 'CLOSE' ? 'bg-rose-50 text-rose-700 dark:bg-rose-950/50 dark:text-rose-400 border border-rose-200/80' : 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 border border-slate-200/90' 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-black tracking-wide',
)}> order.status === 'CLOSE'
? 'border border-rose-200/80 bg-rose-50 text-rose-700 dark:bg-rose-950/50 dark:text-rose-400'
: 'border border-slate-200/90 bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
)}
>
â—Ź {order.status} â—Ź {order.status}
</span> </span>
</td> </td>
@@ -272,26 +365,30 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
{order.submissions.length} Orang {order.submissions.length} Orang
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<p className="font-black text-slate-900 dark:text-white">{formatRupiah(totalOmzet)}</p> <p className="font-black text-slate-900 dark:text-white">
{formatRupiah(totalOmzet)}
</p>
{totalPiutang > 0 && ( {totalPiutang > 0 && (
<p className="text-[10px] text-rose-500 font-bold mt-0.5">Piutang: {formatRupiah(totalPiutang)}</p> <p className="mt-0.5 text-[10px] font-bold text-rose-500">
Piutang: {formatRupiah(totalPiutang)}
</p>
)} )}
</td> </td>
</tr> </tr>
{isExpanded && ( {isExpanded && (
<tr className="bg-slate-50 dark:bg-slate-900/60"> <tr className="bg-slate-50 dark:bg-slate-900/60">
<td colSpan={5} className="px-6 py-5 border-l-4 border-l-[#1B2CC1]"> <td colSpan={5} className="border-l-4 border-l-[#1B2CC1] px-6 py-5">
<div className="pl-8 space-y-3"> <div className="space-y-3 pl-8">
<h4 className="text-[11px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1.5"> <h4 className="flex items-center gap-1.5 text-[11px] font-bold tracking-wider text-slate-500 uppercase">
<Receipt className="w-3.5 h-3.5" /> Breakdown Pemesan <Receipt className="h-3.5 w-3.5" /> Breakdown Pemesan
</h4> </h4>
{order.submissions.length === 0 ? ( {order.submissions.length === 0 ? (
<p className="text-xs text-slate-400 italic">Belum ada pemesan.</p> <p className="text-xs text-slate-400 italic">Belum ada pemesan.</p>
) : ( ) : (
<div className="overflow-hidden border border-slate-200/80 dark:border-slate-700 rounded-xl bg-white dark:bg-slate-800"> <div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white dark:border-slate-700 dark:bg-slate-800">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="bg-slate-100/50 dark:bg-slate-700/30 text-slate-500 uppercase font-semibold"> <thead className="bg-slate-100/50 font-semibold text-slate-500 uppercase dark:bg-slate-700/30">
<tr> <tr>
<th className="px-4 py-2.5 text-left">Nama</th> <th className="px-4 py-2.5 text-left">Nama</th>
<th className="px-4 py-2.5 text-left">Status</th> <th className="px-4 py-2.5 text-left">Status</th>
@@ -301,19 +398,41 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
<tbody className="divide-y divide-slate-100 dark:divide-slate-700/50"> <tbody className="divide-y divide-slate-100 dark:divide-slate-700/50">
{order.submissions.map((sub: any) => ( {order.submissions.map((sub: any) => (
<tr key={sub.id}> <tr key={sub.id}>
<td className="px-4 py-2.5 font-semibold text-slate-800 dark:text-slate-200">{sub.user.name}</td> <td className="px-4 py-2.5 font-semibold text-slate-800 dark:text-slate-200">
{sub.user.name}
</td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<span className={cn( <span
'text-[9px] font-bold px-1.5 py-0.5 rounded uppercase', className={cn(
sub.payment_status === 'LUNAS' 'rounded px-1.5 py-0.5 text-[9px] font-bold uppercase',
? 'text-emerald-700 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-950/40' sub.payment_status === 'LUNAS'
: 'text-rose-700 bg-rose-100 dark:text-rose-400 dark:bg-rose-950/40' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
)}> : 'bg-rose-100 text-rose-700 dark:bg-rose-950/40 dark:text-rose-400'
)}
>
{sub.payment_status === 'LUNAS' ? 'Lunas' : 'Belum'} {sub.payment_status === 'LUNAS' ? 'Lunas' : 'Belum'}
</span> </span>
</td> </td>
<td className="px-4 py-2.5 text-right font-black text-slate-700 dark:text-slate-300"> <td className="flex flex-col px-4 py-2.5 text-right font-black text-slate-700 dark:text-slate-300">
{sub.bill ? formatRupiah(sub.bill) : '-'} <span>{sub.bill ? formatRupiah(sub.bill) : '-'}</span>
{(() => {
const bill = Number(sub.bill) || 0
const paid =
(sub.paid_amount ??
(sub.payment_status === 'LUNAS' ? bill : 0)) +
(sub.saldo_used || 0)
const unpaid = Math.max(0, bill - paid)
if (unpaid > 0 && paid > 0) {
return (
<div className="mt-0.5 text-[10px] leading-tight font-medium text-slate-500">
Masuk: {formatRupiah(paid)}
<br />
Sisa: {formatRupiah(unpaid)}
</div>
)
}
return null
})()}
</td> </td>
</tr> </tr>
))} ))}
@@ -331,7 +450,11 @@ export default function ReportDataGrid({ type, data }: { type: 'SUBMITTOR' | 'CR
</tbody> </tbody>
</table> </table>
</div> </div>
<PaginationBar currentPage={currentPage} totalPages={totalPages} onPage={setCurrentPage} /> <PaginationBar
currentPage={currentPage}
totalPages={totalPages}
onPage={setCurrentPage}
/>
</> </>
)} )}
</Card> </Card>
+48 -29
View File
@@ -12,12 +12,19 @@ interface ShareButtonProps {
orderId: string orderId: string
orderTitle?: string orderTitle?: string
className?: string className?: string
variant?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" variant?: 'link' | 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost'
size?: "default" | "sm" | "lg" | "icon" size?: 'default' | 'sm' | 'lg' | 'icon'
showText?: boolean showText?: boolean
} }
export function ShareButton({ orderId, orderTitle = 'Pesanan', className, variant = "outline", size = "sm", showText = true }: ShareButtonProps) { export function ShareButton({
orderId,
orderTitle = 'Pesanan',
className,
variant = 'outline',
size = 'sm',
showText = true,
}: ShareButtonProps) {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [broadcasting, setBroadcasting] = useState(false) const [broadcasting, setBroadcasting] = useState(false)
@@ -31,11 +38,13 @@ export function ShareButton({ orderId, orderTitle = 'Pesanan', className, varian
const handleShareWA = async () => { const handleShareWA = async () => {
const settings = await getSettings() const settings = await getSettings()
let template = settings.WHATSAPP_TEMPLATE || 'Halo tim! Lagi ada orderan open nih untuk {title}. Mumpung belum di-checkout, yang mau ikutan nitip bisa langsung cek ke sini ya: {url}' let template =
settings.WHATSAPP_TEMPLATE ||
'Halo tim! Lagi ada orderan open nih untuk {title}. Mumpung belum di-checkout, yang mau ikutan nitip bisa langsung cek ke sini ya: {url}'
const url = `${window.location.origin}/order/${orderId}` const url = `${window.location.origin}/order/${orderId}`
const text = template.replace('{title}', orderTitle).replace('{url}', url) const text = template.replace('{title}', orderTitle).replace('{url}', url)
window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank') window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank')
} }
@@ -46,13 +55,13 @@ export function ShareButton({ orderId, orderTitle = 'Pesanan', className, varian
if (res.success) { if (res.success) {
toast.success('Broadcast Terkirim!', { toast.success('Broadcast Terkirim!', {
description: 'Berhasil mengirim pesan ke Mattermost.', description: 'Berhasil mengirim pesan ke Mattermost.',
duration: 3000 duration: 3000,
}) })
setOpen(false) setOpen(false)
} else { } else {
toast.error('Gagal Broadcast', { toast.error('Gagal Broadcast', {
description: res.error || 'Terjadi kesalahan saat mengirim.', description: res.error || 'Terjadi kesalahan saat mengirim.',
duration: 3000 duration: 3000,
}) })
} }
setBroadcasting(false) setBroadcasting(false)
@@ -60,48 +69,58 @@ export function ShareButton({ orderId, orderTitle = 'Pesanan', className, varian
return ( return (
<> <>
<Button <Button
variant={variant} variant={variant}
size={size} size={size}
className={cn("transition-all", className)} className={cn('transition-all', className)}
title="Bagikan PO" title="Bagikan PO"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<Share2 className="w-3.5 h-3.5 shrink-0" /> <Share2 className="h-3.5 w-3.5 shrink-0" />
{showText && <span className="truncate hidden sm:inline">Bagikan</span>} {showText && <span className="hidden truncate sm:inline">Bagikan</span>}
</Button> </Button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xs p-6 rounded-3xl border-slate-200/90 dark:border-slate-800"> <DialogContent className="rounded-3xl border-slate-200/90 p-6 sm:max-w-xs dark:border-slate-800">
<DialogTitle className="text-xl font-black text-center text-slate-900 dark:text-white mb-2">Bagikan Pesanan</DialogTitle> <DialogTitle className="mb-2 text-center text-xl font-black text-slate-900 dark:text-white">
<p className="text-sm text-slate-500 text-center mb-4"> Bagikan Pesanan
</DialogTitle>
<p className="mb-4 text-center text-sm text-slate-500">
Pilih metode untuk membagikan PO ini ke teman atau tim Anda. Pilih metode untuk membagikan PO ini ke teman atau tim Anda.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<Button <Button
variant="outline" variant="outline"
onClick={handleCopyLink} onClick={handleCopyLink}
className="w-full h-11 rounded-xl justify-start font-bold gap-3 text-slate-700 dark:text-slate-300" className="h-11 w-full justify-start gap-3 rounded-xl font-bold text-slate-700 dark:text-slate-300"
> >
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />} {copied ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<Copy className="h-4 w-4" />
)}
{copied ? 'Tautan Disalin!' : 'Salin Tautan'} {copied ? 'Tautan Disalin!' : 'Salin Tautan'}
</Button> </Button>
<Button <Button
onClick={handleShareWA} onClick={handleShareWA}
className="w-full h-11 rounded-xl justify-start font-bold gap-3 bg-[#25D366] hover:bg-[#20bd5a] text-white" className="h-11 w-full justify-start gap-3 rounded-xl bg-[#25D366] font-bold text-white hover:bg-[#20bd5a]"
> >
<MessageCircle className="w-4 h-4" /> <MessageCircle className="h-4 w-4" />
Kirim ke WhatsApp Kirim ke WhatsApp
</Button> </Button>
<Button <Button
onClick={handleBroadcastMattermost} onClick={handleBroadcastMattermost}
disabled={broadcasting} disabled={broadcasting}
className="w-full h-11 rounded-xl justify-start font-bold gap-3 bg-[#0668E1] hover:bg-[#0557bc] text-white" className="h-11 w-full justify-start gap-3 rounded-xl bg-[#0668E1] font-bold text-white hover:bg-[#0557bc]"
> >
{broadcasting ? <Loader2 className="w-4 h-4 animate-spin" /> : <MessageSquare className="w-4 h-4" />} {broadcasting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquare className="h-4 w-4" />
)}
{broadcasting ? 'Mengirim...' : 'Broadcast ke Mattermost'} {broadcasting ? 'Mengirim...' : 'Broadcast ke Mattermost'}
</Button> </Button>
</div> </div>
+107 -71
View File
@@ -4,29 +4,30 @@ import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Store, Store,
ClipboardList, ClipboardList,
ShoppingBag, ShoppingBag,
UserCircle, UserCircle,
Sparkles, Sparkles,
X, X,
ArrowUpRight, ArrowUpRight,
Info, Info,
BarChart2, BarChart2,
Wallet, Wallet,
Settings Settings,
} from 'lucide-react' } from 'lucide-react'
const menuItems = [ const menuItems = [
{ name: 'Open Order', href: '/', icon: Store, desc: 'Daftar PO live hari ini' }, { name: 'Open Order', href: '/', icon: Store, desc: 'Daftar PO live hari ini' },
{ name: 'Jasa Order Saya', href: '/my-orders', icon: ClipboardList, desc: 'Kelola PO buatan Anda' }, {
name: 'Jasa Order Saya',
href: '/my-orders',
icon: ClipboardList,
desc: 'Kelola PO buatan Anda',
},
{ name: 'Pesanan Saya', href: '/my-purchases', icon: ShoppingBag, desc: 'Riwayat titipan Anda' }, { name: 'Pesanan Saya', href: '/my-purchases', icon: ShoppingBag, desc: 'Riwayat titipan Anda' },
{ name: 'Buku Saldo', href: '/balances', icon: Wallet, desc: 'Pantau riwayat saldo' }, { name: 'Buku Saldo', href: '/balances', icon: Wallet, desc: 'Pantau riwayat saldo' },
{ name: 'Laporan', href: '/reports', icon: BarChart2, desc: 'Ringkasan transaksi' }, { name: 'Laporan', href: '/reports', icon: BarChart2, desc: 'Ringkasan transaksi' },
@@ -64,79 +65,95 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
// Dynamic Menu Items based on role // Dynamic Menu Items based on role
const finalMenuItems = [...menuItems] const finalMenuItems = [...menuItems]
if (userRole === 'superadmin') { if (userRole === 'superadmin') {
finalMenuItems.push({ name: 'Pengaturan', href: '/settings/users', icon: Settings, desc: 'Konfigurasi aplikasi' }) finalMenuItems.push({
name: 'Pengaturan',
href: '/settings/users',
icon: Settings,
desc: 'Konfigurasi aplikasi',
})
} }
return ( return (
<> <>
{/* Mobile Backdrop */} {/* Mobile Backdrop */}
{isOpen && ( {isOpen && (
<div <div
onClick={onClose} onClick={onClose}
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-40 lg:hidden transition-opacity" className="fixed inset-0 z-40 bg-slate-900/40 backdrop-blur-sm transition-opacity lg:hidden"
/> />
)} )}
<aside className={cn( <aside
"fixed lg:sticky top-0 left-0 z-50 h-screen w-72 bg-white dark:bg-slate-900 border-r border-slate-200/80 dark:border-slate-800 flex flex-col justify-between transition-transform duration-300 ease-in-out p-5", className={cn(
isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0" 'fixed top-0 left-0 z-50 flex h-screen w-72 flex-col justify-between border-r border-slate-200/80 bg-white p-5 transition-transform duration-300 ease-in-out lg:sticky dark:border-slate-800 dark:bg-slate-900',
)}> isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
)}
>
<div className="space-y-6"> <div className="space-y-6">
{/* Brand Header */} {/* Brand Header */}
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between px-1">
<Link href="/" className="flex items-center gap-3 group"> <Link href="/" className="group flex items-center gap-3">
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#1B2CC1] to-[#121E85] flex items-center justify-center text-white shadow-lg shadow-[#1B2CC1]/25 transition-all duration-300 group-hover:scale-105 group-hover:shadow-[#1B2CC1]/40"> <div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-[#1B2CC1] to-[#121E85] text-white shadow-lg shadow-[#1B2CC1]/25 transition-all duration-300 group-hover:scale-105 group-hover:shadow-[#1B2CC1]/40">
<Sparkles className="w-5 h-5" /> <Sparkles className="h-5 w-5" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-black text-xl tracking-tight text-slate-900 dark:text-white leading-none"> <span className="text-xl leading-none font-black tracking-tight text-slate-900 dark:text-white">
TitipIn TitipIn
</span> </span>
<span className="text-[9px] font-extrabold uppercase px-1.5 py-0.5 rounded-md bg-[#1B2CC1]/10 text-[#1B2CC1] dark:bg-blue-900/40 dark:text-blue-300"> <span className="rounded-md bg-[#1B2CC1]/10 px-1.5 py-0.5 text-[9px] font-extrabold text-[#1B2CC1] uppercase dark:bg-blue-900/40 dark:text-blue-300">
Pro Pro
</span> </span>
</div> </div>
<span className="text-[11px] font-medium text-slate-400 mt-1">Sistem Titip Pesanan</span> <span className="mt-1 text-[11px] font-medium text-slate-400">
Sistem Titip Pesanan
</span>
</div> </div>
</Link> </Link>
{onClose && ( {onClose && (
<button onClick={onClose} className="lg:hidden p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100"> <button
<X className="w-5 h-5" /> onClick={onClose}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600 lg:hidden"
>
<X className="h-5 w-5" />
</button> </button>
)} )}
</div> </div>
{/* User Profile Card */} {/* User Profile Card */}
<Link <Link
href="/profile" href="/profile"
onClick={onClose} onClick={onClose}
className="flex items-center gap-3 p-3 rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40 hover:bg-slate-100/80 dark:hover:bg-slate-800 transition-all duration-200 group shadow-xs" className="group flex items-center gap-3 rounded-2xl border border-slate-200/90 bg-slate-50/70 p-3 shadow-xs transition-all duration-200 hover:bg-slate-100/80 dark:border-slate-800 dark:bg-slate-800/40 dark:hover:bg-slate-800"
> >
<div className="relative"> <div className="relative">
{userPhoto ? ( {userPhoto ? (
<img src={userPhoto} alt={userName} className="w-10 h-10 rounded-xl object-cover ring-2 ring-white dark:ring-slate-700 shadow-sm" /> <img
src={userPhoto}
alt={userName}
className="h-10 w-10 rounded-xl object-cover shadow-sm ring-2 ring-white dark:ring-slate-700"
/>
) : ( ) : (
<div className="w-10 h-10 rounded-xl bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center font-black text-sm ring-2 ring-white dark:ring-slate-700 shadow-xs"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#1B2CC1]/10 text-sm font-black text-[#1B2CC1] shadow-xs ring-2 ring-white dark:ring-slate-700">
{userName.charAt(0).toUpperCase()} {userName.charAt(0).toUpperCase()}
</div> </div>
)} )}
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-emerald-500 rounded-full ring-2 ring-white dark:ring-slate-900 shadow-xs" /> <span className="absolute -right-0.5 -bottom-0.5 h-3 w-3 rounded-full bg-emerald-500 shadow-xs ring-2 ring-white dark:ring-slate-900" />
</div> </div>
<div className="flex flex-col min-w-0 flex-1"> <div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-800 dark:text-slate-200 truncate group-hover:text-[#1B2CC1] transition-colors"> <span className="truncate text-xs font-bold text-slate-800 transition-colors group-hover:text-[#1B2CC1] dark:text-slate-200">
{userName} {userName}
</span> </span>
<ArrowUpRight className="w-3.5 h-3.5 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" /> <ArrowUpRight className="h-3.5 w-3.5 text-slate-400 opacity-0 transition-opacity group-hover:opacity-100" />
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5"> <div className="mt-0.5 flex items-center gap-1.5">
{userRole === 'superadmin' && ( {userRole === 'superadmin' && (
<span className="px-1.5 py-[2px] rounded-md bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 text-[8px] font-black uppercase tracking-wider"> <span className="rounded-md bg-purple-100 px-1.5 py-[2px] text-[8px] font-black tracking-wider text-purple-700 uppercase dark:bg-purple-900/30 dark:text-purple-400">
Admin Admin
</span> </span>
)} )}
<span className="text-[10px] font-medium text-slate-500 dark:text-slate-400 truncate"> <span className="truncate text-[10px] font-medium text-slate-500 dark:text-slate-400">
{userNameAccount ? `@${userNameAccount}` : 'Aktif'} {userNameAccount ? `@${userNameAccount}` : 'Aktif'}
</span> </span>
</div> </div>
@@ -145,7 +162,7 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
{/* Main Navigation */} {/* Main Navigation */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="px-3 pb-1.5 text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500"> <div className="px-3 pb-1.5 text-[10px] font-black tracking-widest text-slate-400 uppercase dark:text-slate-500">
Navigasi Utama Navigasi Utama
</div> </div>
<nav className="space-y-1"> <nav className="space-y-1">
@@ -158,26 +175,30 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
href={item.href} href={item.href}
onClick={onClose} onClick={onClose}
className={cn( className={cn(
"flex items-center gap-3 px-3.5 py-3 rounded-2xl text-sm font-semibold transition-all duration-200 group relative", 'group relative flex items-center gap-3 rounded-2xl px-3.5 py-3 text-sm font-semibold transition-all duration-200',
isActive isActive
? "bg-[#1B2CC1] text-white shadow-md shadow-[#1B2CC1]/25 font-bold" ? 'bg-[#1B2CC1] font-bold text-white shadow-md shadow-[#1B2CC1]/25'
: "text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100/80 dark:hover:bg-slate-800/60" : 'text-slate-600 hover:bg-slate-100/80 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800/60 dark:hover:text-white'
)} )}
> >
<div className={cn( <div
"w-8 h-8 rounded-xl flex items-center justify-center transition-colors", className={cn(
isActive 'flex h-8 w-8 items-center justify-center rounded-xl transition-colors',
? "bg-white/15 text-white" isActive
: "bg-slate-100 dark:bg-slate-800 text-slate-500 group-hover:text-[#1B2CC1] group-hover:bg-[#1B2CC1]/10" ? 'bg-white/15 text-white'
)}> : 'bg-slate-100 text-slate-500 group-hover:bg-[#1B2CC1]/10 group-hover:text-[#1B2CC1] dark:bg-slate-800'
<Icon className="w-4 h-4" /> )}
>
<Icon className="h-4 w-4" />
</div> </div>
<div className="flex flex-col min-w-0"> <div className="flex min-w-0 flex-col">
<span className="leading-tight">{item.name}</span> <span className="leading-tight">{item.name}</span>
<span className={cn( <span
"text-[10px] font-normal truncate mt-0.5", className={cn(
isActive ? "text-blue-100" : "text-slate-400" 'mt-0.5 truncate text-[10px] font-normal',
)}> isActive ? 'text-blue-100' : 'text-slate-400'
)}
>
{item.desc} {item.desc}
</span> </span>
</div> </div>
@@ -190,31 +211,46 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
<> <>
<button <button
onClick={() => setIsLogoutOpen(true)} onClick={() => setIsLogoutOpen(true)}
className="w-full flex items-center gap-3 px-3.5 py-3 rounded-2xl text-sm font-semibold transition-all duration-200 group relative text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 cursor-pointer" className="group relative flex w-full cursor-pointer items-center gap-3 rounded-2xl px-3.5 py-3 text-sm font-semibold text-rose-600 transition-all duration-200 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950/30"
> >
<div className="w-8 h-8 rounded-xl flex items-center justify-center transition-colors bg-rose-100 dark:bg-rose-900/40 text-rose-500 group-hover:bg-rose-200 dark:group-hover:bg-rose-800/60"> <div className="flex h-8 w-8 items-center justify-center rounded-xl bg-rose-100 text-rose-500 transition-colors group-hover:bg-rose-200 dark:bg-rose-900/40 dark:group-hover:bg-rose-800/60">
<X className="w-4 h-4" /> <X className="h-4 w-4" />
</div> </div>
<div className="flex flex-col min-w-0 text-left"> <div className="flex min-w-0 flex-col text-left">
<span className="leading-tight">Logout</span> <span className="leading-tight">Logout</span>
<span className="text-[10px] font-normal truncate mt-0.5 text-rose-400/80">Keluar dari akun</span> <span className="mt-0.5 truncate text-[10px] font-normal text-rose-400/80">
Keluar dari akun
</span>
</div> </div>
</button> </button>
<Dialog open={isLogoutOpen} onOpenChange={setIsLogoutOpen}> <Dialog open={isLogoutOpen} onOpenChange={setIsLogoutOpen}>
<DialogContent className="sm:max-w-xs rounded-3xl p-0 overflow-hidden border-0"> <DialogContent className="overflow-hidden rounded-3xl border-0 p-0 sm:max-w-xs">
<div className="px-6 pt-6 pb-2 text-center"> <div className="px-6 pt-6 pb-2 text-center">
<div className="w-12 h-12 rounded-full bg-rose-100 text-rose-600 flex items-center justify-center mx-auto mb-3"> <div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-rose-100 text-rose-600">
<X className="w-6 h-6" /> <X className="h-6 w-6" />
</div> </div>
<DialogTitle className="text-xl font-black text-slate-800">Konfirmasi Logout</DialogTitle> <DialogTitle className="text-xl font-black text-slate-800">
<p className="text-xs text-slate-500 mt-2"> Konfirmasi Logout
</DialogTitle>
<p className="mt-2 text-xs text-slate-500">
Apakah Anda yakin ingin keluar dari akun ini? Apakah Anda yakin ingin keluar dari akun ini?
</p> </p>
</div> </div>
<div className="p-4 flex gap-3 bg-slate-50"> <div className="flex gap-3 bg-slate-50 p-4">
<Button type="button" variant="outline" onClick={() => setIsLogoutOpen(false)} className="flex-1 h-11 rounded-xl">Batal</Button> <Button
<Button type="button" onClick={handleLogout} className="flex-1 h-11 rounded-xl bg-rose-600 hover:bg-rose-700 text-white font-bold cursor-pointer"> type="button"
variant="outline"
onClick={() => setIsLogoutOpen(false)}
className="h-11 flex-1 rounded-xl"
>
Batal
</Button>
<Button
type="button"
onClick={handleLogout}
className="h-11 flex-1 cursor-pointer rounded-xl bg-rose-600 font-bold text-white hover:bg-rose-700"
>
Ya, Logout Ya, Logout
</Button> </Button>
</div> </div>
@@ -239,8 +275,8 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
</p> </p>
</div> */} </div> */}
{/* Copyright */} {/* Copyright */}
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800 text-center"> <div className="mt-4 border-t border-slate-100 pt-4 text-center dark:border-slate-800">
<p className="text-[10px] text-slate-400 font-medium"> <p className="text-[10px] font-medium text-slate-400">
&copy; {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan &copy; {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan
</p> </p>
</div> </div>
+38 -25
View File
@@ -14,11 +14,14 @@ export function SummaryGenerator({ order }: { order: any }) {
const itemCounts: Record<string, number> = {} const itemCounts: Record<string, number> = {}
order.submissions?.forEach((sub: any) => { order.submissions?.forEach((sub: any) => {
const itemStrings = sub.items.map((i: any) => `${i.name} ${i.qty}x`) const itemStrings = sub.items.map(
(i: any) => `${i.name} ${i.qty}x${i.note ? ` (${i.note})` : ''}`
)
summaryByPerson += `- ${sub.user?.name || 'Unknown'} : ${itemStrings.join(', ')}\n` summaryByPerson += `- ${sub.user?.name || 'Unknown'} : ${itemStrings.join(', ')}\n`
sub.items.forEach((i: any) => { sub.items.forEach((i: any) => {
itemCounts[i.name] = (itemCounts[i.name] || 0) + i.qty const key = i.note ? `${i.name} (${i.note})` : i.name
itemCounts[key] = (itemCounts[key] || 0) + i.qty
}) })
}) })
@@ -42,54 +45,64 @@ export function SummaryGenerator({ order }: { order: any }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="px-1"> <div className="px-1">
<h3 className="text-lg font-black text-slate-900 dark:text-white flex items-center gap-2"> <h3 className="flex items-center gap-2 text-lg font-black text-slate-900 dark:text-white">
<Receipt className="w-5 h-5 text-[#1B2CC1]" /> <Receipt className="h-5 w-5 text-[#1B2CC1]" />
<span>Generator Rekap</span> <span>Generator Rekap</span>
</h3> </h3>
<p className="text-xs text-slate-500 mt-0.5">Salin format teks siap kirim ke WhatsApp / grup.</p> <p className="mt-0.5 text-xs text-slate-500">
Salin format teks siap kirim ke WhatsApp / grup.
</p>
</div> </div>
{/* Rekap Per Orang */} {/* Rekap Per Orang */}
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden"> <Card className="overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="p-4 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center bg-slate-50/50 dark:bg-slate-800/40"> <div className="flex items-center justify-between border-b border-slate-100 bg-slate-50/50 p-4 dark:border-slate-800 dark:bg-slate-800/40">
<span className="text-xs font-bold text-slate-800 dark:text-slate-200"> <span className="text-xs font-bold text-slate-800 dark:text-slate-200">
Rekap per Orang Rekap per Orang
</span> </span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleCopyPerson} onClick={handleCopyPerson}
className="h-8 text-xs font-bold rounded-lg border-slate-200 gap-1.5 hover:bg-[#1B2CC1] hover:text-white transition-all" className="h-8 gap-1.5 rounded-lg border-slate-200 text-xs font-bold transition-all hover:bg-[#1B2CC1] hover:text-white"
> >
{copiedPerson ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />} {copiedPerson ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copiedPerson ? 'Tersalin!' : 'Copy Text'}</span> <span>{copiedPerson ? 'Tersalin!' : 'Copy Text'}</span>
</Button> </Button>
</div> </div>
<CardContent className="p-4"> <CardContent className="p-4">
<pre className="text-xs font-mono bg-slate-50 dark:bg-slate-800/80 p-3.5 rounded-xl overflow-x-auto whitespace-pre-wrap border border-slate-200/80 dark:border-slate-700 text-slate-700 dark:text-slate-300 leading-relaxed"> <pre className="overflow-x-auto rounded-xl border border-slate-200/80 bg-slate-50 p-3.5 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300">
{summaryByPerson} {summaryByPerson}
</pre> </pre>
</CardContent> </CardContent>
</Card> </Card>
{/* Rekap Per Item */} {/* Rekap Per Item */}
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden"> <Card className="overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="p-4 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center bg-slate-50/50 dark:bg-slate-800/40"> <div className="flex items-center justify-between border-b border-slate-100 bg-slate-50/50 p-4 dark:border-slate-800 dark:bg-slate-800/40">
<span className="text-xs font-bold text-slate-800 dark:text-slate-200"> <span className="text-xs font-bold text-slate-800 dark:text-slate-200">
Rekap per Item (Akumulasi) Rekap per Item (Akumulasi)
</span> </span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleCopyItem} onClick={handleCopyItem}
className="h-8 text-xs font-bold rounded-lg border-slate-200 gap-1.5 hover:bg-[#1B2CC1] hover:text-white transition-all" className="h-8 gap-1.5 rounded-lg border-slate-200 text-xs font-bold transition-all hover:bg-[#1B2CC1] hover:text-white"
> >
{copiedItem ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />} {copiedItem ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copiedItem ? 'Tersalin!' : 'Copy Text'}</span> <span>{copiedItem ? 'Tersalin!' : 'Copy Text'}</span>
</Button> </Button>
</div> </div>
<CardContent className="p-4"> <CardContent className="p-4">
<pre className="text-xs font-mono bg-slate-50 dark:bg-slate-800/80 p-3.5 rounded-xl overflow-x-auto whitespace-pre-wrap border border-slate-200/80 dark:border-slate-700 text-slate-700 dark:text-slate-300 leading-relaxed"> <pre className="overflow-x-auto rounded-xl border border-slate-200/80 bg-slate-50 p-3.5 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300">
{summaryByItem} {summaryByItem}
</pre> </pre>
</CardContent> </CardContent>
+17 -20
View File
@@ -1,41 +1,38 @@
import { mergeProps } from "@base-ui/react/merge-props" import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from "@base-ui/react/use-render" import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const badgeVariants = cva( const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
outline: outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
ghost: link: 'text-primary underline-offset-4 hover:underline',
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} }
) )
function Badge({ function Badge({
className, className,
variant = "default", variant = 'default',
render, render,
...props ...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) { }: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
return useRender({ return useRender({
defaultTagName: "span", defaultTagName: 'span',
props: mergeProps<"span">( props: mergeProps<'span'>(
{ {
className: cn(badgeVariants({ variant }), className), className: cn(badgeVariants({ variant }), className),
}, },
@@ -43,7 +40,7 @@ function Badge({
), ),
render, render,
state: { state: {
slot: "badge", slot: 'badge',
variant, variant,
}, },
}) })
+20 -20
View File
@@ -1,49 +1,49 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button" import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
"cursor-pointer group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "cursor-pointer group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80", default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive: destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: "text-primary underline-offset-4 hover:underline", link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
default: default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: "size-8", icon: 'size-8',
"icon-xs": 'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": 'icon-sm':
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
"icon-lg": "size-9", 'icon-lg': 'size-9',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} }
) )
function Button({ function Button({
className, className,
variant = "default", variant = 'default',
size = "default", size = 'default',
...props ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return ( return (
+49 -85
View File
@@ -1,29 +1,24 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import { import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from 'react-day-picker'
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from '@/components/ui/button'
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react" import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from 'lucide-react'
function Calendar({ function Calendar({
className, className,
classNames, classNames,
showOutsideDays = true, showOutsideDays = true,
captionLayout = "label", captionLayout = 'label',
buttonVariant = "ghost", buttonVariant = 'ghost',
locale, locale,
formatters, formatters,
components, components,
...props ...props
}: React.ComponentProps<typeof DayPicker> & { }: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"] buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) { }) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames()
@@ -31,7 +26,7 @@ function Calendar({
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
className={cn( className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent", 'group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className className
@@ -39,131 +34,100 @@ function Calendar({
captionLayout={captionLayout} captionLayout={captionLayout}
locale={locale} locale={locale}
formatters={{ formatters={{
formatMonthDropdown: (date) => formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: 'short' }),
date.toLocaleString(locale?.code, { month: "short" }),
...formatters, ...formatters,
}} }}
classNames={{ classNames={{
root: cn("w-fit", defaultClassNames.root), root: cn('w-fit', defaultClassNames.root),
months: cn( months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
"relative flex flex-col gap-4 md:flex-row", month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn( nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", 'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
defaultClassNames.nav defaultClassNames.nav
), ),
button_previous: cn( button_previous: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50", 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_previous defaultClassNames.button_previous
), ),
button_next: cn( button_next: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50", 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_next defaultClassNames.button_next
), ),
month_caption: cn( month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption defaultClassNames.month_caption
), ),
dropdowns: cn( dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", 'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns defaultClassNames.dropdowns
), ),
dropdown_root: cn( dropdown_root: cn('relative rounded-(--cell-radius)', defaultClassNames.dropdown_root),
"relative rounded-(--cell-radius)", dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn( caption_label: cn(
"font-medium select-none", 'font-medium select-none',
captionLayout === "label" captionLayout === 'label'
? "text-sm" ? 'text-sm'
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", : 'flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label defaultClassNames.caption_label
), ),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid), month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", 'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
defaultClassNames.weekday defaultClassNames.weekday
), ),
week: cn("mt-2 flex w-full", defaultClassNames.week), week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn( week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn( week_number: cn(
"text-[0.8rem] text-muted-foreground select-none", 'text-[0.8rem] text-muted-foreground select-none',
defaultClassNames.week_number defaultClassNames.week_number
), ),
day: cn( day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", 'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
props.showWeekNumber props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", : '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
defaultClassNames.day defaultClassNames.day
), ),
range_start: cn( range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", 'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
defaultClassNames.range_start defaultClassNames.range_start
), ),
range_middle: cn("rounded-none", defaultClassNames.range_middle), range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn( range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", 'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
defaultClassNames.range_end defaultClassNames.range_end
), ),
today: cn( today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none", 'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
defaultClassNames.today defaultClassNames.today
), ),
outside: cn( outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground", 'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside defaultClassNames.outside
), ),
disabled: cn( disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
"text-muted-foreground opacity-50", hidden: cn('invisible', defaultClassNames.hidden),
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames, ...classNames,
}} }}
components={{ components={{
Root: ({ className, rootRef, ...props }) => { Root: ({ className, rootRef, ...props }) => {
return ( return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
}, },
Chevron: ({ className, orientation, ...props }) => { Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") { if (orientation === 'left') {
return ( return <ChevronLeftIcon className={cn('size-4', className)} {...props} />
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
} }
if (orientation === "right") { if (orientation === 'right') {
return ( return <ChevronRightIcon className={cn('size-4', className)} {...props} />
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
} }
return ( return <ChevronDownIcon className={cn('size-4', className)} {...props} />
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
}, },
DayButton: ({ ...props }) => ( DayButton: ({ ...props }) => <CalendarDayButton locale={locale} {...props} />,
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => { WeekNumber: ({ children, ...props }) => {
return ( return (
<td {...props}> <td {...props}>
@@ -209,7 +173,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end} data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle} data-range-middle={modifiers.range_middle}
className={cn( className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70", 'group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day, defaultClassNames.day,
className className
)} )}
+18 -33
View File
@@ -1,18 +1,18 @@
import * as React from "react" import * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Card({ function Card({
className, className,
size = "default", size = 'default',
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { }: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
return ( return (
<div <div
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", 'group/card bg-card text-card-foreground ring-foreground/10 flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl py-(--card-spacing) text-sm ring-1 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
className className
)} )}
{...props} {...props}
@@ -20,12 +20,12 @@ function Card({
) )
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)", 'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)',
className className
)} )}
{...props} {...props}
@@ -33,12 +33,12 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
) )
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-title" data-slot="card-title"
className={cn( className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", 'font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
className className
)} )}
{...props} {...props}
@@ -46,45 +46,38 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
) )
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) )
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} {...props}
/> />
) )
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div data-slot="card-content" className={cn('px-(--card-spacing)', className)} {...props} />
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
) )
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn( className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)", 'bg-muted/50 flex items-center rounded-b-xl border-t p-(--card-spacing)',
className className
)} )}
{...props} {...props}
@@ -92,12 +85,4 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
) )
} }
export { export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+6 -7
View File
@@ -1,16 +1,16 @@
"use client" 'use client'
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
import { CheckIcon } from "lucide-react" import { CheckIcon } from 'lucide-react'
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary", 'peer border-input group-has-[:focus-visible]/field-label:not-data-checked:border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3',
className className
)} )}
{...props} {...props}
@@ -19,8 +19,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5" className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
> >
<CheckIcon <CheckIcon />
/>
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) )
+21 -35
View File
@@ -1,11 +1,11 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
import { Button } from "@/components/ui/button" import { Button } from '@/components/ui/button'
import { XIcon } from "lucide-react" import { XIcon } from 'lucide-react'
function Dialog({ ...props }: DialogPrimitive.Root.Props) { function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />
@@ -23,15 +23,12 @@ function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
} }
function DialogOverlay({ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return ( return (
<DialogPrimitive.Backdrop <DialogPrimitive.Backdrop
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", 'data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs',
className className
)} )}
{...props} {...props}
@@ -55,7 +52,7 @@ function DialogContent({
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", 'bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-sm',
className className
)} )}
{...props} {...props}
@@ -68,14 +65,15 @@ function DialogContent({
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(
"absolute top-3 right-3 rounded-full transition-all z-10", 'absolute top-3 right-3 z-10 rounded-full transition-all',
closeClassName || "text-slate-500 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-100 dark:hover:bg-slate-800" closeClassName ||
'text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100'
)} )}
size="icon-sm" size="icon-sm"
/> />
} }
> >
<XIcon className="w-4 h-4" /> <XIcon className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
@@ -84,13 +82,9 @@ function DialogContent({
) )
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div data-slot="dialog-header" className={cn('flex flex-col gap-2', className)} {...props} />
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
) )
} }
@@ -99,23 +93,21 @@ function DialogFooter({
showCloseButton = false, showCloseButton = false,
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<'div'> & {
showCloseButton?: boolean showCloseButton?: boolean
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", 'bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 sm:flex-row sm:justify-end',
className className
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}> <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
Close
</DialogPrimitive.Close>
)} )}
</div> </div>
) )
@@ -125,24 +117,18 @@ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn( className={cn('font-heading text-base leading-none font-medium', className)}
"font-heading text-base leading-none font-medium",
className
)}
{...props} {...props}
/> />
) )
} }
function DialogDescription({ function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
className,
...props
}: DialogPrimitive.Description.Props) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn( className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", 'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3',
className className
)} )}
{...props} {...props}
+5 -5
View File
@@ -1,15 +1,15 @@
import * as React from "react" import * as React from 'react'
import { Input as InputPrimitive } from "@base-ui/react/input" import { Input as InputPrimitive } from '@base-ui/react/input'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return ( return (
<InputPrimitive <InputPrimitive
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", 'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm',
className className
)} )}
{...props} {...props}
+5 -5
View File
@@ -1,15 +1,15 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Label({ className, ...props }: React.ComponentProps<"label">) { function Label({ className, ...props }: React.ComponentProps<'label'>) {
return ( return (
<label <label
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className className
)} )}
{...props} {...props}
+14 -27
View File
@@ -1,9 +1,9 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import { Popover as PopoverPrimitive } from "@base-ui/react/popover" import { Popover as PopoverPrimitive } from '@base-ui/react/popover'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Popover({ ...props }: PopoverPrimitive.Root.Props) { function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />
@@ -15,16 +15,13 @@ function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
function PopoverContent({ function PopoverContent({
className, className,
align = "center", align = 'center',
alignOffset = 0, alignOffset = 0,
side = "bottom", side = 'bottom',
sideOffset = 4, sideOffset = 4,
...props ...props
}: PopoverPrimitive.Popup.Props & }: PopoverPrimitive.Popup.Props &
Pick< Pick<PopoverPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return ( return (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner <PopoverPrimitive.Positioner
@@ -37,7 +34,7 @@ function PopoverContent({
<PopoverPrimitive.Popup <PopoverPrimitive.Popup
data-slot="popover-content" data-slot="popover-content"
className={cn( className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", 'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 outline-hidden duration-100',
className className
)} )}
{...props} {...props}
@@ -47,11 +44,11 @@ function PopoverContent({
) )
} }
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="popover-header" data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)} className={cn('flex flex-col gap-0.5 text-sm', className)}
{...props} {...props}
/> />
) )
@@ -61,30 +58,20 @@ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return ( return (
<PopoverPrimitive.Title <PopoverPrimitive.Title
data-slot="popover-title" data-slot="popover-title"
className={cn("font-medium", className)} className={cn('font-medium', className)}
{...props} {...props}
/> />
) )
} }
function PopoverDescription({ function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
className,
...props
}: PopoverPrimitive.Description.Props) {
return ( return (
<PopoverPrimitive.Description <PopoverPrimitive.Description
data-slot="popover-description" data-slot="popover-description"
className={cn("text-muted-foreground", className)} className={cn('text-muted-foreground', className)}
{...props} {...props}
/> />
) )
} }
export { export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger }
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+28 -39
View File
@@ -1,10 +1,10 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import { Select as SelectPrimitive } from "@base-ui/react/select" import { Select as SelectPrimitive } from '@base-ui/react/select'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react'
const Select = SelectPrimitive.Root const Select = SelectPrimitive.Root
@@ -12,7 +12,7 @@ function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return ( return (
<SelectPrimitive.Group <SelectPrimitive.Group
data-slot="select-group" data-slot="select-group"
className={cn("scroll-my-1 p-1", className)} className={cn('scroll-my-1 p-1', className)}
{...props} {...props}
/> />
) )
@@ -22,7 +22,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return ( return (
<SelectPrimitive.Value <SelectPrimitive.Value
data-slot="select-value" data-slot="select-value"
className={cn("flex flex-1 text-left", className)} className={cn('flex flex-1 text-left', className)}
{...props} {...props}
/> />
) )
@@ -30,27 +30,25 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
function SelectTrigger({ function SelectTrigger({
className, className,
size = "default", size = 'default',
children, children,
...props ...props
}: SelectPrimitive.Trigger.Props & { }: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default" size?: 'sm' | 'default'
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex w-fit items-center justify-between gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon <SelectPrimitive.Icon
render={ render={<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4" />}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/> />
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) )
@@ -59,16 +57,16 @@ function SelectTrigger({
function SelectContent({ function SelectContent({
className, className,
children, children,
side = "bottom", side = 'bottom',
sideOffset = 4, sideOffset = 4,
align = "center", align = 'center',
alignOffset = 0, alignOffset = 0,
alignItemWithTrigger = true, alignItemWithTrigger = true,
...props ...props
}: SelectPrimitive.Popup.Props & }: SelectPrimitive.Popup.Props &
Pick< Pick<
SelectPrimitive.Positioner.Props, SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" 'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
>) { >) {
return ( return (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
@@ -83,7 +81,10 @@ function SelectContent({
<SelectPrimitive.Popup <SelectPrimitive.Popup
data-slot="select-content" data-slot="select-content"
data-align-trigger={alignItemWithTrigger} data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} className={cn(
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none',
className
)}
{...props} {...props}
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
@@ -95,29 +96,22 @@ function SelectContent({
) )
} }
function SelectLabel({ function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return ( return (
<SelectPrimitive.GroupLabel <SelectPrimitive.GroupLabel
data-slot="select-label" data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)} className={cn('text-muted-foreground px-1.5 py-1 text-xs', className)}
{...props} {...props}
/> />
) )
} }
function SelectItem({ function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className
)} )}
{...props} {...props}
@@ -136,14 +130,11 @@ function SelectItem({
) )
} }
function SelectSeparator({ function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
className,
...props
}: SelectPrimitive.Separator.Props) {
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)} className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
) )
@@ -157,13 +148,12 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpArrow <SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4", "bg-popover top-0 z-10 flex w-full cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
> >
<ChevronUpIcon <ChevronUpIcon />
/>
</SelectPrimitive.ScrollUpArrow> </SelectPrimitive.ScrollUpArrow>
) )
} }
@@ -176,13 +166,12 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownArrow <SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4", "bg-popover bottom-0 z-10 flex w-full cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
> >
<ChevronDownIcon <ChevronDownIcon />
/>
</SelectPrimitive.ScrollDownArrow> </SelectPrimitive.ScrollDownArrow>
) )
} }
+29 -30
View File
@@ -1,48 +1,47 @@
"use client" 'use client'
import { useTheme } from "next-themes" import { useTheme } from 'next-themes'
import { Toaster as Sonner, type ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from 'sonner'
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from 'lucide-react'
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = 'system' } = useTheme()
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps['theme']}
className="toaster group" className="toaster group"
icons={{ icons={{
success: ( success: <CircleCheckIcon className="size-4" />,
<CircleCheckIcon className="size-4" /> info: <InfoIcon className="size-4" />,
), warning: <TriangleAlertIcon className="size-4" />,
info: ( error: <OctagonXIcon className="size-4" />,
<InfoIcon className="size-4" /> loading: <Loader2Icon className="size-4 animate-spin" />,
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}} }}
style={ style={
{ {
"--normal-bg": "var(--popover)", '--normal-bg': 'var(--popover)',
"--normal-text": "var(--popover-foreground)", '--normal-text': 'var(--popover-foreground)',
"--normal-border": "var(--border)", '--normal-border': 'var(--border)',
"--border-radius": "var(--radius)", '--border-radius': 'var(--radius)',
} as React.CSSProperties } as React.CSSProperties
} }
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: "cn-toast group-[.toaster]:bg-white dark:group-[.toaster]:bg-slate-950 group-[.toaster]:text-slate-950 dark:group-[.toaster]:text-slate-50 group-[.toaster]:border-slate-200 dark:group-[.toaster]:border-slate-800", toast:
title: "font-bold", 'cn-toast group-[.toaster]:bg-white dark:group-[.toaster]:bg-slate-950 group-[.toaster]:text-slate-950 dark:group-[.toaster]:text-slate-50 group-[.toaster]:border-slate-200 dark:group-[.toaster]:border-slate-800',
description: "!text-slate-700 dark:!text-slate-300", title: 'font-bold',
success: "group-[.toaster]:border-emerald-500 group-[.toaster]:bg-emerald-50 dark:group-[.toaster]:bg-emerald-950/50 group-[.toaster]:text-emerald-900 dark:group-[.toaster]:text-emerald-100 [&_svg]:text-emerald-600 dark:[&_svg]:text-emerald-400 [&_[data-description]]:!text-emerald-700 dark:[&_[data-description]]:!text-emerald-300", description: '!text-slate-700 dark:!text-slate-300',
error: "group-[.toaster]:border-red-500 group-[.toaster]:bg-red-50 dark:group-[.toaster]:bg-red-950/50 group-[.toaster]:text-red-900 dark:group-[.toaster]:text-red-100 [&_svg]:text-red-600 dark:[&_svg]:text-red-400 [&_[data-description]]:!text-red-700 dark:[&_[data-description]]:!text-red-300", success:
'group-[.toaster]:border-emerald-500 group-[.toaster]:bg-emerald-50 dark:group-[.toaster]:bg-emerald-950/50 group-[.toaster]:text-emerald-900 dark:group-[.toaster]:text-emerald-100 [&_svg]:text-emerald-600 dark:[&_svg]:text-emerald-400 [&_[data-description]]:!text-emerald-700 dark:[&_[data-description]]:!text-emerald-300',
error:
'group-[.toaster]:border-red-500 group-[.toaster]:bg-red-50 dark:group-[.toaster]:bg-red-950/50 group-[.toaster]:text-red-900 dark:group-[.toaster]:text-red-100 [&_svg]:text-red-600 dark:[&_svg]:text-red-400 [&_[data-description]]:!text-red-700 dark:[&_[data-description]]:!text-red-300',
}, },
}} }}
{...props} {...props}
+21 -48
View File
@@ -1,63 +1,51 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<'table'>) {
return ( return (
<div <div data-slot="table-container" className="relative w-full overflow-x-auto">
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table <table
data-slot="table" data-slot="table"
className={cn("w-full caption-bottom text-sm", className)} className={cn('w-full caption-bottom text-sm', className)}
{...props} {...props}
/> />
</div> </div>
) )
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return ( return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return ( return (
<tbody <tbody
data-slot="table-body" data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)} className={cn('[&_tr:last-child]:border-0', className)}
{...props} {...props}
/> />
) )
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return ( return (
<tfoot <tfoot
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props} {...props}
/> />
) )
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return ( return (
<tr <tr
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted", 'hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className className
)} )}
{...props} {...props}
@@ -65,12 +53,12 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
) )
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return ( return (
<th <th
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0", 'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0',
className className
)} )}
{...props} {...props}
@@ -78,39 +66,24 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
) )
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return ( return (
<td <td
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', className)}
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props} {...props}
/> />
) )
} }
function TableCaption({ function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
className,
...props
}: React.ComponentProps<"caption">) {
return ( return (
<caption <caption
data-slot="table-caption" data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props} {...props}
/> />
) )
} }
export { export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+4 -4
View File
@@ -1,12 +1,12 @@
import * as React from "react" import * as React from 'react'
import { cn } from "cn" import { cn } from 'cn'
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return ( return (
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", 'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex field-sizing-content min-h-16 w-full rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm',
className className
)} )}
{...props} {...props}
+1 -4
View File
@@ -14,10 +14,7 @@ export async function verifyPassword(password: string, hash: string) {
} }
export async function encrypt(payload: any) { export async function encrypt(payload: any) {
return await new SignJWT(payload) return await new SignJWT(payload).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().sign(key) // No expiration as requested
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.sign(key) // No expiration as requested
} }
export async function decrypt(input: string): Promise<any> { export async function decrypt(input: string): Promise<any> {
+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 }
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ const prismaClientSingleton = () => {
} }
declare const globalThis: { declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>; prismaGlobal: ReturnType<typeof prismaClientSingleton>
} & typeof global; } & typeof global
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton() const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
+2 -2
View File
@@ -1,5 +1,5 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from 'clsx'
import { twMerge } from "tailwind-merge" import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
+1 -1
View File
@@ -7,7 +7,7 @@ const publicRoutes = ['/login', '/register']
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname const path = request.nextUrl.pathname
// Also protect dynamic routes like /my-orders/[id] // Also protect dynamic routes like /my-orders/[id]
const isProtectedRoute = protectedRoutes.includes(path) || path.startsWith('/my-orders/') const isProtectedRoute = protectedRoutes.includes(path) || path.startsWith('/my-orders/')
const isPublicRoute = publicRoutes.includes(path) const isPublicRoute = publicRoutes.includes(path)
+1264 -1671
View File
File diff suppressed because it is too large Load Diff