feat: implement app directory routing, authentication flow, and dynamic reporting module

This commit is contained in:
Firman Ramdhani
2026-08-28 15:50:01 +07:00
parent 3e68d12ce4
commit 84e6599b23
28 changed files with 1869 additions and 478 deletions
-157
View File
@@ -1,157 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { checkUser, registerUser } from '@/app/actions'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Loader2, Sparkles, User, Image as ImageIcon } from 'lucide-react'
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isVerified, setIsVerified] = useState(false)
const [showModal, setShowModal] = useState(false)
const [userId, setUserId] = useState<string | null>(null)
const [name, setName] = useState('')
const [photo, setPhoto] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
async function initAuth() {
let id = localStorage.getItem('user_id')
if (!id) {
id = uuidv4()
localStorage.setItem('user_id', id)
}
setUserId(id)
const user = await checkUser(id)
if (user) {
setIsVerified(true)
} else {
setShowModal(true)
}
setLoading(false)
}
initAuth()
}, [])
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError('Nama wajib diisi')
return
}
setError('')
setSaving(true)
const res = await registerUser(userId!, name.trim(), photo.trim() || undefined)
if (res.success) {
setShowModal(false)
setIsVerified(true)
} else {
setError(res.error || 'Terjadi kesalahan')
}
setSaving(false)
}
const handleOpenChange = (open: boolean) => {
if (!open && !isVerified) {
setShowModal(true)
}
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-[#F4F6FB] dark:bg-[#0B0F19] gap-4">
<div className="w-12 h-12 rounded-2xl bg-[#1B2CC1] flex items-center justify-center text-white shadow-xl shadow-[#1B2CC1]/30 animate-pulse">
<Sparkles className="w-6 h-6" />
</div>
<div className="flex items-center gap-2 text-sm font-semibold text-slate-500">
<Loader2 className="h-4 w-4 animate-spin text-[#1B2CC1]" />
<span>Memuat sesi Anda...</span>
</div>
</div>
)
}
return (
<>
{isVerified && children}
<Dialog open={showModal} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[440px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl [&>button]:hidden">
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white text-center relative overflow-hidden">
<div className="absolute -right-8 -bottom-8 w-32 h-32 bg-white/10 rounded-full blur-2xl pointer-events-none" />
<div className="w-14 h-14 rounded-2xl bg-white/15 backdrop-blur-md flex items-center justify-center mx-auto mb-3 text-white border border-white/20 shadow-inner">
<Sparkles className="w-7 h-7" />
</div>
<DialogTitle className="text-2xl font-black tracking-tight text-white">
Selamat Datang di TitipIn
</DialogTitle>
<DialogDescription className="text-blue-100/90 text-xs mt-1.5 max-w-xs mx-auto">
Daftarkan nama Anda untuk mulai membuka atau menitip pesanan bersama teman.
</DialogDescription>
</div>
<form onSubmit={handleRegister} className="p-6 space-y-4 bg-white dark:bg-slate-900">
<div className="space-y-1.5">
<Label htmlFor="reg-name" className="text-xs font-bold text-slate-700 dark:text-slate-300">
Nama Tampilan <span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
id="reg-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Contoh: Budi Santoso"
className="rounded-xl h-11 border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
autoComplete="off"
/>
</div>
<p className="text-[11px] text-slate-400">Nama harus unik agar pembuat PO mudah mengenali Anda.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="reg-photo" className="text-xs font-bold text-slate-700 dark:text-slate-300">
URL Foto Profil (Opsional)
</Label>
<Input
id="reg-photo"
value={photo}
onChange={(e) => setPhoto(e.target.value)}
placeholder="https://images.unsplash.com/..."
className="rounded-xl h-11 border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
autoComplete="off"
/>
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/60 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
{error}
</div>
)}
<Button
type="submit"
disabled={saving}
className="w-full h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/25 transition-all mt-2"
>
{saving ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Menyimpan Profil...</span>
</div>
) : (
'Mulai Gunakan TitipIn'
)}
</Button>
</form>
</DialogContent>
</Dialog>
</>
)
}
+7
View File
@@ -54,6 +54,13 @@ export function Header({ onMenuClick }: { onMenuClick: () => void }) {
badge: 'Laporan',
Icon: BarChart2
}
case '/users':
return {
title: 'Manajemen Pengguna',
subtitle: 'Kelola data pengguna, role, dan status aktif.',
badge: 'Superadmin',
Icon: User
}
default:
if (pathname.startsWith('/my-orders/')) {
return {
+8 -5
View File
@@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { submitOrder, getUserSubmission } from '@/app/actions'
import { submitOrder, getUserSubmission, getSessionUser } from '@/app/actions'
import { PlusCircle, MinusCircle, User, CheckCircle2, ShoppingBag, Sparkles, Users } from 'lucide-react'
import { format } from 'date-fns'
@@ -113,11 +113,14 @@ function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: () => voi
const [error, setError] = useState('')
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) {
setUserId(id)
loadExistingSubmission(id)
const init = async () => {
const user = await getSessionUser()
if (user?.id) {
setUserId(user.id)
loadExistingSubmission(user.id)
}
}
init()
}, [order.id])
const loadExistingSubmission = async (uid: string) => {
+76 -10
View File
@@ -4,6 +4,12 @@ import { useEffect, useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
Store,
ClipboardList,
@@ -15,7 +21,6 @@ import {
Info,
BarChart2
} from 'lucide-react'
import { checkUser } from '@/app/actions'
const menuItems = [
{ name: 'Open Order', href: '/', icon: Store, desc: 'Daftar PO live hari ini' },
@@ -27,22 +32,38 @@ const menuItems = [
export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () => void }) {
const pathname = usePathname()
const [userName, setUserName] = useState<string>('Pengguna')
const [userNameAccount, setUserNameAccount] = useState<string>('Pengguna')
const [userPhoto, setUserPhoto] = useState<string | null>(null)
const [userRole, setUserRole] = useState<string | null>(null)
const [userId, setUserId] = useState<string | null>(null)
const [isLogoutOpen, setIsLogoutOpen] = useState(false)
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) {
setUserId(id)
checkUser(id).then((u) => {
import('@/app/actions').then(({ getSessionUser }) => {
getSessionUser().then((u) => {
if (u) {
setUserId(u.id)
setUserName(u.name)
setUserNameAccount(u.username)
setUserRole(u.role)
if (u.photo) setUserPhoto(u.photo)
}
})
}
})
}, [pathname])
const handleLogout = async () => {
const { logoutUser } = await import('@/app/actions')
await logoutUser()
window.location.href = '/login'
}
// Dynamic Menu Items based on role
const finalMenuItems = [...menuItems]
if (userRole === 'superadmin') {
finalMenuItems.push({ name: 'User Management', href: '/users', icon: UserCircle, desc: 'Kelola pengguna' })
}
return (
<>
{/* Mobile Backdrop */}
@@ -106,9 +127,16 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
</span>
<ArrowUpRight className="w-3.5 h-3.5 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<span className="text-[10px] font-mono text-slate-400 truncate">
{userId ? `ID: ${userId.slice(0, 10)}...` : 'Aktif'}
</span>
<div className="flex items-center gap-1.5 mt-0.5">
{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">
Admin
</span>
)}
<span className="text-[10px] font-medium text-slate-500 dark:text-slate-400 truncate">
{userNameAccount ? `@${userNameAccount}` : 'Aktif'}
</span>
</div>
</div>
</Link>
@@ -118,7 +146,7 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
Navigasi Utama
</div>
<nav className="space-y-1">
{menuItems.map((item) => {
{finalMenuItems.map((item) => {
const Icon = item.icon
const isActive = pathname === item.href
return (
@@ -153,6 +181,44 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
</Link>
)
})}
{/* Logout Button */}
{userId && (
<>
<button
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"
>
<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">
<X className="w-4 h-4" />
</div>
<div className="flex flex-col min-w-0 text-left">
<span className="leading-tight">Logout</span>
<span className="text-[10px] font-normal truncate mt-0.5 text-rose-400/80">Keluar dari akun</span>
</div>
</button>
<Dialog open={isLogoutOpen} onOpenChange={setIsLogoutOpen}>
<DialogContent className="sm:max-w-xs rounded-3xl p-0 overflow-hidden border-0">
<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">
<X className="w-6 h-6" />
</div>
<DialogTitle className="text-xl font-black text-slate-800">Konfirmasi Logout</DialogTitle>
<p className="text-xs text-slate-500 mt-2">
Apakah Anda yakin ingin keluar dari akun ini?
</p>
</div>
<div className="p-4 flex gap-3 bg-slate-50">
<Button type="button" variant="outline" onClick={() => setIsLogoutOpen(false)} className="flex-1 h-11 rounded-xl">Batal</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">
Ya, Logout
</Button>
</div>
</DialogContent>
</Dialog>
</>
)}
</nav>
</div>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"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: {
variant: {