Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b614be79c1 | ||
|
|
2814e3cab8 | ||
|
|
7c3c5e2249 | ||
|
|
1337af0102 | ||
|
|
e57adc1646 | ||
|
|
51d05653c6 | ||
|
|
d5791e759e | ||
|
|
44466f1566 | ||
|
|
be343f375a | ||
|
|
d1ed8f803b | ||
|
|
f4358eb00b | ||
|
|
e3c8f980f7 |
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
public
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
||||||
@@ -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 -->
|
||||||
|
|||||||
Generated
+4574
-394
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -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",
|
||||||
@@ -17,10 +18,12 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cn": "^0.2.6",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"jose": "^6.2.10",
|
"jose": "^6.2.10",
|
||||||
"lucide-react": "^1.34.0",
|
"lucide-react": "^1.34.0",
|
||||||
"next": "16.3.3",
|
"next": "16.3.3",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
"react-hook-form": "^7.86.0",
|
"react-hook-form": "^7.86.0",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
"shadcn": "^4.19.0",
|
"shadcn": "^4.19.0",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"uuid": "^14.0.2",
|
"uuid": "^14.0.2",
|
||||||
@@ -42,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",
|
||||||
|
|||||||
+67
-34
@@ -1,6 +1,3 @@
|
|||||||
// This is your Prisma schema file,
|
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@@ -11,40 +8,45 @@ 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 {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
date DateTime
|
date DateTime
|
||||||
allow_custom Boolean @default(false)
|
allow_custom Boolean @default(false)
|
||||||
status String @default("DRAFT")
|
status String @default("DRAFT")
|
||||||
creator_id String
|
creator_id String
|
||||||
creator User @relation("CreatedOrders", fields: [creator_id], references: [id])
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
description String?
|
||||||
available_items AvailableItem[]
|
available_items AvailableItem[]
|
||||||
submissions Submission[]
|
creator User @relation("CreatedOrders", fields: [creator_id], references: [id])
|
||||||
created_at DateTime @default(now())
|
submissions Submission[]
|
||||||
updated_at DateTime @updatedAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model AvailableItem {
|
model AvailableItem {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
order_id String
|
order_id String
|
||||||
name String
|
name String
|
||||||
order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
|
created_at DateTime @default(now())
|
||||||
created_at DateTime @default(now())
|
updated_at DateTime @updatedAt
|
||||||
updated_at DateTime @updatedAt
|
is_sold_out Boolean @default(false)
|
||||||
|
order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Submission {
|
model Submission {
|
||||||
@@ -52,22 +54,53 @@ model Submission {
|
|||||||
order_id String
|
order_id String
|
||||||
user_id String
|
user_id String
|
||||||
bill Int?
|
bill Int?
|
||||||
paid_amount Int?
|
|
||||||
payment_status String @default("BELUM_BAYAR")
|
payment_status String @default("BELUM_BAYAR")
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
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[]
|
||||||
created_at DateTime @default(now())
|
|
||||||
updated_at DateTime @updatedAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model SubmissionItem {
|
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)
|
||||||
submission Submission @relation(fields: [submission_id], references: [id], onDelete: Cascade)
|
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
|
submission Submission @relation(fields: [submission_id], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Setting {
|
||||||
|
key String @id
|
||||||
|
value String
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model BalanceWithdrawal {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
creator_id String
|
||||||
|
user_id String
|
||||||
|
amount Int
|
||||||
|
note String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
creator User @relation("CreatorWithdrawals", fields: [creator_id], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation("UserWithdrawals", fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserNotificationConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
user_id String @unique
|
||||||
|
mattermost_channel_id String?
|
||||||
|
telegram_chat_id String?
|
||||||
|
whatsapp_number String?
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|||||||
+692
-81
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
+694
-459
File diff suppressed because it is too large
Load Diff
+486
-245
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>
|
||||||
<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>
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { redirect, notFound } from 'next/navigation'
|
|||||||
import { Store, User, Users, CheckCircle2, ChevronLeft, MapPin, Sparkles } from 'lucide-react'
|
import { Store, User, Users, CheckCircle2, ChevronLeft, MapPin, Sparkles } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { PublicOrderActions } from './PublicOrderActions'
|
import { PublicOrderActions } from './PublicOrderActions'
|
||||||
|
import { SummaryGenerator } from '@/components/SummaryGenerator'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { id as idLocale } from 'date-fns/locale'
|
import { id as idLocale } from 'date-fns/locale'
|
||||||
import { cn } from '@/lib/utils'
|
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()
|
||||||
|
|
||||||
@@ -24,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,77 +90,115 @@ 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>
|
||||||
|
|
||||||
{/* Daftar Penitip (Submissions) */}
|
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-3">
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-3xl border border-slate-200/60 dark:border-slate-800 shadow-sm overflow-hidden">
|
{/* Left 2 Cols: Daftar Penitip & Description */}
|
||||||
<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="space-y-6 lg:col-span-2">
|
||||||
<div className="flex items-center gap-2">
|
{order.description && (
|
||||||
<div className="w-8 h-8 rounded-xl bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center">
|
<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">
|
||||||
<Users className="w-4 h-4" />
|
<h3 className="mb-3 font-bold text-slate-900 dark:text-white">Deskripsi</h3>
|
||||||
|
<div className="text-sm leading-relaxed whitespace-pre-wrap text-slate-600 dark:text-slate-300">
|
||||||
|
{order.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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="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 h-8 w-8 items-center justify-center rounded-xl bg-[#1B2CC1]/10 text-[#1B2CC1]">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 dark:text-white">Daftar Penitip</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||||
|
{order.submissions.length > 0 ? (
|
||||||
|
order.submissions.map((sub: any) => (
|
||||||
|
<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 ? (
|
||||||
|
<img
|
||||||
|
src={sub.user.photo}
|
||||||
|
alt={sub.user.name}
|
||||||
|
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<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">
|
||||||
|
{sub.items.map((item: any) => (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium ${
|
||||||
|
item.is_custom
|
||||||
|
? 'border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||||
|
: '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.note && (
|
||||||
|
<span className="ml-0.5 italic opacity-70">({item.note})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<p className="font-medium text-slate-500">Belum ada yang menitip.</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
Jadilah yang pertama untuk menitip pesanan!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-slate-900 dark:text-white">Daftar Penitip</h3>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-slate-100 dark:divide-slate-800">
|
{/* Right 1 Col: Summary Generators */}
|
||||||
{order.submissions.length > 0 ? (
|
<SummaryGenerator order={order} />
|
||||||
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">
|
|
||||||
{sub.user.photo ? (
|
|
||||||
<img src={sub.user.photo} alt={sub.user.name} className="w-10 h-10 rounded-full object-cover shrink-0" />
|
|
||||||
) : (
|
|
||||||
<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">
|
|
||||||
{sub.user.name.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4 className="font-bold text-slate-800 dark:text-slate-200 text-sm mb-2">{sub.user.name}</h4>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{sub.items.map((item: any) => (
|
|
||||||
<span
|
|
||||||
key={item.id}
|
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium border ${
|
|
||||||
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'
|
|
||||||
: 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.qty}x {item.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="p-12 text-center">
|
|
||||||
<p className="text-slate-500 font-medium">Belum ada yang menitip.</p>
|
|
||||||
<p className="text-xs text-slate-400 mt-1">Jadilah yang pertama untuk menitip pesanan!</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+31
-27
@@ -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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
+266
-74
@@ -1,13 +1,35 @@
|
|||||||
'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)
|
||||||
@@ -15,6 +37,13 @@ export default function ProfilePage() {
|
|||||||
const [photo, setPhoto] = useState('')
|
const [photo, setPhoto] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Notification Config state
|
||||||
|
const [globalNotifEnabled, setGlobalNotifEnabled] = useState(false)
|
||||||
|
const [mmChannelId, setMmChannelId] = useState('')
|
||||||
|
const [mmActive, setMmActive] = useState(true)
|
||||||
|
const [savingNotif, setSavingNotif] = useState(false)
|
||||||
|
const [notifSuccess, setNotifSuccess] = useState(false)
|
||||||
|
|
||||||
// Profile state
|
// Profile state
|
||||||
const [savingProfile, setSavingProfile] = useState(false)
|
const [savingProfile, setSavingProfile] = useState(false)
|
||||||
const [profileError, setProfileError] = useState('')
|
const [profileError, setProfileError] = useState('')
|
||||||
@@ -40,6 +69,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)
|
||||||
}
|
}
|
||||||
@@ -55,14 +98,22 @@ export default function ProfilePage() {
|
|||||||
setProfileSuccess(false)
|
setProfileSuccess(false)
|
||||||
setSavingProfile(true)
|
setSavingProfile(true)
|
||||||
|
|
||||||
const res = await updateProfile(user.id, name.trim(), photo.trim() || null)
|
const [res, notifRes] = await Promise.all([
|
||||||
|
updateProfile(user.id, name.trim(), photo.trim() || null),
|
||||||
|
globalNotifEnabled
|
||||||
|
? updateUserNotificationConfig(user.id, {
|
||||||
|
mattermost_channel_id: mmChannelId,
|
||||||
|
is_active: mmActive,
|
||||||
|
})
|
||||||
|
: Promise.resolve({ success: true, error: null }),
|
||||||
|
])
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success && notifRes.success) {
|
||||||
setUser(res.user)
|
setUser(res.user)
|
||||||
setProfileSuccess(true)
|
setProfileSuccess(true)
|
||||||
setTimeout(() => setProfileSuccess(false), 3000)
|
setTimeout(() => setProfileSuccess(false), 3000)
|
||||||
} else {
|
} else {
|
||||||
setProfileError(res.error || 'Terjadi kesalahan')
|
setProfileError(res.error || notifRes.error || 'Terjadi kesalahan')
|
||||||
}
|
}
|
||||||
setSavingProfile(false)
|
setSavingProfile(false)
|
||||||
}
|
}
|
||||||
@@ -104,167 +155,308 @@ 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="mt-6 space-y-6 border-t border-slate-100 pt-6 dark:border-slate-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400">
|
||||||
|
<MessageCircle className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-slate-900 dark:text-white">
|
||||||
|
Notifikasi Mattermost
|
||||||
|
</h3>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500">
|
||||||
|
Terima update pesanan PO langsung di DM Mattermost Anda.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-start space-y-0 space-x-3 rounded-md border border-slate-200 p-4 dark:border-slate-800">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="mmActive"
|
||||||
|
checked={mmActive}
|
||||||
|
onChange={(e) => setMmActive(e.target.checked)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-slate-300 text-[#1B2CC1] focus:ring-[#1B2CC1]"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<Label htmlFor="mmActive" className="text-sm font-bold">
|
||||||
|
Aktifkan Notifikasi
|
||||||
|
</Label>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Kirim notifikasi setiap kali ada yang merubah pesanannya di PO Anda.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="mm-channel"
|
||||||
|
className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300"
|
||||||
|
>
|
||||||
|
Username / Channel ID
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="mm-channel"
|
||||||
|
value={mmChannelId}
|
||||||
|
onChange={(e) => setMmChannelId(e.target.value)}
|
||||||
|
placeholder="Contoh: budisantoso atau 9dpxnitm..."
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{profileError && (
|
{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
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getSettings, updateSettings } from '@/app/actions'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Loader2, Save, MessageSquare, MessageCircle, AlertCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
|
||||||
|
export default function IntegrationsPage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
MATTERMOST_NOTIF_ENABLED: 'false',
|
||||||
|
MATTERMOST_BOT_TOKEN: '',
|
||||||
|
MATTERMOST_API_URL: '',
|
||||||
|
MATTERMOST_WEBHOOK_URL: '',
|
||||||
|
MATTERMOST_TEMPLATE: '',
|
||||||
|
WHATSAPP_TEMPLATE: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
const data = await getSettings()
|
||||||
|
if (data) {
|
||||||
|
setSettings({
|
||||||
|
MATTERMOST_NOTIF_ENABLED: data.MATTERMOST_NOTIF_ENABLED || 'false',
|
||||||
|
MATTERMOST_BOT_TOKEN: data.MATTERMOST_BOT_TOKEN || '',
|
||||||
|
MATTERMOST_API_URL: data.MATTERMOST_API_URL || '',
|
||||||
|
MATTERMOST_WEBHOOK_URL: data.MATTERMOST_WEBHOOK_URL || '',
|
||||||
|
MATTERMOST_TEMPLATE:
|
||||||
|
data.MATTERMOST_TEMPLATE ||
|
||||||
|
'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)
|
||||||
|
}
|
||||||
|
fetchSettings()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleChange = (key: string, value: string) => {
|
||||||
|
setSettings((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
const res = await updateSettings(settings)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success('Berhasil Disimpan', {
|
||||||
|
description: 'Pengaturan integrasi berhasil disimpan!',
|
||||||
|
duration: 3000,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.error('Gagal', {
|
||||||
|
description: 'Gagal menyimpan pengaturan',
|
||||||
|
duration: 3000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-[#1B2CC1]" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Integrasi & Sharing</h2>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Konfigurasi endpoint API dan template pesan otomatis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
{/* Mattermost Section */}
|
||||||
|
<div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50/50 p-5 dark:border-slate-800 dark:bg-slate-900/50">
|
||||||
|
<div className="flex items-center gap-2 font-black text-indigo-600 dark:text-indigo-400">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
<h3>Konfigurasi Mattermost</h3>
|
||||||
|
</div>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-start space-x-3 space-y-0 rounded-md border border-slate-200 p-4 dark:border-slate-800 bg-white dark:bg-slate-950">
|
||||||
|
<Checkbox
|
||||||
|
id="mattermostNotifEnabled"
|
||||||
|
checked={settings.MATTERMOST_NOTIF_ENABLED === 'true'}
|
||||||
|
onCheckedChange={(checked) => handleChange('MATTERMOST_NOTIF_ENABLED', checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 leading-none w-full">
|
||||||
|
<Label htmlFor="mattermostNotifEnabled" className="text-sm font-bold">
|
||||||
|
Aktifkan DM Kreator saat Update Pesanan (Menggunakan Bot API)
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-slate-500 mb-3">
|
||||||
|
Kirim notifikasi otomatis ke channel/DM kreator PO tiap ada update pesanan. (Membutuhkan Bot Token).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{settings.MATTERMOST_NOTIF_ENABLED === 'true' && (
|
||||||
|
<div className="space-y-4 mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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 className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-bold text-slate-700 uppercase">Pesan Template</Label>
|
||||||
|
<Textarea
|
||||||
|
value={settings.MATTERMOST_TEMPLATE}
|
||||||
|
onChange={(e) => handleChange('MATTERMOST_TEMPLATE', e.target.value)}
|
||||||
|
className="min-h-[100px] rounded-xl bg-white dark:bg-slate-950"
|
||||||
|
placeholder="Template pesan..."
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* WhatsApp Section */}
|
||||||
|
<div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50/50 p-5 dark:border-slate-800 dark:bg-slate-900/50">
|
||||||
|
<div className="flex items-center gap-2 font-black text-emerald-600 dark:text-emerald-400">
|
||||||
|
<MessageCircle className="h-5 w-5" />
|
||||||
|
<h3>WhatsApp Share Template</h3>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<Label className="text-xs font-bold text-slate-700 uppercase">Pesan Template</Label>
|
||||||
|
<Textarea
|
||||||
|
value={settings.WHATSAPP_TEMPLATE}
|
||||||
|
onChange={(e) => handleChange('WHATSAPP_TEMPLATE', e.target.value)}
|
||||||
|
className="min-h-[100px] rounded-xl bg-white dark:bg-slate-950"
|
||||||
|
placeholder="Template pesan..."
|
||||||
|
/>
|
||||||
|
<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 className="pt-2">
|
||||||
|
<Button
|
||||||
|
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
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { User, Share2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'User Management', href: '/settings/users', icon: User },
|
||||||
|
{ name: 'Integrations & Share', href: '/settings/integrations', icon: Share2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-in fade-in space-y-6 pb-12 duration-500">
|
||||||
|
{/* Settings Navigation Tabs */}
|
||||||
|
<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 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) => {
|
||||||
|
const isActive = pathname === tab.href
|
||||||
|
const Icon = tab.icon
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.href}
|
||||||
|
href={tab.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-4 py-2 whitespace-nowrap transition-all duration-200',
|
||||||
|
isActive
|
||||||
|
? '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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{tab.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Content */}
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
getUsers,
|
||||||
|
createUserByAdmin,
|
||||||
|
toggleUserActive,
|
||||||
|
deleteUser,
|
||||||
|
resetUserPassword,
|
||||||
|
} from '@/app/actions'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Loader2, Plus, KeyRound, Ban, Trash2, CheckCircle2, ShieldCheck, User } from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<any[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
|
const [isPassOpen, setIsPassOpen] = useState(false)
|
||||||
|
const [selectedUser, setSelectedUser] = useState<any>(null)
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [role, setRole] = useState('user')
|
||||||
|
const [newPass, setNewPass] = useState('')
|
||||||
|
|
||||||
|
const [actionLoading, setActionLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers(page)
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
const loadUsers = async (p: number) => {
|
||||||
|
setLoading(true)
|
||||||
|
const res = await getUsers(p, 10)
|
||||||
|
setUsers(res.users)
|
||||||
|
setTotal(res.total)
|
||||||
|
setTotalPages(res.totalPages)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name || !username || !password) return alert('Lengkapi semua field')
|
||||||
|
|
||||||
|
setActionLoading(true)
|
||||||
|
const res = await createUserByAdmin(name, username, password, role)
|
||||||
|
if (res.success) {
|
||||||
|
setIsCreateOpen(false)
|
||||||
|
setName('')
|
||||||
|
setUsername('')
|
||||||
|
setPassword('')
|
||||||
|
setRole('user')
|
||||||
|
loadUsers(page)
|
||||||
|
} else {
|
||||||
|
alert(res.error)
|
||||||
|
}
|
||||||
|
setActionLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newPass || newPass.length < 6) return alert('Password minimal 6 karakter')
|
||||||
|
|
||||||
|
setActionLoading(true)
|
||||||
|
const res = await resetUserPassword(selectedUser.id, newPass)
|
||||||
|
if (res.success) {
|
||||||
|
setIsPassOpen(false)
|
||||||
|
setNewPass('')
|
||||||
|
alert('Password berhasil diubah')
|
||||||
|
} else {
|
||||||
|
alert(res.error)
|
||||||
|
}
|
||||||
|
setActionLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleActive = async (id: string, currentStatus: boolean) => {
|
||||||
|
if (!confirm(`Yakin ingin ${currentStatus ? 'menonaktifkan' : 'mengaktifkan'} user ini?`))
|
||||||
|
return
|
||||||
|
const res = await toggleUserActive(id, !currentStatus)
|
||||||
|
if (res.success) {
|
||||||
|
loadUsers(page)
|
||||||
|
} else {
|
||||||
|
alert(res.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Yakin ingin menghapus user ini secara permanen?')) return
|
||||||
|
const res = await deleteUser(id)
|
||||||
|
if (res.success) {
|
||||||
|
loadUsers(page)
|
||||||
|
} else {
|
||||||
|
alert(res.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Manajemen Pengguna</h2>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Kelola data seluruh pengguna TitipIn.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<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>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center">
|
||||||
|
<Loader2 className="mx-auto h-6 w-6 animate-spin text-[#1B2CC1]" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-slate-500">
|
||||||
|
Belum ada pengguna lain.
|
||||||
|
</td>
|
||||||
|
</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>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-slate-100 bg-slate-50/50 p-4 dark:border-slate-800">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CREATE MODAL */}
|
||||||
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<DialogContent className="overflow-hidden rounded-3xl border-0 p-0 sm:max-w-md">
|
||||||
|
<div className="border-b border-slate-100 px-6 pt-6 pb-4">
|
||||||
|
<DialogTitle className="text-xl font-black">Tambah User Baru</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleCreateUser} className="space-y-4 px-6 py-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-bold text-slate-700 uppercase">Role Akses</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRole('user')}
|
||||||
|
className={`flex h-11 items-center justify-center gap-2 rounded-xl border text-sm font-bold transition-all ${
|
||||||
|
role === 'user'
|
||||||
|
? 'border-[#1B2CC1] bg-[#1B2CC1]/5 text-[#1B2CC1] ring-1 ring-[#1B2CC1]/20'
|
||||||
|
: 'border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User className="h-4 w-4" /> User Biasa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRole('superadmin')}
|
||||||
|
className={`flex h-11 items-center justify-center gap-2 rounded-xl border text-sm font-bold transition-all ${
|
||||||
|
role === 'superadmin'
|
||||||
|
? '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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-4 w-4" /> Superadmin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* RESET PASSWORD MODAL */}
|
||||||
|
<Dialog open={isPassOpen} onOpenChange={setIsPassOpen}>
|
||||||
|
<DialogContent className="overflow-hidden rounded-3xl border-0 p-0 sm:max-w-md">
|
||||||
|
<div className="border-b border-slate-100 px-6 pt-6 pb-4">
|
||||||
|
<DialogTitle className="text-xl font-black">Ubah Password</DialogTitle>
|
||||||
|
<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>
|
||||||
|
<form onSubmit={handleResetPassword} className="space-y-4 px-6 py-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { getUsers, createUserByAdmin, toggleUserActive, deleteUser, resetUserPassword } from '@/app/actions'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Loader2, Plus, KeyRound, Ban, Trash2, CheckCircle2, ShieldCheck, User } from 'lucide-react'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
||||||
|
|
||||||
export default function UsersPage() {
|
|
||||||
const [users, setUsers] = useState<any[]>([])
|
|
||||||
const [total, setTotal] = useState(0)
|
|
||||||
const [totalPages, setTotalPages] = useState(1)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
// Modals
|
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
|
||||||
const [isPassOpen, setIsPassOpen] = useState(false)
|
|
||||||
const [selectedUser, setSelectedUser] = useState<any>(null)
|
|
||||||
|
|
||||||
// Forms
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [username, setUsername] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [role, setRole] = useState('user')
|
|
||||||
const [newPass, setNewPass] = useState('')
|
|
||||||
|
|
||||||
const [actionLoading, setActionLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadUsers(page)
|
|
||||||
}, [page])
|
|
||||||
|
|
||||||
const loadUsers = async (p: number) => {
|
|
||||||
setLoading(true)
|
|
||||||
const res = await getUsers(p, 10)
|
|
||||||
setUsers(res.users)
|
|
||||||
setTotal(res.total)
|
|
||||||
setTotalPages(res.totalPages)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateUser = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!name || !username || !password) return alert('Lengkapi semua field')
|
|
||||||
|
|
||||||
setActionLoading(true)
|
|
||||||
const res = await createUserByAdmin(name, username, password, role)
|
|
||||||
if (res.success) {
|
|
||||||
setIsCreateOpen(false)
|
|
||||||
setName('')
|
|
||||||
setUsername('')
|
|
||||||
setPassword('')
|
|
||||||
setRole('user')
|
|
||||||
loadUsers(page)
|
|
||||||
} else {
|
|
||||||
alert(res.error)
|
|
||||||
}
|
|
||||||
setActionLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResetPassword = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!newPass || newPass.length < 6) return alert('Password minimal 6 karakter')
|
|
||||||
|
|
||||||
setActionLoading(true)
|
|
||||||
const res = await resetUserPassword(selectedUser.id, newPass)
|
|
||||||
if (res.success) {
|
|
||||||
setIsPassOpen(false)
|
|
||||||
setNewPass('')
|
|
||||||
alert('Password berhasil diubah')
|
|
||||||
} else {
|
|
||||||
alert(res.error)
|
|
||||||
}
|
|
||||||
setActionLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleActive = async (id: string, currentStatus: boolean) => {
|
|
||||||
if (!confirm(`Yakin ingin ${currentStatus ? 'menonaktifkan' : 'mengaktifkan'} user ini?`)) return
|
|
||||||
const res = await toggleUserActive(id, !currentStatus)
|
|
||||||
if (res.success) {
|
|
||||||
loadUsers(page)
|
|
||||||
} else {
|
|
||||||
alert(res.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
if (!confirm('Yakin ingin menghapus user ini secara permanen?')) return
|
|
||||||
const res = await deleteUser(id)
|
|
||||||
if (res.success) {
|
|
||||||
loadUsers(page)
|
|
||||||
} else {
|
|
||||||
alert(res.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 animate-in fade-in duration-500 pb-12">
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-black text-slate-900 dark:text-white">Manajemen Pengguna</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">Kelola data seluruh pengguna TitipIn.</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setIsCreateOpen(true)} className="bg-[#1B2CC1] hover:bg-[#15229E] text-white rounded-xl h-11 px-5 shadow-md">
|
|
||||||
<Plus className="w-4 h-4 mr-2" /> Tambah User
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 overflow-hidden shadow-sm">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm text-left">
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
<td colSpan={5} className="px-6 py-12 text-center">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin mx-auto text-[#1B2CC1]" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : users.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-6 py-12 text-center text-slate-500">Belum ada pengguna lain.</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
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' && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleToggleActive(u.id, u.is_active)}
|
|
||||||
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'}`}
|
|
||||||
title={u.is_active ? 'Nonaktifkan' : 'Aktifkan'}
|
|
||||||
>
|
|
||||||
{u.is_active ? <Ban className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleDelete(u.id)}
|
|
||||||
className="h-8 px-2 rounded-lg bg-rose-100 text-rose-700 hover:bg-rose-200 hover:text-rose-900 font-medium"
|
|
||||||
title="Hapus"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* CREATE MODAL */}
|
|
||||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
|
||||||
<DialogContent className="sm:max-w-md rounded-3xl p-0 overflow-hidden border-0">
|
|
||||||
<div className="px-6 pt-6 pb-4 border-b border-slate-100">
|
|
||||||
<DialogTitle className="text-xl font-black">Tambah User Baru</DialogTitle>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleCreateUser} className="px-6 py-4 space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs font-bold uppercase text-slate-700">Nama Lengkap</Label>
|
|
||||||
<Input required value={name} onChange={e => setName(e.target.value)} className="h-11 rounded-xl" placeholder="Masukkan nama lengkap" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs font-bold uppercase text-slate-700">Username</Label>
|
|
||||||
<Input required value={username} onChange={e => setUsername(e.target.value)} className="h-11 rounded-xl" placeholder="Masukkan username unik" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs font-bold uppercase text-slate-700">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)" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-bold uppercase text-slate-700">Role Akses</Label>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRole('user')}
|
|
||||||
className={`h-11 rounded-xl border flex items-center justify-center gap-2 text-sm font-bold transition-all ${
|
|
||||||
role === 'user'
|
|
||||||
? 'border-[#1B2CC1] bg-[#1B2CC1]/5 text-[#1B2CC1] ring-1 ring-[#1B2CC1]/20'
|
|
||||||
: 'border-slate-200 text-slate-500 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<User className="w-4 h-4" /> User Biasa
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRole('superadmin')}
|
|
||||||
className={`h-11 rounded-xl border flex items-center justify-center gap-2 text-sm font-bold transition-all ${
|
|
||||||
role === 'superadmin'
|
|
||||||
? '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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="w-4 h-4" /> Superadmin
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pt-4 flex gap-3">
|
|
||||||
<Button type="button" variant="outline" onClick={() => setIsCreateOpen(false)} className="flex-1 h-11 rounded-xl">Batal</Button>
|
|
||||||
<Button type="submit" disabled={actionLoading} className="flex-1 h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white">
|
|
||||||
{actionLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Simpan User'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* RESET PASSWORD MODAL */}
|
|
||||||
<Dialog open={isPassOpen} onOpenChange={setIsPassOpen}>
|
|
||||||
<DialogContent className="sm:max-w-md rounded-3xl p-0 overflow-hidden border-0">
|
|
||||||
<div className="px-6 pt-6 pb-4 border-b border-slate-100">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleResetPassword} className="px-6 py-4 space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs font-bold uppercase text-slate-700">Password Baru</Label>
|
|
||||||
<Input required type="password" value={newPass} onChange={e => setNewPass(e.target.value)} className="h-11 rounded-xl" placeholder="Minimal 6 karakter" />
|
|
||||||
</div>
|
|
||||||
<div className="pt-4 flex gap-3">
|
|
||||||
<Button type="button" variant="outline" onClick={() => setIsPassOpen(false)} className="flex-1 h-11 rounded-xl">Batal</Button>
|
|
||||||
<Button type="submit" disabled={actionLoading} className="flex-1 h-11 rounded-xl bg-slate-900 hover:bg-slate-800 text-white">
|
|
||||||
{actionLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Simpan Password'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,94 +41,112 @@ 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>
|
||||||
@@ -136,27 +154,27 @@ export default function LoginPage() {
|
|||||||
<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">
|
||||||
© {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan by firmanramdhani
|
© {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,127 +56,146 @@ 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>
|
||||||
@@ -184,27 +203,27 @@ export default function RegisterPage() {
|
|||||||
<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">
|
||||||
© {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan by firmanramdhani
|
© {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+618
-222
File diff suppressed because it is too large
Load Diff
+86
-56
@@ -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
@@ -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
-30
@@ -1,57 +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'
|
||||||
|
|
||||||
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
|
||||||
@@ -61,11 +61,11 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<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" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-18
@@ -4,46 +4,45 @@ 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 © {new Date().getFullYear()} - Sistem Titip Pesanan by firmanramdhani
|
TitipIn © {new Date().getFullYear()} - Sistem Titip Pesanan
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
+37
-23
@@ -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 } 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,42 +34,43 @@ 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 '/users':
|
case '/settings/users':
|
||||||
|
case '/settings/integrations':
|
||||||
return {
|
return {
|
||||||
title: 'Manajemen Pengguna',
|
title: 'Pengaturan Aplikasi',
|
||||||
subtitle: 'Kelola data pengguna, role, dan status aktif.',
|
subtitle: 'Konfigurasi integrasi, role, dan sistem.',
|
||||||
badge: 'Superadmin',
|
badge: 'Superadmin',
|
||||||
Icon: User
|
Icon: Settings,
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if (pathname.startsWith('/my-orders/')) {
|
if (pathname.startsWith('/my-orders/')) {
|
||||||
@@ -67,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,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>
|
||||||
@@ -116,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
@@ -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>
|
||||||
|
|||||||
@@ -3,14 +3,30 @@
|
|||||||
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 } 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 { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { id as idLocale } from 'date-fns/locale'
|
import { id as idLocale } from 'date-fns/locale'
|
||||||
@@ -18,41 +34,55 @@ 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)
|
||||||
|
|
||||||
return (
|
const activeItems = order.available_items.filter((i: any) => !i.is_sold_out)
|
||||||
<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">
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white line-clamp-2 leading-snug flex-1 min-w-0 group-hover:text-[#1B2CC1] dark:group-hover:text-blue-400 transition-colors">
|
<div className="min-w-0 flex-1">
|
||||||
{order.title}
|
<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">
|
||||||
</h3>
|
{order.title}
|
||||||
<div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 shrink-0">
|
</h3>
|
||||||
<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">
|
{order.description && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs font-medium text-slate-500">
|
||||||
|
{order.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
|
||||||
|
<span className="rounded-full bg-slate-100/80 px-2.5 py-1 text-[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>
|
||||||
@@ -61,56 +91,67 @@ 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 ({order.available_items.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">
|
||||||
{order.available_items.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>
|
||||||
))}
|
))}
|
||||||
{order.available_items.length === 0 && (
|
{activeItems.length === 0 && (
|
||||||
<li className="text-[10px] text-slate-400 italic">Hanya menerima item kustom.</li>
|
<li className="text-[10px] text-slate-400 italic">
|
||||||
|
{order.allow_custom ? 'Hanya menerima kustom.' : 'Semua item habis.'}
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Actions */}
|
{/* Right: Actions */}
|
||||||
<div className="w-full md:w-48 p-5 bg-slate-50/60 dark:bg-slate-800/40 flex flex-col items-center justify-center gap-3 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={cn(
|
<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]">
|
||||||
buttonVariants({ size: "sm" }),
|
<ShoppingBag className="h-4 w-4 shrink-0" />
|
||||||
"w-full h-9 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/20 transition-all hover:scale-[1.01] gap-1.5 cursor-pointer"
|
|
||||||
)}>
|
|
||||||
<ShoppingBag className="w-3.5 h-3.5 shrink-0" />
|
|
||||||
<span className="truncate">Titip Sekarang</span>
|
<span className="truncate">Titip Sekarang</span>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<OrderFormModal order={order} onSuccess={() => setOpen(false)} />
|
{open && <OrderFormModal order={order} onSuccess={() => setOpen(false)} />}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ShareButton
|
<div className="flex w-full gap-2">
|
||||||
orderId={order.id}
|
<Link
|
||||||
className="w-full h-9 rounded-xl font-bold text-xs gap-1.5 shadow-sm bg-white dark:bg-slate-900"
|
href={`/order/${order.id}`}
|
||||||
showText={true}
|
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="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">Detail</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<ShareButton
|
||||||
|
orderId={order.id}
|
||||||
|
orderTitle={order.title}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,76 +7,115 @@ import { DialogContent, DialogTitle } 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, 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 [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('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
const freshOrder = await getOrderDetail(order.id)
|
||||||
|
if (freshOrder) setLiveOrder(freshOrder)
|
||||||
|
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (user?.id) {
|
if (user?.id) {
|
||||||
setUserId(user.id)
|
setUserId(user.id)
|
||||||
loadExistingSubmission(user.id)
|
loadExistingSubmission(user.id, freshOrder || order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
init()
|
init()
|
||||||
}, [order.id])
|
}, [order.id])
|
||||||
|
|
||||||
const loadExistingSubmission = async (uid: string) => {
|
const loadExistingSubmission = async (uid: string, currentOrder: any) => {
|
||||||
const sub = await getUserSubmission(order.id, uid)
|
const sub = await getUserSubmission(currentOrder.id, uid)
|
||||||
if (sub) {
|
if (sub) {
|
||||||
const newItems: any = { ...items }
|
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 = order.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) {
|
||||||
newItems[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(newItems)
|
setItems(freshItems)
|
||||||
setCustomItems(newCustoms)
|
setCustomItems(newCustoms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -86,16 +125,26 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
|
|||||||
|
|
||||||
const payloadItems: any[] = []
|
const payloadItems: any[] = []
|
||||||
|
|
||||||
order.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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,9 +155,9 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await submitOrder({
|
const res = await submitOrder({
|
||||||
order_id: order.id,
|
order_id: liveOrder.id,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
items: payloadItems
|
items: payloadItems,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
@@ -120,76 +169,114 @@ 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
|
||||||
{order.title}
|
</span>
|
||||||
|
<DialogTitle className="mt-1 text-2xl font-black tracking-tight text-white">
|
||||||
|
{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">
|
||||||
{order.available_items.map((item: any) => {
|
{liveOrder.available_items.map((item: any) => {
|
||||||
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}
|
id={`item-${item.id}`}
|
||||||
onCheckedChange={(c) => handleStandardItemToggle(item.id, c as boolean)}
|
checked={isSelected && !item.is_sold_out}
|
||||||
className="rounded-lg data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1]"
|
disabled={item.is_sold_out || loading}
|
||||||
/>
|
onCheckedChange={(c) =>
|
||||||
<Label htmlFor={`item-${item.id}`} className="text-sm font-bold text-slate-800 dark:text-slate-200 cursor-pointer truncate">
|
!item.is_sold_out && handleStandardItemToggle(item.id, c as boolean)
|
||||||
{item.name}
|
}
|
||||||
</Label>
|
className="rounded-lg disabled:opacity-50 data-[state=checked]:border-[#1B2CC1] data-[state=checked]:bg-[#1B2CC1]"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
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>
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{order.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>
|
||||||
)}
|
)}
|
||||||
@@ -197,18 +284,19 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Items */}
|
{/* Custom Items */}
|
||||||
{order.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"
|
||||||
|
disabled={loading}
|
||||||
onClick={addCustomItem}
|
onClick={addCustomItem}
|
||||||
className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/40 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5"
|
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>
|
||||||
@@ -217,27 +305,35 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
|
|||||||
{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}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span className="w-4 text-xs font-bold text-slate-400">{idx + 1}.</span>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Nama Menu / Catatan Khusus"
|
placeholder="Nama Menu Kustom"
|
||||||
value={ci.name}
|
value={ci.name}
|
||||||
onChange={(e) => updateCustomItem(ci.id, 'name', e.target.value)}
|
disabled={loading}
|
||||||
className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium"
|
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"
|
||||||
|
disabled={loading}
|
||||||
onClick={() => removeCustomItem(ci.id)}
|
onClick={() => removeCustomItem(ci.id)}
|
||||||
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
|
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>
|
||||||
@@ -247,27 +343,31 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
|
|||||||
) : (
|
) : (
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 "{searchQuery}".</p>
|
<p className="text-sm font-semibold">
|
||||||
|
Tidak ada hasil untuk "{searchQuery}".
|
||||||
|
</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,25 +397,34 @@ 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>
|
||||||
|
<p className="text-2xl font-black text-rose-600 dark:text-rose-400">
|
||||||
|
{formatRupiah(target.amount)}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700 flex flex-col gap-3">
|
<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">
|
||||||
|
Rp
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={cashInput}
|
value={cashInput}
|
||||||
@@ -325,23 +433,29 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
|
|||||||
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">
|
||||||
@@ -354,16 +468,22 @@ export default function ReportByPersonGrid({ data, balancesData = [], creatorId,
|
|||||||
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>
|
||||||
|
|||||||
@@ -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,
|
||||||
export default function ReportCharts({ type, data }: { type: 'SUBMITTOR' | 'CREATOR', data: any[] }) {
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
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,13 +110,13 @@ 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" />
|
||||||
@@ -90,17 +134,14 @@ export default function ReportCharts({ type, data }: { type: 'SUBMITTOR' | 'CREA
|
|||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+108
-30
@@ -2,52 +2,130 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Share2, Check } from 'lucide-react'
|
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Share2, Check, Copy, MessageCircle, MessageSquare, Loader2 } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { broadcastToMattermost, getSettings } from '@/app/actions'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface ShareButtonProps {
|
interface ShareButtonProps {
|
||||||
orderId: string
|
orderId: 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, 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 [broadcasting, setBroadcasting] = useState(false)
|
||||||
|
|
||||||
const handleShare = () => {
|
const handleCopyLink = () => {
|
||||||
const url = `${window.location.origin}/order/${orderId}`
|
const url = `${window.location.origin}/order/${orderId}`
|
||||||
navigator.clipboard.writeText(url)
|
navigator.clipboard.writeText(url)
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleShareWA = async () => {
|
||||||
|
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}'
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/order/${orderId}`
|
||||||
|
const text = template.replace('{title}', orderTitle).replace('{url}', url)
|
||||||
|
|
||||||
|
window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBroadcastMattermost = async () => {
|
||||||
|
setBroadcasting(true)
|
||||||
|
const url = `${window.location.origin}/order/${orderId}`
|
||||||
|
const res = await broadcastToMattermost(orderId, url)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success('Broadcast Terkirim!', {
|
||||||
|
description: 'Berhasil mengirim pesan ke Mattermost.',
|
||||||
|
duration: 3000,
|
||||||
|
})
|
||||||
|
setOpen(false)
|
||||||
|
} else {
|
||||||
|
toast.error('Gagal Broadcast', {
|
||||||
|
description: res.error || 'Terjadi kesalahan saat mengirim.',
|
||||||
|
duration: 3000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setBroadcasting(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
variant={variant}
|
<Button
|
||||||
size={size}
|
variant={variant}
|
||||||
onClick={handleShare}
|
size={size}
|
||||||
className={cn(
|
className={cn('transition-all', className)}
|
||||||
"transition-all",
|
title="Bagikan PO"
|
||||||
copied
|
onClick={() => setOpen(true)}
|
||||||
? "bg-emerald-50 text-emerald-600 border-emerald-200 hover:bg-emerald-100 hover:text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 dark:border-emerald-800"
|
>
|
||||||
: "",
|
<Share2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
className
|
{showText && <span className="hidden truncate sm:inline">Bagikan</span>}
|
||||||
)}
|
</Button>
|
||||||
title="Bagikan PO"
|
|
||||||
>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
{copied ? (
|
<DialogContent className="rounded-3xl border-slate-200/90 p-6 sm:max-w-xs dark:border-slate-800">
|
||||||
<>
|
<DialogTitle className="mb-2 text-center text-xl font-black text-slate-900 dark:text-white">
|
||||||
<Check className="w-3.5 h-3.5 shrink-0" />
|
Bagikan Pesanan
|
||||||
{showText && <span className="truncate hidden sm:inline">Tersalin</span>}
|
</DialogTitle>
|
||||||
</>
|
<p className="mb-4 text-center text-sm text-slate-500">
|
||||||
) : (
|
Pilih metode untuk membagikan PO ini ke teman atau tim Anda.
|
||||||
<>
|
</p>
|
||||||
<Share2 className="w-3.5 h-3.5 shrink-0" />
|
|
||||||
{showText && <span className="truncate hidden sm:inline">Bagikan</span>}
|
<div className="space-y-3">
|
||||||
</>
|
<Button
|
||||||
)}
|
variant="outline"
|
||||||
</Button>
|
onClick={handleCopyLink}
|
||||||
|
className="h-11 w-full justify-start gap-3 rounded-xl font-bold text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{copied ? 'Tautan Disalin!' : 'Salin Tautan'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleShareWA}
|
||||||
|
className="h-11 w-full justify-start gap-3 rounded-xl bg-[#25D366] font-bold text-white hover:bg-[#20bd5a]"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
Kirim ke WhatsApp
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleBroadcastMattermost}
|
||||||
|
disabled={broadcasting}
|
||||||
|
className="h-11 w-full justify-start gap-3 rounded-xl bg-[#0668E1] font-bold text-white hover:bg-[#0557bc]"
|
||||||
|
>
|
||||||
|
{broadcasting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{broadcasting ? 'Mengirim...' : 'Broadcast ke Mattermost'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-63
@@ -4,11 +4,7 @@ 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,
|
||||||
@@ -20,12 +16,18 @@ import {
|
|||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Info,
|
Info,
|
||||||
BarChart2,
|
BarChart2,
|
||||||
Wallet
|
Wallet,
|
||||||
|
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' },
|
||||||
@@ -63,7 +65,12 @@ 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: 'User Management', href: '/users', icon: UserCircle, desc: 'Kelola pengguna' })
|
finalMenuItems.push({
|
||||||
|
name: 'Pengaturan',
|
||||||
|
href: '/settings/users',
|
||||||
|
icon: Settings,
|
||||||
|
desc: 'Konfigurasi aplikasi',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,36 +79,43 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
|
|||||||
{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>
|
||||||
@@ -110,32 +124,36 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
|
|||||||
<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>
|
||||||
@@ -144,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">
|
||||||
@@ -157,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>
|
||||||
@@ -189,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>
|
||||||
@@ -238,9 +275,9 @@ 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">
|
||||||
© {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan<br/>by firmanramdhani
|
© {new Date().getFullYear()} TitipIn - Sistem Titip Pesanan
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Copy, Check, Receipt } from 'lucide-react'
|
||||||
|
|
||||||
|
export function SummaryGenerator({ order }: { order: any }) {
|
||||||
|
const [copiedPerson, setCopiedPerson] = useState(false)
|
||||||
|
const [copiedItem, setCopiedItem] = useState(false)
|
||||||
|
|
||||||
|
// Generate Summaries
|
||||||
|
let summaryByPerson = `${order.title}\n`
|
||||||
|
const itemCounts: Record<string, number> = {}
|
||||||
|
|
||||||
|
order.submissions?.forEach((sub: any) => {
|
||||||
|
const itemStrings = sub.items.map(
|
||||||
|
(i: any) => `${i.name} ${i.qty}x${i.note ? ` (${i.note})` : ''}`
|
||||||
|
)
|
||||||
|
summaryByPerson += `- ${sub.user?.name || 'Unknown'} : ${itemStrings.join(', ')}\n`
|
||||||
|
|
||||||
|
sub.items.forEach((i: any) => {
|
||||||
|
const key = i.note ? `${i.name} (${i.note})` : i.name
|
||||||
|
itemCounts[key] = (itemCounts[key] || 0) + i.qty
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let summaryByItem = `${order.title}\n`
|
||||||
|
Object.entries(itemCounts).forEach(([name, qty]) => {
|
||||||
|
summaryByItem += `- ${name} : ${qty} pcs\n`
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCopyPerson = () => {
|
||||||
|
navigator.clipboard.writeText(summaryByPerson)
|
||||||
|
setCopiedPerson(true)
|
||||||
|
setTimeout(() => setCopiedPerson(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyItem = () => {
|
||||||
|
navigator.clipboard.writeText(summaryByItem)
|
||||||
|
setCopiedItem(true)
|
||||||
|
setTimeout(() => setCopiedItem(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="px-1">
|
||||||
|
<h3 className="flex items-center gap-2 text-lg font-black text-slate-900 dark:text-white">
|
||||||
|
<Receipt className="h-5 w-5 text-[#1B2CC1]" />
|
||||||
|
<span>Generator Rekap</span>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">
|
||||||
|
Salin format teks siap kirim ke WhatsApp / grup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rekap Per Orang */}
|
||||||
|
<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="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">
|
||||||
|
Rekap per Orang
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyPerson}
|
||||||
|
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="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{copiedPerson ? 'Tersalin!' : 'Copy Text'}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<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}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Rekap Per Item */}
|
||||||
|
<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="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">
|
||||||
|
Rekap per Item (Akumulasi)
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyItem}
|
||||||
|
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="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{copiedItem ? 'Tersalin!' : 'Copy Text'}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<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}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+17
-20
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
OctagonXIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = 'system' } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps['theme']}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
info: <InfoIcon className="size-4" />,
|
||||||
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
|
error: <OctagonXIcon className="size-4" />,
|
||||||
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--normal-bg': 'var(--popover)',
|
||||||
|
'--normal-text': 'var(--popover-foreground)',
|
||||||
|
'--normal-border': 'var(--border)',
|
||||||
|
'--border-radius': 'var(--radius)',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
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',
|
||||||
|
title: 'font-bold',
|
||||||
|
description: '!text-slate-700 dark:!text-slate-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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
+21
-48
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from 'cn'
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
'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
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
+1
-4
@@ -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> {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user