From db352351b3fff12498abc2df4f96709bcba59547 Mon Sep 17 00:00:00 2001 From: Projects-codelive Date: Sun, 29 Mar 2026 17:16:27 +0530 Subject: [PATCH] ui enhanced for all routes --- AGENTS.md | 2 +- src/app/(app)/admin/approval-rules/page.tsx | 488 ++++++----- src/app/(app)/admin/expenses/page.tsx | 46 +- src/app/(app)/admin/settings/page.tsx | 168 +++- src/app/(app)/admin/users/page.tsx | 440 +++++----- src/app/(app)/admin/workflow/page.tsx | 98 ++- src/app/(app)/dashboard/DashboardContent.tsx | 123 +-- src/app/(app)/layout.tsx | 19 +- src/app/(app)/notifications/page.tsx | 8 +- src/app/globals.css | 784 +++++++---------- src/components/AdminExpenseTable.tsx | 840 +++++++------------ src/components/ApprovalTable.tsx | 147 ++-- src/components/ExpenseTable.tsx | 163 ++-- src/components/OdooSidebar.tsx | 139 ++- src/components/ui/enterprise-table.tsx | 50 +- src/components/ui/enterprise-toolbar.tsx | 24 +- src/components/ui/summary-strip.tsx | 10 +- src/components/ui/table-pagination.tsx | 55 +- src/components/workflow-builder.tsx | 321 ++++--- 19 files changed, 1837 insertions(+), 2088 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2249003..840e913 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ -# AGENTS.md +# AGENTS.md This file provides guidance to WARP (warp.dev) when working with code in this repository. ## Existing guidance files diff --git a/src/app/(app)/admin/approval-rules/page.tsx b/src/app/(app)/admin/approval-rules/page.tsx index 87983a9..09b1e65 100644 --- a/src/app/(app)/admin/approval-rules/page.tsx +++ b/src/app/(app)/admin/approval-rules/page.tsx @@ -3,14 +3,12 @@ import { useState, useEffect } from "react" import { useSession } from "next-auth/react" import { useRouter } from "next/navigation" -import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" -import { Card, CardContent } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Trash2, Plus, GripVertical, ShieldCheck } from "lucide-react" +import { cn } from "@/lib/utils" +import { Trash2, Plus, GripVertical, ShieldCheck, X, ChevronDown, ChevronUp } from "lucide-react" interface User { id: string @@ -38,27 +36,22 @@ interface ApprovalRule { } const EMPTY_FORM = { - name: "", - description: "", - assignedUserId: "none", - managerId: "none", - isManagerApprover: false, - approversSequence: false, - minApprovalPercentage: "", + name: "", description: "", assignedUserId: "none", managerId: "none", + isManagerApprover: false, approversSequence: false, minApprovalPercentage: "", } export default function ApprovalRulesPage() { const { data: session, status } = useSession() const router = useRouter() - const [users, setUsers] = useState([]) - const [rules, setRules] = useState([]) - const [loading, setLoading] = useState(true) + const [users, setUsers] = useState([]) + const [rules, setRules] = useState([]) + const [loading, setLoading] = useState(true) const [showForm, setShowForm] = useState(false) - const [form, setForm] = useState({ ...EMPTY_FORM }) + const [form, setForm] = useState({ ...EMPTY_FORM }) const [approvers, setApprovers] = useState([]) - const [saving, setSaving] = useState(false) - const [error, setError] = useState("") + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") useEffect(() => { if (status === "unauthenticated") router.push("/login") @@ -76,14 +69,11 @@ export default function ApprovalRulesPage() { }) }, []) - // When assignedUser changes, pre-fill manager from their record + const managers = users.filter(u => u.role === "MANAGER" || u.role === "ADMIN") + function handleAssignedUserChange(userId: string) { const user = users.find(u => u.id === userId) - setForm(f => ({ - ...f, - assignedUserId: userId, - managerId: user?.manager?.id ?? f.managerId, - })) + setForm(f => ({ ...f, assignedUserId: userId, managerId: user?.manager?.id ?? f.managerId })) } function addApprover() { @@ -100,9 +90,7 @@ export default function ApprovalRulesPage() { async function handleSave(e: React.FormEvent) { e.preventDefault() - setSaving(true) - setError("") - + setSaving(true); setError("") const res = await fetch("/api/approval-rules", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -114,16 +102,10 @@ export default function ApprovalRulesPage() { approvers: approvers.filter(a => a.userId), }), }) - const data = await res.json() - if (!res.ok) { - setError(data.error || "Failed to save") - } else { - setRules(prev => [data, ...prev]) - setShowForm(false) - setForm({ ...EMPTY_FORM }) - setApprovers([]) - } + if (!res.ok) { setError(data.error || "Failed to save"); setSaving(false); return } + setRules(prev => [data, ...prev]) + setShowForm(false); setForm({ ...EMPTY_FORM }); setApprovers([]) setSaving(false) } @@ -133,250 +115,254 @@ export default function ApprovalRulesPage() { setRules(prev => prev.filter(r => r.id !== id)) } - const managers = users.filter(u => u.role === "MANAGER" || u.role === "ADMIN") - return ( -
-
- Admin - / - Approval Rules +
+ + {/* Header */} +
+
+ Admin + / + Approval Rules + {rules.length} rule{rules.length !== 1 ? "s" : ""} +
+
-
-
+ +
+ + {/* Page title */} +
+
+ +
-

Approval Rules

-

Define who approves expenses and in what order.

+

Approval Rules

+

Define who approves expenses and in what order

-
- {/* Form */} - {showForm && ( - - -
-
- {/* Left column */} -
-
- - -
- -
- - setForm({ ...form, description: e.target.value })} - /> -
- -
- - -

- Initially set from user record. Admin can change for this rule. -

-
- -
- - setForm({ ...form, name: e.target.value })} - required - /> -
+ {/* Create form */} + {showForm && ( +
+
+

New Approval Rule

+
- - {/* Right column — Approvers */} -
-
- -
- - setForm({ ...form, isManagerApprover: v })} - /> - {form.isManagerApprover && ( -

- Approval request goes to manager first, before other approvers. -

- )} + +
+
+ + setForm({ ...form, name: e.target.value })} required + className="h-9 border-gray-200 focus:border-blue-400" /> +
+
+ + setForm({ ...form, description: e.target.value })} + className="h-9 border-gray-200 focus:border-blue-400" /> +
+
+ + +
+
+ +
- {/* Approver rows */} -
-
- - User - Required + {/* Approvers */} +
+
+ +
- {approvers.map((a, idx) => ( -
- - {idx + 1} - - -
- updateApprover(idx, { required: v })} - /> -
- + {approvers.length === 0 ? ( +

No approvers added yet.

+ ) : ( +
+ {approvers.map((a, idx) => ( +
+ + {idx + 1} + + +
+ updateApprover(idx, { required: v })} /> + Required +
+ +
+ ))}
- ))} - + )}
- {/* Approvers Sequence */} -
-
- setForm({ ...form, approversSequence: v })} - /> - + {/* Options */} +
+
+ setForm({ ...form, isManagerApprover: v })} /> +
+

Manager approves

+

Manager is first approver

+
+
+
+ setForm({ ...form, approversSequence: v })} /> +
+

Sequential

+

Approve in order

+
+
+
+

Min approval %

+
+ setForm({ ...form, minApprovalPercentage: e.target.value })} + className="h-8 w-20 text-sm border-gray-200" /> + % +
-

- If ticked, the sequence above matters — request goes to approver 1 first, then 2, etc. - If a required approver rejects, the expense is auto-rejected. - If not ticked, request is sent to all approvers simultaneously. -

- {/* Min approval % */} -
- -
- setForm({ ...form, minApprovalPercentage: e.target.value })} - className="w-24" - /> - % -
-

- Percentage of approvers required to approve the request. -

+ {error &&

{error}

} + +
+ +
+ +
+ )} + + {/* Rules list */} + {loading ? ( +
+
+
+ Loading rules… +
+
+ ) : rules.length === 0 ? ( +
+
+ +
+
+

No approval rules yet

+

Create a rule to define who approves expenses.

+
+ ) : ( +
+ {rules.map((rule, idx) => ( + + ))} +
+ )} +
+
+ ) +} + +function RuleCard({ rule, onDelete, defaultOpen }: { rule: ApprovalRule; onDelete: (id: string) => void; defaultOpen?: boolean }) { + const [open, setOpen] = useState(defaultOpen ?? false) - {error &&

{error}

} + const tags = [ + rule.assignedUser && { label: `User: ${rule.assignedUser.name}`, color: "bg-blue-100 text-blue-700" }, + rule.manager && { label: `Manager: ${rule.manager.name}`, color: "bg-amber-100 text-amber-700" }, + rule.isManagerApprover && { label: "Manager approves", color: "bg-violet-100 text-violet-700" }, + rule.approversSequence && { label: "Sequential", color: "bg-emerald-100 text-emerald-700" }, + rule.minApprovalPercentage && { label: `${rule.minApprovalPercentage}% required`, color: "bg-gray-100 text-gray-600" }, + ].filter(Boolean) as { label: string; color: string }[] -
- - + return ( +
+
setOpen(o => !o)}> +
+
+
- - - - )} +
+

{rule.name}

+ {rule.description &&

{rule.description}

} +
+
+
+ {rule.approvers.length} approver{rule.approvers.length !== 1 ? "s" : ""} + + {open ? : } +
+
- {/* Rules list */} - {loading ? ( -

Loading...

- ) : rules.length === 0 ? ( - - -
- -
-
-

No approval rules yet

-

Create a rule to define who approves expenses.

+ {open && ( +
+ {tags.length > 0 && ( +
+ {tags.map(t => ( + {t.label} + ))}
- - - - ) : ( -
- {rules.map(rule => ( - - -
-
-

{rule.name}

- {rule.description &&

{rule.description}

} -
- -
-
- {rule.assignedUser && ( - User: {rule.assignedUser.name} - )} - {rule.manager && ( - Manager: {rule.manager.name} - )} - {rule.isManagerApprover && ( - Manager approver - )} - {rule.approversSequence && ( - Sequential - )} - {rule.minApprovalPercentage && ( - {rule.minApprovalPercentage}% required - )} + )} + {rule.approvers.length > 0 && ( +
+

Approvers

+
+ {rule.approvers.sort((a, b) => a.stepOrder - b.stepOrder).map((a, i) => ( + + {i + 1}. + {a.user.name} + {a.required && } + + ))}
- {rule.approvers.length > 0 && ( -
- {rule.approvers.map((a, i) => ( - - {i + 1}. - {a.user.name} - {a.required && } - - ))} -
- )} - - - ))} +
+ )}
)} -
) } diff --git a/src/app/(app)/admin/expenses/page.tsx b/src/app/(app)/admin/expenses/page.tsx index 019ed77..e644055 100644 --- a/src/app/(app)/admin/expenses/page.tsx +++ b/src/app/(app)/admin/expenses/page.tsx @@ -38,33 +38,23 @@ export default async function AdminExpensesPage() { }) return ( -
-
- Admin - / - All Expenses - {company?.name} -
-
- ({ - id: e.id, - description: e.description, - category: e.category, - date: e.date.toISOString(), - submittedAmount: Number(e.submittedAmount), - submittedCurrency: e.submittedCurrency, - convertedAmount: Number(e.convertedAmount), - status: e.status, - employee: e.employee, - isAdminOverride: e.isAdminOverride, - adminOverrideAt: e.adminOverrideAt?.toISOString() || null, - adminOverrideComment: e.adminOverrideComment, - }))} - companyCurrency={company?.currency || "USD"} - employees={employees} - /> -
-
+ ({ + id: e.id, + description: e.description, + category: e.category, + date: e.date.toISOString(), + submittedAmount: Number(e.submittedAmount), + submittedCurrency: e.submittedCurrency, + convertedAmount: Number(e.convertedAmount), + status: e.status, + employee: e.employee, + isAdminOverride: e.isAdminOverride, + adminOverrideAt: e.adminOverrideAt?.toISOString() || null, + adminOverrideComment: e.adminOverrideComment, + }))} + companyCurrency={company?.currency || "USD"} + employees={employees} + /> ) } diff --git a/src/app/(app)/admin/settings/page.tsx b/src/app/(app)/admin/settings/page.tsx index ca135eb..f35b8e5 100644 --- a/src/app/(app)/admin/settings/page.tsx +++ b/src/app/(app)/admin/settings/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation" import Link from "next/link" import { authOptions } from "@/lib/auth" import { prisma } from "@/lib/prisma" -import { Building2, Bell, Shield, Workflow, ChevronRight } from "lucide-react" +import { Building2, Bell, Shield, Workflow, ChevronRight, Settings, Globe, Hash, User } from "lucide-react" export default async function AdminSettingsPage() { const session = await getServerSession(authOptions) @@ -12,56 +12,140 @@ export default async function AdminSettingsPage() { const company = await prisma.company.findUnique({ where: { id: session.user.companyId } }) - const settings = [ - { title: "Company Settings", description: "Manage company name, currency, and preferences", icon: Building2, href: "/dashboard" }, - { title: "Notifications", description: "Configure notification preferences", icon: Bell, href: "/notifications" }, - { title: "User Management", description: "Manage user roles and permissions", icon: Shield, href: "/admin/users" }, - { title: "Approval Workflow", description: "Configure expense approval workflows", icon: Workflow, href: "/admin/workflow" }, + const navCards = [ + { + title: "Company Setup", + description: "Update company name, currency, and branding", + icon: Building2, + href: "/dashboard", + gradient: "from-blue-500 to-blue-600", + bg: "bg-blue-50", + iconColor: "text-blue-600", + badge: "Core", + }, + { + title: "Notifications", + description: "Configure how and when alerts are sent", + icon: Bell, + href: "/notifications", + gradient: "from-amber-500 to-orange-500", + bg: "bg-amber-50", + iconColor: "text-amber-600", + badge: null, + }, + { + title: "User Management", + description: "Invite team members and manage roles", + icon: Shield, + href: "/admin/users", + gradient: "from-emerald-500 to-teal-500", + bg: "bg-emerald-50", + iconColor: "text-emerald-600", + badge: null, + }, + { + title: "Approval Workflow", + description: "Define multi-step expense approval chains", + icon: Workflow, + href: "/admin/workflow", + gradient: "from-violet-500 to-purple-600", + bg: "bg-violet-50", + iconColor: "text-violet-600", + badge: null, + }, + ] + + const infoRows = [ + { label: "Company Name", value: company?.name || "Not set", icon: Building2, color: "text-blue-500" }, + { label: "Default Currency", value: company?.currency || "USD", icon: Globe, color: "text-emerald-500" }, + { label: "Company ID", value: company?.id?.slice(0, 12) + "…", icon: Hash, color: "text-gray-400" }, + { label: "Admin", value: session.user.name || session.user.email || "—", icon: User, color: "text-violet-500" }, ] return ( -
-
- Admin - / - Settings +
+ + {/* Sticky header */} +
+ Admin + / + Settings
-
-
- {settings.map((s) => { - const Icon = s.icon - return ( - -
- -
-
-
-

{s.title}

- + +
+ + {/* Page hero */} +
+
+ +
+
+

Settings

+

Manage your workspace configuration

+
+
+ + {/* Nav cards */} +
+

Configuration

+
+ {navCards.map((card) => { + const Icon = card.icon + return ( + + {/* Subtle gradient accent on hover */} +
+ +
+
-

{s.description}

-
- - ) - })} + +
+
+

{card.title}

+ {card.badge && ( + {card.badge} + )} +
+

{card.description}

+
+ + + + ) + })} +
-
-
- Company Information + {/* Company info */} +
+

Company Information

+
+ {infoRows.map((row, i) => { + const Icon = row.icon + return ( +
+
+ +
+ {row.label} + {row.value} +
+ ) + })}
- {[ - { label: "Company Name", value: company?.name || "Not set" }, - { label: "Default Currency", value: company?.currency || "USD" }, - { label: "Company ID", value: company?.id.slice(0, 8) + "..." }, - ].map((row) => ( -
- {row.label} - {row.value} -
- ))}
+ + {/* Version footer */} +

Reimbursor · Admin Panel

+
) diff --git a/src/app/(app)/admin/users/page.tsx b/src/app/(app)/admin/users/page.tsx index 3341dea..519c5ec 100644 --- a/src/app/(app)/admin/users/page.tsx +++ b/src/app/(app)/admin/users/page.tsx @@ -3,16 +3,10 @@ import { useState, useEffect, useCallback } from "react" import { useSession } from "next-auth/react" import { useRouter } from "next/navigation" -import { Card, CardContent } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { PageHeader, Section } from "@/components/ui/page-header" -import { EmptyState } from "@/components/ui/empty-state" import { cn } from "@/lib/utils" import { Users, Shield, UserCheck, User as UserIcon, Plus, Pencil, Trash2, Send } from "lucide-react" @@ -23,31 +17,40 @@ interface User { role: "ADMIN" | "MANAGER" | "EMPLOYEE" managerId: string | null manager: { id: string; name: string } | null - _count?: { expenses: number } createdAt: string } -const roleAvatarStyle: Record = { - ADMIN: "bg-red-100 text-red-600", - MANAGER: "bg-amber-100 text-amber-600", - EMPLOYEE: "bg-blue-100 text-blue-600", +const ROLE_STYLE: Record = { + ADMIN: { pill: "bg-red-100 text-red-700", avatar: "bg-red-500", dot: "bg-red-400" }, + MANAGER: { pill: "bg-amber-100 text-amber-700", avatar: "bg-amber-500", dot: "bg-amber-400" }, + EMPLOYEE: { pill: "bg-blue-100 text-blue-700", avatar: "bg-blue-500", dot: "bg-blue-400" }, } -const EMPTY_FORM = { name: "", email: "", role: "EMPLOYEE" as const, managerId: "none" } +const AVATAR_COLORS = ["bg-blue-500","bg-violet-500","bg-emerald-500","bg-amber-500","bg-rose-500","bg-cyan-500","bg-indigo-500"] +function avatarColor(name: string) { + let h = 0 + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % AVATAR_COLORS.length + return AVATAR_COLORS[h] +} +function initials(name: string) { + return name.split(" ").map(n => n[0]).join("").slice(0, 2).toUpperCase() +} + +const EMPTY_FORM = { name: "", email: "", role: "EMPLOYEE" as "ADMIN" | "MANAGER" | "EMPLOYEE", managerId: "none" } export default function AdminUsersPage() { const { data: session, status } = useSession() const router = useRouter() - const [users, setUsers] = useState([]) - const [loading, setLoading] = useState(true) + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) const [showCreate, setShowCreate] = useState(false) - const [editUser, setEditUser] = useState(null) - const [form, setForm] = useState({ ...EMPTY_FORM }) - const [saving, setSaving] = useState(false) - const [error, setError] = useState("") - const [sendingPw, setSendingPw] = useState(null) - const [deleting, setDeleting] = useState(null) + const [editUser, setEditUser] = useState(null) + const [form, setForm] = useState({ ...EMPTY_FORM }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + const [sendingPw, setSendingPw] = useState(null) + const [deleting, setDeleting] = useState(null) useEffect(() => { if (status === "unauthenticated") router.push("/login") @@ -63,116 +66,71 @@ export default function AdminUsersPage() { useEffect(() => { fetchUsers() }, [fetchUsers]) - const managers = users.filter((u) => u.role === "MANAGER" || u.role === "ADMIN") - - const roleStats = { - ADMIN: users.filter((u) => u.role === "ADMIN").length, - MANAGER: users.filter((u) => u.role === "MANAGER").length, - EMPLOYEE: users.filter((u) => u.role === "EMPLOYEE").length, - } - - const statCards = [ - { label: "Administrators", value: roleStats.ADMIN, icon: Shield, iconBg: "bg-red-50", iconColor: "text-red-600" }, - { label: "Managers", value: roleStats.MANAGER, icon: UserCheck, iconBg: "bg-amber-50", iconColor: "text-amber-600" }, - { label: "Employees", value: roleStats.EMPLOYEE, icon: UserIcon, iconBg: "bg-blue-50", iconColor: "text-blue-600" }, + const managers = users.filter(u => u.role === "MANAGER" || u.role === "ADMIN") + const stats = [ + { label: "Administrators", value: users.filter(u => u.role === "ADMIN").length, icon: Shield, bg: "bg-red-50", color: "text-red-600" }, + { label: "Managers", value: users.filter(u => u.role === "MANAGER").length, icon: UserCheck, bg: "bg-amber-50", color: "text-amber-600" }, + { label: "Employees", value: users.filter(u => u.role === "EMPLOYEE").length, icon: UserIcon, bg: "bg-blue-50", color: "text-blue-600" }, ] - function openCreate() { - setForm({ ...EMPTY_FORM }) - setError("") - setShowCreate(true) - } - + function openCreate() { setForm({ name: "", email: "", role: "EMPLOYEE", managerId: "none" }); setError(""); setShowCreate(true) } function openEdit(user: User) { - setForm({ - name: user.name, - email: user.email, - role: user.role as typeof EMPTY_FORM.role, - managerId: user.managerId ?? "none", - }) - setError("") - setEditUser(user) + setForm({ name: user.name, email: user.email, role: user.role, managerId: user.managerId ?? "none" }) + setError(""); setEditUser(user) } async function handleCreate(e: React.FormEvent) { - e.preventDefault() - setSaving(true) - setError("") + e.preventDefault(); setSaving(true); setError("") const res = await fetch("/api/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: form.name, - email: form.email, - role: form.role, - managerId: form.managerId === "none" ? undefined : form.managerId, - }), + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: form.name, email: form.email, role: form.role, managerId: form.managerId === "none" ? undefined : form.managerId }), }) const data = await res.json() if (!res.ok) { setError(data.error || "Failed to create user"); setSaving(false); return } - setShowCreate(false) - fetchUsers() - setSaving(false) + setShowCreate(false); fetchUsers(); setSaving(false) } async function handleEdit(e: React.FormEvent) { - e.preventDefault() - if (!editUser) return - setSaving(true) - setError("") + e.preventDefault(); if (!editUser) return; setSaving(true); setError("") const res = await fetch(`/api/users/${editUser.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: form.name, - email: form.email, - role: form.role, - managerId: form.managerId === "none" ? null : form.managerId, - }), + method: "PATCH", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: form.name, email: form.email, role: form.role, managerId: form.managerId === "none" ? null : form.managerId }), }) const data = await res.json() if (!res.ok) { setError(data.error || "Failed to update user"); setSaving(false); return } - setEditUser(null) - fetchUsers() - setSaving(false) + setEditUser(null); fetchUsers(); setSaving(false) } async function handleDelete(user: User) { if (!confirm(`Delete ${user.name}? This cannot be undone.`)) return setDeleting(user.id) await fetch(`/api/users/${user.id}`, { method: "DELETE" }) - setDeleting(null) - fetchUsers() + setDeleting(null); fetchUsers() } async function handleSendPassword(user: User) { setSendingPw(user.id) - await fetch(`/api/users?id=${user.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "sendPassword" }), - }) - setSendingPw(null) - alert(`Password sent to ${user.email}`) + await fetch(`/api/users?id=${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "sendPassword" }) }) + setSendingPw(null); alert(`Password sent to ${user.email}`) } - const UserFormFields = () => ( + const FormFields = () => (
- - setForm({ ...form, name: e.target.value })} placeholder="Jane Smith" required /> + + setForm({ ...form, name: e.target.value })} placeholder="Jane Smith" required className="h-9 border-gray-200" />
- - setForm({ ...form, email: e.target.value })} placeholder="jane@company.com" required /> + + setForm({ ...form, email: e.target.value })} placeholder="jane@company.com" required className="h-9 border-gray-200" />
- - setForm({ ...form, role: v as typeof form.role })}> + Employee Manager @@ -181,181 +139,217 @@ export default function AdminUsersPage() {
- - setForm({ ...form, managerId: v })}> + — No manager — - {managers - .filter((m) => !editUser || m.id !== editUser.id) - .map((m) => ( - {m.name} ({m.role.toLowerCase()}) - ))} + {managers.filter(m => !editUser || m.id !== editUser.id).map(m => ( + {m.name} ({m.role.toLowerCase()}) + ))}
- {error &&

{error}

} + {error &&

{error}

}
) return ( -
-
- Admin - / - User Management +
+ + {/* Sticky header */} +
+
+ Admin + / + User Management + {users.length} member{users.length !== 1 ? "s" : ""} +
+
-
- - Add User - - } - /> - {/* Stats */} -
- {statCards.map((stat) => { - const Icon = stat.icon - return ( - - -
-
-

{stat.label}

-

{stat.value}

-
-
- -
+
+ + {/* Page title */} +
+
+ +
+
+

User Management

+

Manage employees, managers, and admin roles

+
+
+ + {/* Stat cards */} +
+ {stats.map(s => { + const Icon = s.icon + return ( +
+
+
- - - ) - })} -
+
+

{s.label}

+

{s.value}

+
+
+ ) + })} +
+ + {/* User list */} +
+
+
+

All Users

+

{users.length} total member{users.length !== 1 ? "s" : ""}

+
+
- {/* User list */} -
- - +
{loading ? ( -
Loading...
+
+
+ Loading users… +
) : users.length === 0 ? ( - } - title="No users yet" - description="Add your first team member" - action={{ label: "Add User", onClick: openCreate }} - /> +
+
+ +
+
+

No users yet

+

Add your first team member to get started.

+
+ +
) : ( -
- {users.map((user) => ( -
-
- - - {user.name?.[0] || "U"} - - -
-

{user.name}

-

{user.email}

+
+ {/* Table header */} +
+ Name + Email + Reports To + Role +
+ {users.map((user, idx) => { + const style = ROLE_STYLE[user.role] ?? ROLE_STYLE.EMPLOYEE + return ( +
+ {/* Name + avatar */} +
+
+ {initials(user.name)} +
+
+

{user.name}

+ {user.id === session?.user?.id && ( + You + )} +
-
-
- {user.manager && ( -
-

Reports to

-

{user.manager.name}

-
- )} - - {user.role.toLowerCase()} - + {/* Email */} +

{user.email}

+ + {/* Manager */} +

+ {user.manager?.name ?? } +

- {/* Actions — visible on hover */} -
- - - {user.id !== session?.user?.id && ( - - )} + + {user.id !== session?.user?.id && ( + + )} +
-
- ))} + ) + })}
)} - - -
+
+
+
- {/* Create user dialog */} + {/* Create dialog */} - Add New User + +
+ +
+ Add New User +
-
- -

+ + +

A temporary password will be auto-generated and emailed to the user.

-
- - +
+ +
- {/* Edit user dialog */} - !o && setEditUser(null)}> + {/* Edit dialog */} + !o && setEditUser(null)}> - Edit User — {editUser?.name} + +
+ +
+ Edit — {editUser?.name} +
-
- -
- - + + +
+ +
-
) } diff --git a/src/app/(app)/admin/workflow/page.tsx b/src/app/(app)/admin/workflow/page.tsx index 779f93f..94122bb 100644 --- a/src/app/(app)/admin/workflow/page.tsx +++ b/src/app/(app)/admin/workflow/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { StepBuilder, WorkflowStep, User } from '@/components/workflow-builder' +import { GitBranch, Info } from 'lucide-react' export default function WorkflowBuilderPage() { const [steps, setSteps] = useState([]) @@ -11,28 +12,22 @@ export default function WorkflowBuilderPage() { useEffect(() => { async function fetchWorkflow() { try { - const response = await fetch('/api/workflow') - if (response.ok) { - const data = await response.json() - if (data.steps && data.steps.length > 0) { - setSteps(data.steps.map((s: WorkflowStep) => ({ - ...s, - id: s.id || `step_${s.stepOrder}`, - }))) + const [wfRes, usersRes] = await Promise.all([ + fetch('/api/workflow'), + fetch('/api/users'), + ]) + if (wfRes.ok) { + const data = await wfRes.json() + if (data.steps?.length > 0) { + setSteps(data.steps.map((s: WorkflowStep) => ({ ...s, id: s.id || `step_${s.stepOrder}` }))) } } - - const usersResponse = await fetch('/api/users') - if (usersResponse.ok) { - const usersData = await usersResponse.json() - setUsers( - (Array.isArray(usersData) ? usersData : []).filter( - (u) => u.role === 'MANAGER' || u.role === 'ADMIN' - ) - ) + if (usersRes.ok) { + const usersData = await usersRes.json() + setUsers((Array.isArray(usersData) ? usersData : []).filter((u) => u.role === 'MANAGER' || u.role === 'ADMIN')) } - } catch (error) { - console.error('Failed to fetch workflow:', error) + } catch (e) { + console.error('Failed to fetch workflow:', e) } finally { setIsLoading(false) } @@ -41,7 +36,7 @@ export default function WorkflowBuilderPage() { }, []) const handleSave = async (workflowSteps: WorkflowStep[]) => { - const response = await fetch('/api/workflow', { + const res = await fetch('/api/workflow', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -55,41 +50,54 @@ export default function WorkflowBuilderPage() { })), }), }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to save workflow') + if (!res.ok) { + const err = await res.json() + throw new Error(err.error || 'Failed to save workflow') } - alert('Workflow saved successfully!') } - if (isLoading) { - return ( -
-
Loading workflow...
-
- ) - } - return ( -
-
- Admin - / - Approval Workflow +
+ {/* Header */} +
+ Admin + / + Workflow
-
-
+ +
+ + {/* Page title */} +
+
+ +
-

Approval Workflow

-

- Configure how expense approvals flow through your organization. -

+

Approval Workflow

+

Configure how expense approvals flow through your organization

+
- + {/* Info banner */} +
+ +

Steps are executed in order. Each step can require one or multiple approvers before moving to the next.

+ + {/* Builder */} + {isLoading ? ( +
+
+
+ Loading workflow... +
+
+ ) : ( +
+ +
+ )}
) diff --git a/src/app/(app)/dashboard/DashboardContent.tsx b/src/app/(app)/dashboard/DashboardContent.tsx index 05fcdf4..4d1e04d 100644 --- a/src/app/(app)/dashboard/DashboardContent.tsx +++ b/src/app/(app)/dashboard/DashboardContent.tsx @@ -19,7 +19,7 @@ interface Session { user: { id: string; name: string; email: string; role: strin interface Props { company: Company | null; showSetup: boolean; onShowSetupChange: (s: boolean) => void } function Skeleton({ className = "" }: { className?: string }) { - return
+ return
} export function DashboardContent({ company, showSetup, onShowSetupChange }: Props) { @@ -47,15 +47,15 @@ export function DashboardContent({ company, showSetup, onShowSetupChange }: Prop if (loading || !session) { return ( -
+
- - + +
-
- {[1,2,3,4].map(i => )} +
+ {[1,2,3,4].map(i => )}
- +
) } @@ -63,48 +63,52 @@ export function DashboardContent({ company, showSetup, onShowSetupChange }: Prop const isManagerOrAdmin = session.user.role === "MANAGER" || session.user.role === "ADMIN" const stats = [ - { label: "Total Submitted", value: data?.totalExpenses ?? 0, icon: Receipt, color: "#2563eb", bg: "#eff6ff" }, - { label: "Pending Review", value: data?.pendingCount ?? 0, icon: Clock, color: "#c2410c", bg: "#fff7ed" }, - { label: "Approved", value: data?.approvedCount ?? 0, icon: CheckCircle2, color: "#15803d", bg: "#f0fdf4" }, - { label: "Rejected", value: data?.rejectedCount ?? 0, icon: XCircle, color: "#dc2626", bg: "#fef2f2" }, + { label: "Total Submitted", value: data?.totalExpenses ?? 0, icon: Receipt, color: "#2563eb", bg: "#dbeafe" }, + { label: "Pending Review", value: data?.pendingCount ?? 0, icon: Clock, color: "#b45309", bg: "#fef3c7" }, + { label: "Approved", value: data?.approvedCount ?? 0, icon: CheckCircle2, color: "#15803d", bg: "#dcfce7" }, + { label: "Rejected", value: data?.rejectedCount ?? 0, icon: XCircle, color: "#dc2626", bg: "#fee2e2" }, ] return ( -
- {/* Breadcrumb / page header */} -
- Home - / - Dashboard -
- - - +
+ + {/* ── Sticky breadcrumb bar ── */} +
+
+ Home + / + Dashboard
+ + +
- {/* Content */} -
- {/* Welcome */} + {/* ── Page content ── */} +
+ + {/* Welcome heading */}
-

Welcome back, {session.user.name?.split(" ")[0]}

-

Here’s your expense overview for today.

+

+ Welcome back, {session.user.name?.split(" ")[0]} +

+

Here’s your expense overview for today.

{/* Stat cards */} -
+
{stats.map((s) => { const Icon = s.icon return ( -
-
- +
+
+
-

{s.label}

-

{s.value}

+

{s.label}

+

{s.value}

) @@ -112,20 +116,21 @@ export function DashboardContent({ company, showSetup, onShowSetupChange }: Prop
{/* Content grid */} -
- {/* Recent expenses table */} +
+ + {/* Recent expenses */}
-
- Recent Expenses - - View all +
+ Recent Expenses + + View all
{!data?.expenses?.length ? ( -
- -

No expenses yet

- Create one +
+ +

No expenses yet

+ Create one
) : ( @@ -140,7 +145,7 @@ export function DashboardContent({ company, showSetup, onShowSetupChange }: Prop {data.expenses.slice(0, 8).map((e) => ( window.location.href = `/expenses/${e.id}`}> - + + + {/* Employee */} + + + {/* Description */} + + + {/* Category */} + + + {/* Date */} + + + {/* Amount */} + + + {/* Status */} + + + {/* Actions */} + + + ) + })} + +
{e.description}{e.description} {e.category} {e.submittedCurrency} {e.submittedAmount.toFixed(2)} @@ -153,34 +158,34 @@ export function DashboardContent({ company, showSetup, onShowSetupChange }: Prop )} - {/* Pending approvals panel */} + {/* Pending approvals */} {isManagerOrAdmin && (
-
- Pending Approvals - - View +
+ Pending Approvals + + View
-
+
0 ? "#fff7ed" : "#f0fdf4" }} + className="w-16 h-16 rounded-2xl flex items-center justify-center" + style={{ background: (data?.pendingApprovals ?? 0) > 0 ? "#fef3c7" : "#dcfce7" }} > {(data?.pendingApprovals ?? 0) > 0 - ? - : } + ? + : }
-

{data?.pendingApprovals ?? 0}

-

+

{data?.pendingApprovals ?? 0}

+

{(data?.pendingApprovals ?? 0) > 0 ? "waiting for your review" : "All caught up!"}

{(data?.pendingApprovals ?? 0) > 0 && ( - )} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index d78c31a..a825a71 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,26 +1,17 @@ "use client" import { useSession } from "next-auth/react" -import { OdooTopbar } from "@/components/OdooTopbar" import { OdooSidebar } from "@/components/OdooSidebar" export default function AppLayout({ children }: { children: React.ReactNode }) { const { data: session } = useSession() return ( -
- {/* Global topbar */} - - -
- {/* Sidebar */} - - - {/* Main content */} -
- {children} -
-
+
+ +
+ {children} +
) } diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx index 39fb636..0cfcf5b 100644 --- a/src/app/(app)/notifications/page.tsx +++ b/src/app/(app)/notifications/page.tsx @@ -27,11 +27,11 @@ export default async function NotificationsPage() { }) return ( -
-
- Notifications +
+
+ Notifications
-
+
({ id: n.id, diff --git a/src/app/globals.css b/src/app/globals.css index aaf343b..8e26f72 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,672 +2,466 @@ @tailwind components; @tailwind utilities; -@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&family=Inter:wght@400;500;600;700;800&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;0,9..40,800&family=Inter:wght@400;500;600;700&display=swap'); -/* ─── Odoo-style design tokens ─────────────────────────────── */ +/* ─── Design tokens ─────────────────────────────────────────── */ @layer base { :root { - /* Page background — neutral gray like Odoo */ - --background: 0 0% 94%; /* #f0f0f0 */ - --foreground: 0 0% 13%; /* #212121 */ + --background: 220 16% 96%; + --foreground: 220 20% 12%; - /* White containers */ - --card: 0 0% 100%; - --card-foreground: 0 0% 13%; - --popover: 0 0% 100%; - --popover-foreground:0 0% 13%; + --card: 0 0% 100%; + --card-foreground: 220 20% 12%; + --popover: 0 0% 100%; + --popover-foreground: 220 20% 12%; - /* Primary — Odoo blue */ - --primary: 221 83% 53%; /* #2563eb */ - --primary-foreground:0 0% 100%; + --primary: 221 83% 53%; + --primary-foreground: 0 0% 100%; - --secondary: 0 0% 96%; - --secondary-foreground: 0 0% 13%; + --secondary: 220 14% 93%; + --secondary-foreground: 220 20% 12%; - --muted: 0 0% 96%; - --muted-foreground: 0 0% 45%; + --muted: 220 14% 93%; + --muted-foreground: 220 10% 48%; - --accent: 221 83% 97%; - --accent-foreground: 221 83% 40%; + --accent: 221 83% 96%; + --accent-foreground: 221 83% 40%; - /* Status */ - --destructive: 0 72% 51%; + --destructive: 0 72% 51%; --destructive-foreground: 0 0% 100%; - --success: 142 71% 36%; - --success-foreground:0 0% 100%; - --warning: 38 92% 44%; - --warning-foreground:0 0% 100%; + --success: 142 71% 36%; + --success-foreground: 0 0% 100%; + --warning: 38 92% 44%; + --warning-foreground: 0 0% 100%; - /* Borders — subtle like Odoo */ - --border: 0 0% 86%; /* #dcdcdc */ - --input: 0 0% 86%; - --ring: 221 83% 53%; - --radius: 0.375rem; /* 6px — tighter than before */ + --border: 220 13% 88%; + --input: 220 13% 88%; + --ring: 221 83% 53%; + --radius: 0.5rem; /* Surfaces */ - --surface-base: 0 0% 100%; - --surface-low: 0 0% 97%; - --surface: 0 0% 94%; - --surface-high: 0 0% 91%; - --surface-highest: 0 0% 88%; - - /* Surface container aliases */ - --surface-container-lowest: 0 0% 100%; - --surface-container-low: 0 0% 97%; - --surface-container: 0 0% 94%; - --surface-container-high: 0 0% 91%; - --surface-container-highest: 0 0% 88%; - - /* Sidebar — dark sidebar */ - --sidebar-bg: 220 13% 18%; /* #252b36 */ - --sidebar-fg: 220 9% 65%; - --sidebar-fg-active: 0 0% 100%; - --sidebar-border: 220 13% 24%; - --sidebar-item-hover:220 13% 26%; + --surface: 0 0% 100%; + --surface-low: 220 16% 98%; + --surface-high: 220 14% 93%; + + /* Sidebar */ + --sidebar-bg: 222 47% 11%; + --sidebar-fg: 220 15% 60%; + --sidebar-fg-active: 0 0% 100%; + --sidebar-border: 222 30% 17%; + --sidebar-item-hover: 222 30% 18%; --sidebar-item-active:221 83% 53%; - --sidebar-indicator: 221 83% 65%; - /* Topbar */ - --topbar-bg: 220 13% 18%; - --topbar-fg: 220 9% 80%; - --topbar-border: 220 13% 24%; - - /* Shadows — very subtle */ - --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.08); - --shadow-md: 0 2px 8px 0 rgb(0 0 0 / 0.08); + /* Shadows */ + --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.04); + --shadow-sm: 0 1px 4px rgb(0 0 0 / 0.06), 0 1px 2px rgb(0 0 0 / 0.04); + --shadow-md: 0 4px 12px rgb(0 0 0 / 0.08), 0 2px 4px rgb(0 0 0 / 0.04); + --shadow-lg: 0 8px 24px rgb(0 0 0 / 0.10), 0 4px 8px rgb(0 0 0 / 0.06); } } -/* ─── Base reset ────────────────────────────────────────────── */ +/* ─── Base ──────────────────────────────────────────────────── */ @layer base { * { @apply border-border; box-sizing: border-box; } - html { scroll-behavior: smooth; font-size: 14px; } + html { scroll-behavior: smooth; } body { @apply bg-background text-foreground antialiased; font-family: 'Inter', system-ui, -apple-system, sans-serif; - font-size: 13px; - line-height: 1.5; - color: #212121; - background: #f0f0f0; + font-size: 15px; + line-height: 1.6; } h1, h2, h3, h4, h5, h6 { font-family: 'DM Sans', system-ui, sans-serif; - font-weight: 600; - letter-spacing: -0.01em; - } - - code, pre, kbd { - font-family: 'JetBrains Mono', monospace; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; } input, select, textarea, button { font-family: inherit; - font-size: 13px; } } -/* ─── Odoo-style utility classes ────────────────────────────── */ +/* ─── Utilities ─────────────────────────────────────────────── */ @layer utilities { - /* ── Typography ── */ + /* Typography */ .o-page-title { - font-size: 16px; - font-weight: 700; - color: #212121; - letter-spacing: -0.01em; + font-family: 'DM Sans', system-ui, sans-serif; + font-size: 1.75rem; + font-weight: 800; + color: hsl(var(--foreground)); + letter-spacing: -0.03em; + line-height: 1.15; } .o-section-title { - font-size: 12px; - font-weight: 600; + font-size: 0.8rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.06em; - color: #717171; + letter-spacing: 0.07em; + color: hsl(var(--muted-foreground)); } .o-label { - font-size: 11px; - font-weight: 600; + font-size: 0.72rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.05em; - color: #888; - } - - .o-value { - font-size: 13px; - font-weight: 500; - color: #212121; + letter-spacing: 0.06em; + color: hsl(var(--muted-foreground)); } .o-muted { - font-size: 12px; - color: #888; + font-size: 0.9rem; + color: hsl(var(--muted-foreground)); } .o-metric { - font-size: 22px; - font-weight: 700; - letter-spacing: -0.02em; - color: #212121; + font-family: 'DM Sans', system-ui, sans-serif; + font-size: 2rem; + font-weight: 800; + letter-spacing: -0.04em; + color: hsl(var(--foreground)); line-height: 1; } - /* ── Containers ── */ + /* Containers */ .o-container { - background: #fff; - border: 1px solid #dcdcdc; - border-radius: 6px; - } - - .o-container-flat { - background: #fff; - border-top: 1px solid #dcdcdc; - border-bottom: 1px solid #dcdcdc; - } - - /* ── Topbar ── */ - .o-topbar { - height: 44px; - background: hsl(var(--topbar-bg)); - border-bottom: 1px solid hsl(var(--topbar-border)); - display: flex; - align-items: center; - position: sticky; - top: 0; - z-index: 50; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 12px; + box-shadow: var(--shadow-sm); } - /* ── Breadcrumb / secondary nav ── */ + /* Breadcrumb / page header — inline, inside padded content */ .o-breadcrumb { - height: 40px; - background: #fff; - border-bottom: 1px solid #dcdcdc; display: flex; align-items: center; - padding: 0 16px; - gap: 4px; - font-size: 13px; + padding: 0 0 20px 0; + gap: 6px; + font-size: 0.875rem; + border-bottom: 1px solid hsl(var(--border)); + margin-bottom: 24px; } - /* ── Toolbar ── */ + /* Toolbar */ .o-toolbar { - height: 40px; - background: #fff; - border-bottom: 1px solid #dcdcdc; + min-height: 56px; + background: hsl(var(--card)); + border-bottom: 1px solid hsl(var(--border)); display: flex; align-items: center; - padding: 0 12px; - gap: 6px; + padding: 0 20px; + gap: 10px; flex-shrink: 0; } - .o-toolbar-btn { - height: 28px; - padding: 0 10px; - font-size: 12px; - font-weight: 500; - border-radius: 4px; - border: 1px solid #dcdcdc; - background: #fff; - color: #212121; + /* Buttons */ + .o-btn { + height: 38px; + padding: 0 18px; + font-size: 0.9rem; + font-weight: 600; + border-radius: 8px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--card)); + color: hsl(var(--foreground)); cursor: pointer; display: inline-flex; align-items: center; - gap: 5px; + gap: 7px; white-space: nowrap; - transition: background 0.1s, border-color 0.1s; + transition: all 0.15s ease; + box-shadow: var(--shadow-xs); + letter-spacing: -0.01em; } - .o-toolbar-btn:hover { - background: #f5f5f5; - border-color: #bbb; + .o-btn:hover { + background: hsl(var(--secondary)); + border-color: hsl(220 13% 78%); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); } - .o-toolbar-btn-primary { - background: #2563eb; - border-color: #2563eb; + .o-btn:active { transform: translateY(0); } + + .o-btn-primary { + background: hsl(var(--primary)); + border-color: hsl(var(--primary)); color: #fff; + box-shadow: 0 2px 8px hsl(var(--primary) / 0.35); } - .o-toolbar-btn-primary:hover { - background: #1d4ed8; - border-color: #1d4ed8; + .o-btn-primary:hover { + background: hsl(221 83% 46%); + border-color: hsl(221 83% 46%); + box-shadow: 0 4px 16px hsl(var(--primary) / 0.45); + transform: translateY(-1px); } - .o-toolbar-divider { - width: 1px; - height: 20px; - background: #dcdcdc; - flex-shrink: 0; + .o-btn-sm { + height: 34px; + padding: 0 14px; + font-size: 0.85rem; + border-radius: 7px; } - /* ── Summary strip ── */ - .o-summary-strip { - display: flex; - background: #fff; - border-bottom: 1px solid #dcdcdc; - overflow-x: auto; + .o-btn-lg { + height: 46px; + padding: 0 26px; + font-size: 1rem; + border-radius: 10px; } - .o-summary-item { - flex: 1; - min-width: 120px; - padding: 8px 16px; - border-right: 1px solid #dcdcdc; - cursor: pointer; - transition: background 0.1s; - user-select: none; - } + /* Legacy compat aliases */ + .o-toolbar-btn { @apply o-btn o-btn-sm; } + .o-toolbar-btn-primary { @apply o-btn-primary; } - .o-summary-item:last-child { border-right: none; } - .o-summary-item:hover { background: #f5f5f5; } - .o-summary-item.active { - background: #eff6ff; - box-shadow: inset 0 -2px 0 #2563eb; - } - - .o-summary-item .o-summary-label { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #888; - display: flex; - align-items: center; - gap: 4px; + /* Stat card */ + .o-stat-card { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 14px; + padding: 20px 22px; + box-shadow: var(--shadow-sm); + transition: box-shadow 0.2s ease, transform 0.2s ease; } - .o-summary-item .o-summary-value { - font-size: 15px; - font-weight: 700; - color: #212121; - margin-top: 1px; - letter-spacing: -0.01em; + .o-stat-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); } - .o-summary-item.active .o-summary-value { color: #2563eb; } - - /* ── Table ── */ + /* Table */ .o-table { width: 100%; border-collapse: collapse; - font-size: 13px; + font-size: 0.875rem; } .o-table thead tr { - background: #f7f7f7; - border-bottom: 1px solid #dcdcdc; + background: hsl(var(--secondary)); + border-bottom: 1px solid hsl(var(--border)); } .o-table thead th { - padding: 8px 12px; + padding: 11px 16px; text-align: left; - font-size: 11px; - font-weight: 600; + font-size: 0.72rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.05em; - color: #888; + letter-spacing: 0.06em; + color: hsl(var(--muted-foreground)); white-space: nowrap; - user-select: none; - position: sticky; - top: 0; - background: #f7f7f7; - z-index: 5; } .o-table tbody tr { - border-bottom: 1px solid #ebebeb; - transition: background 0.08s; + border-bottom: 1px solid hsl(var(--border) / 0.6); + transition: background 0.1s ease; } .o-table tbody tr:last-child { border-bottom: none; } - .o-table tbody tr:hover { background: #f5f8ff; } - .o-table tbody tr.selected { background: #eff6ff; } + + .o-table tbody tr:hover { background: hsl(var(--accent)); } .o-table td { - padding: 9px 12px; + padding: 13px 16px; vertical-align: middle; - color: #212121; - } - - .o-table tfoot tr { - background: #f7f7f7; - border-top: 1px solid #dcdcdc; - font-weight: 600; - } - - .o-table tfoot td { padding: 8px 12px; } - - /* ── Row actions (hover reveal) ── */ - .o-row-actions { - opacity: 0; - transition: opacity 0.1s; - display: flex; - align-items: center; - gap: 2px; - justify-content: flex-end; - } - - .o-table tbody tr:hover .o-row-actions { opacity: 1; } - - .o-row-btn { - height: 24px; - width: 24px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 4px; - border: none; - background: transparent; - color: #888; - cursor: pointer; - transition: background 0.1s, color 0.1s; + color: hsl(var(--foreground)); } - .o-row-btn:hover { background: #e8e8e8; color: #212121; } - .o-row-btn.approve:hover { background: #dcfce7; color: #16a34a; } - .o-row-btn.reject:hover { background: #fee2e2; color: #dc2626; } - - /* ── Status badges ── */ + /* Status badges */ .o-badge { display: inline-flex; align-items: center; - gap: 4px; - padding: 2px 8px; + gap: 5px; + padding: 3px 10px; border-radius: 9999px; - font-size: 11px; - font-weight: 600; + font-size: 0.75rem; + font-weight: 700; letter-spacing: 0.02em; white-space: nowrap; } - .o-badge-dot { - width: 5px; - height: 5px; - border-radius: 50%; - flex-shrink: 0; - } + .o-badge-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } - .o-badge-draft { background: #f1f5f9; color: #475569; } - .o-badge-pending { background: #fff7ed; color: #c2410c; } + .o-badge-draft { background: hsl(220 14% 93%); color: hsl(220 10% 40%); } + .o-badge-pending { background: #fff3e0; color: #b45309; } .o-badge-approved { background: #f0fdf4; color: #15803d; } .o-badge-rejected { background: #fef2f2; color: #dc2626; } - .o-badge-info { background: #eff6ff; color: #1d4ed8; } + .o-badge-info { background: hsl(var(--accent)); color: hsl(var(--primary)); } - /* ── Stat card ── */ - .o-stat-card { - background: #fff; - border: 1px solid #dcdcdc; - border-radius: 6px; - padding: 14px 16px; - } - - /* ── Form field ── */ + /* Form */ .o-field-label { - font-size: 11px; + font-size: 0.8rem; font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #888; - margin-bottom: 4px; + color: hsl(var(--muted-foreground)); + margin-bottom: 6px; display: block; + letter-spacing: -0.01em; } .o-input { width: 100%; - height: 32px; - padding: 0 8px; - font-size: 13px; - border: 1px solid #dcdcdc; - border-radius: 4px; - background: #fff; - color: #212121; + height: 38px; + padding: 0 12px; + font-size: 0.9rem; + border: 1.5px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--card)); + color: hsl(var(--foreground)); outline: none; - transition: border-color 0.15s; + transition: border-color 0.15s, box-shadow 0.15s; } - .o-input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px rgb(37 99 235 / 0.12); } - - /* ── Pagination ── */ - .o-pagination { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - background: #f7f7f7; - border-top: 1px solid #dcdcdc; - font-size: 12px; - color: #888; + .o-input:focus { + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12); } - .o-page-btn { - height: 24px; - width: 24px; - display: inline-flex; + /* Auth */ + .o-auth-page { + min-height: 100vh; + background: hsl(var(--background)); + display: flex; align-items: center; justify-content: center; - border-radius: 4px; - border: 1px solid #dcdcdc; - background: #fff; - cursor: pointer; - color: #555; - transition: background 0.1s; - } - - .o-page-btn:hover:not(:disabled) { background: #f0f0f0; } - .o-page-btn:disabled { opacity: 0.4; cursor: not-allowed; } - - /* ── Sidebar ── */ - .o-sidebar { - width: 220px; - flex-shrink: 0; - background: hsl(var(--sidebar-bg)); - border-right: 1px solid hsl(var(--sidebar-border)); - display: flex; - flex-direction: column; - min-height: 100vh; + padding: 24px; } - .o-sidebar-logo { - height: 44px; - display: flex; - align-items: center; - padding: 0 16px; - border-bottom: 1px solid hsl(var(--sidebar-border)); - flex-shrink: 0; + .o-auth-card { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 16px; + width: 100%; + max-width: 420px; + padding: 40px; + box-shadow: var(--shadow-lg); } + /* Sidebar */ .o-nav-section { - padding: 12px 8px 4px; - font-size: 10px; - font-weight: 700; + padding: 16px 12px 6px; + font-size: 0.68rem; + font-weight: 800; text-transform: uppercase; - letter-spacing: 0.08em; - color: hsl(var(--sidebar-fg) / 0.5); - padding-left: 12px; + letter-spacing: 0.1em; + color: hsl(var(--sidebar-fg) / 0.4); } - .o-nav-item { + /* Empty state */ + .o-empty { display: flex; + flex-direction: column; align-items: center; - gap: 8px; - padding: 7px 12px; - border-radius: 4px; - margin: 1px 4px; - font-size: 13px; - font-weight: 500; - color: hsl(var(--sidebar-fg)); - text-decoration: none; - transition: background 0.1s, color 0.1s; - cursor: pointer; - } - - .o-nav-item:hover { - background: hsl(var(--sidebar-item-hover)); - color: hsl(var(--sidebar-fg-active)); - } - - .o-nav-item.active { - background: hsl(var(--sidebar-item-active)); - color: #fff; + justify-content: center; + padding: 56px 24px; + color: hsl(var(--muted-foreground)); + text-align: center; + gap: 10px; + font-size: 0.9rem; } - /* ── Auth page ── */ - .o-auth-page { - min-height: 100vh; - background: #f0f0f0; + /* Pagination */ + .o-pagination { display: flex; align-items: center; - justify-content: center; - padding: 24px; - } + justify-content: space-between; + padding: 10px 16px; + background: hsl(var(--secondary)); + border-top: 1px solid hsl(var(--border)); + font-size: 0.85rem; + color: hsl(var(--muted-foreground)); + border-radius: 0 0 12px 12px; + } + + /* Surfaces */ + .surface { background: hsl(var(--surface)); } + .surface-low { background: hsl(var(--surface-low)); } + .glass { background: rgba(255,255,255,0.85); backdrop-filter: blur(16px); border: 1px solid rgba(255,255,255,0.5); } + + /* Text helpers */ + .text-page-title { @apply o-page-title; } + .text-section-header { font-size: 0.95rem; font-weight: 700; color: hsl(var(--foreground)); letter-spacing: -0.01em; } + .text-body-muted { font-size: 0.875rem; color: hsl(var(--muted-foreground)); } + .text-label { @apply o-label; } + .text-metric { @apply o-metric; } + + /* Status pill */ + .status-pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 12px; border-radius: 9999px; font-size: 0.8rem; font-weight: 700; white-space: nowrap; } + .status-pill-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } + + /* Table helpers */ + .row-actions { opacity: 0; transition: opacity 0.15s; } + tr:hover .row-actions, .group:hover .row-actions { opacity: 1; } + .thead-sticky th { position: sticky; top: 0; z-index: 10; background: hsl(var(--secondary)); } - .o-auth-card { - background: #fff; - border: 1px solid #dcdcdc; - border-radius: 8px; - width: 100%; - max-width: 400px; - padding: 32px; - box-shadow: 0 2px 12px rgb(0 0 0 / 0.08); - } + /* Summary strip */ + .summary-strip { display: flex; background: hsl(var(--card)); border: 1px solid hsl(var(--border)); border-radius: 12px; overflow: hidden; box-shadow: var(--shadow-sm); } + .summary-strip-item { flex: 1; padding: 16px 20px; cursor: pointer; border-right: 1px solid hsl(var(--border)); transition: background 0.15s; text-align: left; } + .summary-strip-item:last-child { border-right: none; } + .summary-strip-item:hover { background: hsl(var(--secondary)); } + .summary-strip-item.active { background: hsl(var(--accent)); box-shadow: inset 0 -3px 0 hsl(var(--primary)); } - /* ── View switcher ── */ - .o-view-switcher { - display: flex; - border: 1px solid #dcdcdc; - border-radius: 4px; - overflow: hidden; - } + /* Enterprise toolbar */ + .enterprise-toolbar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid hsl(var(--border)); background: hsl(var(--card)); flex-wrap: wrap; min-height: 56px; } - .o-view-btn { - height: 28px; - width: 32px; - display: inline-flex; - align-items: center; - justify-content: center; - background: #fff; - border: none; - border-right: 1px solid #dcdcdc; - color: #888; - cursor: pointer; - transition: background 0.1s, color 0.1s; + /* Animations */ + @keyframes fadeUp { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } } - .o-view-btn:last-child { border-right: none; } - .o-view-btn:hover { background: #f5f5f5; color: #212121; } - .o-view-btn.active { background: #eff6ff; color: #2563eb; } - - /* ── Kanban card ── */ - .o-kanban-col { - background: #f5f5f5; - border-radius: 6px; - padding: 8px; - min-width: 220px; - flex: 1; + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } } - .o-kanban-card { - background: #fff; - border: 1px solid #dcdcdc; - border-radius: 4px; - padding: 10px 12px; - margin-bottom: 6px; - cursor: pointer; - transition: box-shadow 0.1s; + @keyframes scaleIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } } - .o-kanban-card:hover { box-shadow: 0 2px 8px rgb(0 0 0 / 0.1); } - - /* ── Scrollbar ── */ - ::-webkit-scrollbar { width: 5px; height: 5px; } - ::-webkit-scrollbar-track { background: transparent; } - ::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px; } - ::-webkit-scrollbar-thumb:hover { background: #aaa; } - - /* ── Selection ── */ - ::selection { background: rgb(37 99 235 / 0.15); color: #1d4ed8; } - - /* ── Checkbox ── */ - .o-checkbox { - width: 14px; - height: 14px; - border-radius: 3px; - border: 1px solid #bbb; - accent-color: #2563eb; - cursor: pointer; - flex-shrink: 0; + @keyframes shimmer { + from { background-position: 200% 0; } + to { background-position: -200% 0; } } - /* ── Divider ── */ - .o-divider { height: 1px; background: #dcdcdc; } + @keyframes pulse-ring { + 0% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.4); } + 70% { box-shadow: 0 0 0 8px hsl(var(--primary) / 0); } + 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0); } + } - /* ── Empty state ── */ - .o-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 48px 24px; - color: #aaa; - text-align: center; - gap: 8px; - } - - /* ── Shadows ── */ - .shadow-elevation-1 { box-shadow: var(--shadow-xs); } - .shadow-elevation-2 { box-shadow: var(--shadow-sm); } - .shadow-elevation-3 { box-shadow: var(--shadow-md); } - - /* ── Legacy compat ── */ - .surface { background: hsl(var(--surface)); } - .surface-low { background: hsl(var(--surface-low)); } - .surface-high { background: hsl(var(--surface-high)); } - .surface-highest { background: hsl(var(--surface-highest)); } - .glass { background: rgba(255,255,255,0.92); backdrop-filter: blur(12px); } - - .text-page-title { font-size: 16px; font-weight: 700; color: #212121; } - .text-section-header{ font-size: 13px; font-weight: 600; color: #212121; } - .text-body-muted { font-size: 12px; color: #888; } - .text-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #888; } - .text-metric { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; color: #212121; line-height: 1; } - .text-headline { font-weight: 700; letter-spacing: -0.01em; } - - .summary-strip { display: flex; border: 1px solid #dcdcdc; border-radius: 6px; overflow: hidden; background: #fff; } - .summary-strip-item { flex: 1; padding: 8px 14px; cursor: pointer; border-right: 1px solid #dcdcdc; transition: background 0.1s; } - .summary-strip-item:last-child { border-right: none; } - .summary-strip-item:hover { background: #f5f5f5; } - .summary-strip-item.active { background: #eff6ff; box-shadow: inset 0 -2px 0 #2563eb; } - .status-pill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 600; white-space: nowrap; } - .status-pill-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; } - .enterprise-toolbar { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-bottom: 1px solid #dcdcdc; background: #fff; flex-wrap: wrap; } - .row-actions { opacity: 0; transition: opacity 0.1s; } - tr:hover .row-actions, .group:hover .row-actions { opacity: 1; } - .thead-sticky th { position: sticky; top: 0; z-index: 10; background: #f7f7f7; } + .animate-fade-up { animation: fadeUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) both; } + .animate-fade-in { animation: fadeIn 0.3s ease both; } + .animate-scale-in { animation: scaleIn 0.3s cubic-bezier(0.23, 1, 0.32, 1) both; } + .animate-pulse-ring { animation: pulse-ring 2s ease infinite; } - /* ── Animations ── */ .animate-shimmer { - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 0.2) 20%, - rgba(255, 255, 255, 0.5) 60%, - rgba(255, 255, 255, 0) 100% - ); + background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%); background-size: 200% 100%; - animation: shimmer 2s infinite linear; + animation: shimmer 1.8s infinite linear; } - @keyframes shimmer { - from { background-position: 200% 0; } - to { background-position: -200% 0; } - } + /* Stagger delays */ + .delay-75 { animation-delay: 75ms; } + .delay-150 { animation-delay: 150ms; } + .delay-225 { animation-delay: 225ms; } + .delay-300 { animation-delay: 300ms; } + .delay-375 { animation-delay: 375ms; } - .transition-premium { - transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1); - } + .transition-smooth { transition: all 0.2s cubic-bezier(0.23, 1, 0.32, 1); } + .transition-premium { transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1); } } + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: hsl(var(--border)); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: hsl(var(--muted-foreground) / 0.4); } + +::selection { background: hsl(var(--primary) / 0.15); color: hsl(var(--primary)); } diff --git a/src/components/AdminExpenseTable.tsx b/src/components/AdminExpenseTable.tsx index 611b43e..3cf1cdc 100644 --- a/src/components/AdminExpenseTable.tsx +++ b/src/components/AdminExpenseTable.tsx @@ -3,38 +3,14 @@ import { useState, useMemo } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" import { AdminOverrideDialog } from "@/components/AdminOverrideDialog" import { ExpenseAmountCell } from "@/components/ExpenseAmountCell" import { cn } from "@/lib/utils" -import { - Search, - Filter, - Download, - RefreshCw, - CheckCircle2, - Clock, - AlertTriangle, - Flag, - ChevronLeft, - ChevronRight, - MoreHorizontal, - Check, - X, - Eye, - FileText +import { + Search, Download, RefreshCw, Clock, + AlertTriangle, X, Eye, FileText, Check, Flag, + ChevronLeft, ChevronRight, Filter } from "lucide-react" interface Expense { @@ -58,289 +34,172 @@ interface AdminExpenseTableProps { employees: { id: string; name: string; email: string }[] } -const ITEMS_PER_PAGE = 10 +const ITEMS_PER_PAGE = 15 + +const STATUS_CONFIG: Record = { + DRAFT: { label: "Draft", pill: "bg-gray-100 text-gray-600", dot: "bg-gray-400" }, + PENDING: { label: "Pending", pill: "bg-amber-100 text-amber-700", dot: "bg-amber-500" }, + APPROVED: { label: "Approved", pill: "bg-emerald-100 text-emerald-700", dot: "bg-emerald-500" }, + REJECTED: { label: "Rejected", pill: "bg-red-100 text-red-700", dot: "bg-red-500" }, +} + +const CATEGORY_COLORS: Record = { + TRAVEL: "bg-violet-100 text-violet-700", + MEALS: "bg-orange-100 text-orange-700", + ACCOMMODATION: "bg-blue-100 text-blue-700", + EQUIPMENT: "bg-cyan-100 text-cyan-700", + TRANSPORTATION:"bg-teal-100 text-teal-700", + SUPPLIES: "bg-lime-100 text-lime-700", + OTHER: "bg-gray-100 text-gray-600", +} + +function initials(name: string) { + return name.split(" ").map(n => n[0]).join("").slice(0, 2).toUpperCase() +} + +const AVATAR_COLORS = [ + "bg-blue-500", "bg-violet-500", "bg-emerald-500", + "bg-amber-500", "bg-rose-500", "bg-cyan-500", "bg-indigo-500", +] +function avatarColor(name: string) { + let h = 0 + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % AVATAR_COLORS.length + return AVATAR_COLORS[h] +} export function AdminExpenseTable({ expenses, companyCurrency, employees }: AdminExpenseTableProps) { const router = useRouter() - const [searchQuery, setSearchQuery] = useState("") - const [statusFilter, setStatusFilter] = useState("ALL") - const [employeeFilter, setEmployeeFilter] = useState("ALL") - const [dateFrom, setDateFrom] = useState("") - const [dateTo, setDateTo] = useState("") - const [currentPage, setCurrentPage] = useState(1) - const [selectedExpenses, setSelectedExpenses] = useState>(new Set()) - const [bulkActionLoading, setBulkActionLoading] = useState(null) + const [search, setSearch] = useState("") + const [statusFilter, setStatusFilter] = useState("ALL") + const [employeeFilter, setEmployeeFilter] = useState("ALL") + const [dateFrom, setDateFrom] = useState("") + const [dateTo, setDateTo] = useState("") + const [page, setPage] = useState(1) + const [selected, setSelected] = useState>(new Set()) const [overrideDialog, setOverrideDialog] = useState<{ - open: boolean - expenseId: string - expenseDescription: string - action: "APPROVE" | "REJECT" + open: boolean; expenseId: string; expenseDescription: string; action: "APPROVE" | "REJECT" }>({ open: false, expenseId: "", expenseDescription: "", action: "APPROVE" }) const stats = useMemo(() => ({ - total: expenses.length, - pending: expenses.filter(e => e.status === "PENDING").length, - overridden: expenses.filter(e => e.isAdminOverride).length, - flagged: expenses.filter(e => e.status === "PENDING" && e.isAdminOverride).length, + total: expenses.length, + pending: expenses.filter(e => e.status === "PENDING").length, + overridden:expenses.filter(e => e.isAdminOverride).length, + rejected: expenses.filter(e => e.status === "REJECTED").length, }), [expenses]) - const filteredExpenses = useMemo(() => { - return expenses.filter((expense) => { - if (searchQuery) { - const query = searchQuery.toLowerCase() - if (!expense.description.toLowerCase().includes(query) && - !expense.employee.name.toLowerCase().includes(query) && - !expense.employee.email.toLowerCase().includes(query)) { - return false - } - } - if (statusFilter !== "ALL") { - if (statusFilter === "OVERRIDE" && !expense.isAdminOverride) return false - if (statusFilter !== "OVERRIDE" && expense.status !== statusFilter) return false - } - if (employeeFilter !== "ALL" && expense.employee.email !== employeeFilter) return false - if (dateFrom && new Date(expense.date) < new Date(dateFrom)) return false - if (dateTo && new Date(expense.date) > new Date(dateTo)) return false - return true - }) - }, [expenses, searchQuery, statusFilter, employeeFilter, dateFrom, dateTo]) - - const totalPages = Math.ceil(filteredExpenses.length / ITEMS_PER_PAGE) - const paginatedExpenses = filteredExpenses.slice( - (currentPage - 1) * ITEMS_PER_PAGE, - currentPage * ITEMS_PER_PAGE - ) - - const resetFilters = () => { - setSearchQuery("") - setStatusFilter("ALL") - setEmployeeFilter("ALL") - setDateFrom("") - setDateTo("") - setCurrentPage(1) - setSelectedExpenses(new Set()) - } - - const hasActiveFilters = searchQuery || statusFilter !== "ALL" || employeeFilter !== "ALL" || dateFrom || dateTo - - const toggleSelectAll = () => { - if (selectedExpenses.size === paginatedExpenses.length) { - setSelectedExpenses(new Set()) - } else { - setSelectedExpenses(new Set(paginatedExpenses.map(e => e.id))) + const filtered = useMemo(() => expenses.filter(e => { + if (search) { + const q = search.toLowerCase() + if (!e.description.toLowerCase().includes(q) && + !e.employee.name.toLowerCase().includes(q) && + !e.employee.email.toLowerCase().includes(q)) return false } - } - - const toggleSelect = (id: string) => { - const newSet = new Set(selectedExpenses) - if (newSet.has(id)) { - newSet.delete(id) - } else { - newSet.add(id) + if (statusFilter !== "ALL") { + if (statusFilter === "OVERRIDE" && !e.isAdminOverride) return false + if (statusFilter !== "OVERRIDE" && e.status !== statusFilter) return false } - setSelectedExpenses(newSet) - } - - const exportToCSV = () => { - const headers = ["Employee", "Email", "Description", "Category", "Date", "Amount", "Currency", "Converted Amount", "Status", "Admin Override"] - const rows = filteredExpenses.map(e => [ - e.employee.name, - e.employee.email, - e.description, - e.category, - new Date(e.date).toLocaleDateString(), - e.submittedAmount, - e.submittedCurrency, - e.convertedAmount, - e.status, - e.isAdminOverride ? "Yes" : "No" - ]) - - const csvContent = [headers, ...rows].map(row => row.join(",")).join("\n") - const blob = new Blob([csvContent], { type: "text/csv" }) - const url = window.URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `expenses-${new Date().toISOString().split("T")[0]}.csv` - a.click() - window.URL.revokeObjectURL(url) - } - - const getStatusBadge = (status: string, isAdminOverride: boolean) => { - if (isAdminOverride) { - return Overridden - } - - const config = { - DRAFT: { variant: "gray" as const, label: "Draft", color: "text-gray-600 bg-gray-100" }, - PENDING: { variant: "warning" as const, label: "Pending", color: "text-amber-600 bg-amber-100" }, - APPROVED: { variant: "success" as const, label: "Approved", color: "text-emerald-600 bg-emerald-100" }, - REJECTED: { variant: "destructive" as const, label: "Rejected", color: "text-red-600 bg-red-100" }, - } - - const style = config[status as keyof typeof config] || config.DRAFT - return ( - - {status === "PENDING" && } - {status === "APPROVED" && } - {status === "REJECTED" && } - {style.label} - - ) - } - - const handleOverrideSuccess = () => { - router.refresh() - } - - const handleBulkAction = async (action: "APPROVE" | "REJECT") => { - if (selectedExpenses.size === 0) return - - setBulkActionLoading(action) - try { - const response = await fetch("/api/admin/expenses/bulk", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - expenseIds: Array.from(selectedExpenses), - action, - }), - }) - - if (response.ok) { - setSelectedExpenses(new Set()) - router.refresh() - } else { - const error = await response.json() - alert(error.error || "Failed to perform bulk action") - } - } catch (error) { - console.error("Bulk action error:", error) - alert("Failed to perform bulk action") - } finally { - setBulkActionLoading(null) - } - } - - const openApproveDialog = (expense: Expense) => { - setOverrideDialog({ - open: true, - expenseId: expense.id, - expenseDescription: expense.description, - action: "APPROVE", - }) - } - - const openRejectDialog = (expense: Expense) => { - setOverrideDialog({ - open: true, - expenseId: expense.id, - expenseDescription: expense.description, - action: "REJECT", - }) + if (employeeFilter !== "ALL" && e.employee.email !== employeeFilter) return false + if (dateFrom && new Date(e.date) < new Date(dateFrom)) return false + if (dateTo && new Date(e.date) > new Date(dateTo)) return false + return true + }), [expenses, search, statusFilter, employeeFilter, dateFrom, dateTo]) + + const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)) + const paginated = filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE) + const hasFilters = search || statusFilter !== "ALL" || employeeFilter !== "ALL" || dateFrom || dateTo + + const reset = () => { setSearch(""); setStatusFilter("ALL"); setEmployeeFilter("ALL"); setDateFrom(""); setDateTo(""); setPage(1); setSelected(new Set()) } + + const toggleAll = () => setSelected(selected.size === paginated.length ? new Set() : new Set(paginated.map(e => e.id))) + const toggleOne = (id: string) => { const s = new Set(selected); if (s.has(id)) { s.delete(id) } else { s.add(id) }; setSelected(s) } + + const exportCSV = () => { + const rows = [ + ["Employee","Email","Description","Category","Date","Amount","Currency","Converted","Status","Override"], + ...filtered.map(e => [e.employee.name, e.employee.email, e.description, e.category, + new Date(e.date).toLocaleDateString(), e.submittedAmount, e.submittedCurrency, + e.convertedAmount, e.status, e.isAdminOverride ? "Yes" : "No"]) + ] + const blob = new Blob([rows.map(r => r.join(",")).join("\n")], { type: "text/csv" }) + const a = Object.assign(document.createElement("a"), { href: URL.createObjectURL(blob), download: `expenses-${new Date().toISOString().split("T")[0]}.csv` }) + a.click(); URL.revokeObjectURL(a.href) } return ( -
-
- setStatusFilter("ALL")}> - -
-
-

Total Expenses

-

{stats.total}

-
-
- -
-
-
-
- - setStatusFilter("PENDING")}> - -
-
-

Pending

-

{stats.pending}

-
-
- -
-
-
-
- - setStatusFilter("OVERRIDE")}> - -
-
-

Admin Overridden

-

{stats.overridden}

-
-
- -
-
-
-
- - setStatusFilter("REJECTED")}> - -
-
-

Rejected

-

- {expenses.filter(e => e.status === "REJECTED").length} -

-
-
- -
-
-
-
+
+ + {/* ── Sticky header bar ── */} +
+
+ Admin + / + All Expenses + {expenses.length} total +
+
- - -
- - - Filters - -
- - {hasActiveFilters && ( - - )} -
+
+ + {/* ── Stat cards ── */} +
+ {[ + { label: "Total Expenses", value: stats.total, icon: FileText, bg: "bg-blue-50", iconCls: "text-blue-600", filter: "ALL" }, + { label: "Pending", value: stats.pending, icon: Clock, bg: "bg-amber-50", iconCls: "text-amber-600", filter: "PENDING" }, + { label: "Admin Overridden",value: stats.overridden, icon: AlertTriangle, bg: "bg-purple-50", iconCls: "text-purple-600", filter: "OVERRIDE" }, + { label: "Rejected", value: stats.rejected, icon: X, bg: "bg-red-50", iconCls: "text-red-600", filter: "REJECTED" }, + ].map(s => { + const Icon = s.icon + const isActive = statusFilter === s.filter + return ( + + ) + })} +
+ + {/* ── Filter bar ── */} +
+
+ + Filters + {hasFilters && ( + + )}
- - -
-
- - +
+ + { - setSearchQuery(e.target.value) - setCurrentPage(1) - }} - className="pl-10" + value={search} + onChange={e => { setSearch(e.target.value); setPage(1) }} + className="w-full h-9 pl-9 pr-3 text-sm border border-gray-200 rounded-lg bg-gray-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all placeholder:text-gray-400" />
- - { setStatusFilter(v); setPage(1) }}> + + All Statuses @@ -351,258 +210,211 @@ export function AdminExpenseTable({ expenses, companyCurrency, employees }: Admi Overridden - - { setEmployeeFilter(v); setPage(1) }}> + + All Employees - {employees.map((emp) => ( - - {emp.name} - + {employees.map(emp => ( + {emp.name} ))} - -
- { setDateFrom(e.target.value); setCurrentPage(1) }} - className="w-36" - placeholder="From" - /> - { setDateTo(e.target.value); setCurrentPage(1) }} - className="w-36" - placeholder="To" - /> + { setDateFrom(e.target.value); setPage(1) }} + className="h-9 px-3 text-sm border border-gray-200 rounded-lg bg-gray-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all text-gray-600 w-[140px]" /> + { setDateTo(e.target.value); setPage(1) }} + className="h-9 px-3 text-sm border border-gray-200 rounded-lg bg-gray-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all text-gray-600 w-[140px]" /> +
+
+ + {/* ── Bulk action bar ── */} + {selected.size > 0 && ( +
+ {selected.size} selected +
+ +
- - - - {selectedExpenses.size > 0 && ( - - -
- - {selectedExpenses.size} expense{selectedExpenses.size > 1 ? "s" : ""} selected - -
- - + )} + + {/* ── Table ── */} +
+ {filtered.length === 0 ? ( +
+
+
-
- - - )} - - - - {filteredExpenses.length === 0 ? ( -
-
- +
+

No expenses found

+

+ {hasFilters ? "Try adjusting your filters." : "Get started by submitting your first expense report."} +

-

No expenses found

-

- {hasActiveFilters - ? "No expenses match your current filters. Try adjusting your search criteria." - : "Get started by submitting your first expense report."} -

- {hasActiveFilters && ( - + {hasFilters && ( + )}
) : ( <> - - - - - 0} - onChange={toggleSelectAll} - className="rounded border-border" - /> - - Employee - Description - Category - Date - Amount - Status - Actions - - - - {paginatedExpenses.map((expense) => ( - - { e.stopPropagation(); toggleSelect(expense.id) }}> - toggleSelect(expense.id)} - className="rounded border-border" +
+
+ + + + {["Employee","Description","Category","Date","Amount","Status",""].map(h => ( + + ))} + + + + {paginated.map((e, idx) => { + const status = STATUS_CONFIG[e.status] ?? STATUS_CONFIG.DRAFT + const catCls = CATEGORY_COLORS[e.category] ?? CATEGORY_COLORS.OTHER + const isSelected = selected.has(e.id) + return ( + - - - ))} - -
+ 0} + onChange={toggleAll} + className="h-4 w-4 rounded border-gray-300 accent-blue-600 cursor-pointer" /> - - router.push(`/expenses/${expense.id}`)}> -
-
- {expense.employee.name[0]} -
-
-

- {expense.employee.name} -

-

- {expense.employee.email} -

-
-
-
- router.push(`/expenses/${expense.id}`)}> - - {expense.description} - - - router.push(`/expenses/${expense.id}`)}> - - {expense.category} - - - router.push(`/expenses/${expense.id}`)}> - {new Date(expense.date).toLocaleDateString()} - - router.push(`/expenses/${expense.id}`)}> - - - router.push(`/expenses/${expense.id}`)}> - {getStatusBadge(expense.status, expense.isAdminOverride)} - - e.stopPropagation()}> -
- - - - {!expense.isAdminOverride && expense.status !== "APPROVED" && expense.status !== "REJECTED" ? ( - <> - - - - ) : ( - +
+ {h} +
+ onClick={() => router.push(`/expenses/${e.id}`)} + > +
{ ev.stopPropagation(); toggleOne(e.id) }}> + toggleOne(e.id)} + className="h-4 w-4 rounded border-gray-300 accent-blue-600 cursor-pointer" /> + +
+
+ {initials(e.employee.name)} +
+
+

{e.employee.name}

+

{e.employee.email}

+
+
+
+
+ {e.description} + {e.isAdminOverride && ( + + Override + + )} +
+
+ + {e.category} + + + {new Date(e.date).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })} + + + + + + {status.label} + + ev.stopPropagation()}> +
+ + + + {!e.isAdminOverride && e.status !== "APPROVED" && e.status !== "REJECTED" && ( + <> + + + + )} +
+
+
-
-

- Showing {(currentPage - 1) * ITEMS_PER_PAGE + 1} to {Math.min(currentPage * ITEMS_PER_PAGE, filteredExpenses.length)} of {filteredExpenses.length} expenses -

-
- - - Page {currentPage} of {totalPages || 1} - - + {page} / {totalPages} + +
)} - - +
+
setOverrideDialog({ ...overrideDialog, open: false })} - onSuccess={handleOverrideSuccess} + onClose={() => setOverrideDialog(d => ({ ...d, open: false }))} + onSuccess={() => router.refresh()} />
) diff --git a/src/components/ApprovalTable.tsx b/src/components/ApprovalTable.tsx index a39b714..e42fd6d 100644 --- a/src/components/ApprovalTable.tsx +++ b/src/components/ApprovalTable.tsx @@ -173,111 +173,100 @@ export function ApprovalTable({ approvals, companyCurrency }: ApprovalTableProps const rowActions = (e: Expense) => ( <> - - + - + + className="h-8 w-8 flex items-center justify-center rounded-lg hover:bg-red-50 text-muted-foreground hover:text-red-600 transition-colors disabled:opacity-40" + > ) return ( -
- {/* Page header */} -
-
-

Pending Approvals

-

+

+ + {/* Sticky breadcrumb bar */} +
+
+ Pending Approvals + {approvals.length} request{approvals.length !== 1 ? "s" : ""} awaiting review -

+
{approvals.length > 0 && ( -
+
)}
- {/* Table container */} -
- { setSearch(v); setPage(1) }} - searchPlaceholder="Search by employee, description..." - selectionCount={selectedKeys.size} - bulkActions={ - <> - - - - } - /> + {/* Page content */} +
+
+ { setSearch(v); setPage(1) }} + searchPlaceholder="Search by employee, description..." + selectionCount={selectedKeys.size} + bulkActions={ + <> + + + + } + /> - e.id} - onRowClick={(e) => router.push(`/expenses/${e.id}`)} - selectedKeys={selectedKeys} - onSelectChange={setSelectedKeys} - rowActions={rowActions} - emptyMessage="No pending approvals — you're all caught up" - emptyIcon={} - className="border-0 rounded-none" - /> + e.id} + onRowClick={(e) => router.push(`/expenses/${e.id}`)} + selectedKeys={selectedKeys} + onSelectChange={setSelectedKeys} + rowActions={rowActions} + emptyMessage="No pending approvals — you're all caught up" + emptyIcon={} + className="border-0 rounded-none" + /> - + +
) diff --git a/src/components/ExpenseTable.tsx b/src/components/ExpenseTable.tsx index 6bc7ff6..ce28ee6 100644 --- a/src/components/ExpenseTable.tsx +++ b/src/components/ExpenseTable.tsx @@ -9,7 +9,6 @@ import { SummaryStrip, SummaryStripItem } from "@/components/ui/summary-strip" import { EnterpriseToolbar } from "@/components/ui/enterprise-toolbar" import { TablePagination } from "@/components/ui/table-pagination" import { StatusPill } from "@/components/ui/status-pill" -import { Button } from "@/components/ui/button" import { Select, SelectContent, @@ -223,9 +222,9 @@ export function ExpenseTable({ {e.status === "DRAFT" && ( @@ -234,101 +233,105 @@ export function ExpenseTable({ title="Delete" disabled={deleting === e.id} onClick={() => handleDelete(e.id)} - className="h-6 w-6 flex items-center justify-center rounded hover:bg-red-50 text-muted-foreground hover:text-red-600 transition-colors" + className="h-8 w-8 flex items-center justify-center rounded-lg hover:bg-red-50 text-muted-foreground hover:text-red-600 transition-colors" > - + )} ) return ( -
- {/* Page header */} -
-
-

Expenses

-

{expenses.length} total records

+
+ + {/* Sticky breadcrumb bar */} +
+
+ Expenses + {expenses.length} total records
{showCreateButton && ( - + )}
- {/* Summary strip */} - + {/* Page content */} +
- {/* Table container */} -
- - - - ) : undefined - } - search={search} - onSearchChange={(v) => { setSearch(v); setPage(1) }} - searchPlaceholder="Search expenses..." - filters={ -
- - -
- } - selectionCount={selectedKeys.size} - bulkActions={ - - } + {/* Summary strip */} + - e.id} - onRowClick={(e) => router.push(`/expenses/${e.id}`)} - selectedKeys={selectedKeys} - onSelectChange={setSelectedKeys} - rowActions={rowActions} - emptyMessage="No expenses match your filters" - emptyIcon={} - className="border-0 rounded-none" - /> + {/* Table card */} +
+ + + + ) : undefined + } + search={search} + onSearchChange={(v) => { setSearch(v); setPage(1) }} + searchPlaceholder="Search expenses..." + filters={ +
+ + +
+ } + selectionCount={selectedKeys.size} + bulkActions={ + + } + /> - + e.id} + onRowClick={(e) => router.push(`/expenses/${e.id}`)} + selectedKeys={selectedKeys} + onSelectChange={setSelectedKeys} + rowActions={rowActions} + emptyMessage="No expenses match your filters" + emptyIcon={} + className="border-0 rounded-none" + /> + + +
) diff --git a/src/components/OdooSidebar.tsx b/src/components/OdooSidebar.tsx index 17228f2..1e8a71f 100644 --- a/src/components/OdooSidebar.tsx +++ b/src/components/OdooSidebar.tsx @@ -1,10 +1,11 @@ "use client" import { usePathname } from "next/navigation" +import { signOut } from "next-auth/react" import Link from "next/link" import { LayoutDashboard, Receipt, CheckSquare, Settings, - FileText, Users, Workflow, ChevronRight, + FileText, Users, Workflow, ChevronRight, Bell, LogOut, } from "lucide-react" interface NavItem { @@ -15,18 +16,22 @@ interface NavItem { section?: string } +interface Session { + user: { name?: string | null; email?: string | null; role?: string } +} + const NAV_ITEMS: NavItem[] = [ - { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, - { href: "/expenses", label: "Expenses", icon: Receipt }, - { href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "ADMIN"] }, - { href: "/admin/expenses", label: "All Expenses", icon: FileText, roles: ["ADMIN"], section: "Admin" }, - { href: "/admin/users", label: "Users", icon: Users, roles: ["ADMIN"], section: "Admin" }, - { href: "/admin/workflow", label: "Workflow", icon: Workflow, roles: ["ADMIN"], section: "Admin" }, + { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { href: "/expenses", label: "Expenses", icon: Receipt }, + { href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "ADMIN"] }, + { href: "/admin/expenses", label: "All Expenses", icon: FileText, roles: ["ADMIN"], section: "Admin" }, + { href: "/admin/users", label: "Users", icon: Users, roles: ["ADMIN"], section: "Admin" }, + { href: "/admin/workflow", label: "Workflow", icon: Workflow, roles: ["ADMIN"], section: "Admin" }, { href: "/admin/approval-rules", label: "Approval Rules", icon: CheckSquare, roles: ["ADMIN"], section: "Admin" }, - { href: "/admin/settings", label: "Settings", icon: Settings, roles: ["ADMIN"], section: "Admin" }, + { href: "/admin/settings", label: "Settings", icon: Settings, roles: ["ADMIN"], section: "Admin" }, ] -export function OdooSidebar({ userRole }: { userRole?: string }) { +export function OdooSidebar({ userRole, session }: { userRole?: string; session?: Session | null }) { const pathname = usePathname() const filtered = NAV_ITEMS.filter((item) => { @@ -38,21 +43,106 @@ export function OdooSidebar({ userRole }: { userRole?: string }) { const adminItems = filtered.filter((i) => i.section === "Admin") return ( -