first commit
parent
023824f729
commit
f64a0b4b7c
@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Check, X, Eye, Loader2 } from "lucide-react";
|
||||||
|
import { updateRequestStatus } from "@/app/lib/actions";
|
||||||
|
|
||||||
|
export function ApprovalActions({ id, type }: { id: string, type: 'reimbursement' | 'overtime' }) {
|
||||||
|
const [isUpdating, setIsUpdating] = useState<'APPROVED' | 'REJECTED' | null>(null);
|
||||||
|
|
||||||
|
async function handleUpdate(status: 'APPROVED' | 'REJECTED') {
|
||||||
|
setIsUpdating(status);
|
||||||
|
try {
|
||||||
|
await updateRequestStatus(id, type, status);
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to update status.");
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" className="rounded-xl w-10 h-10 p-0 text-neutral-400 hover:text-indigo-600 hover:bg-indigo-50 transition-all">
|
||||||
|
<Eye className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleUpdate('APPROVED')}
|
||||||
|
disabled={!!isUpdating}
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-xl w-10 h-10 p-0 text-neutral-400 hover:text-emerald-600 hover:bg-emerald-50 transition-all"
|
||||||
|
>
|
||||||
|
{isUpdating === 'APPROVED' ? <Loader2 className="w-5 h-5 animate-spin" /> : <Check className="w-5 h-5" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleUpdate('REJECTED')}
|
||||||
|
disabled={!!isUpdating}
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-xl w-10 h-10 p-0 text-neutral-400 hover:text-rose-600 hover:bg-rose-50 transition-all"
|
||||||
|
>
|
||||||
|
{isUpdating === 'REJECTED' ? <Loader2 className="w-5 h-5 animate-spin" /> : <X className="w-5 h-5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApprovalActions } from "./approval-actions";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
ArrowRight
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default async function ApprovalsPage() {
|
||||||
|
const [reimbursements, overtimes] = await Promise.all([
|
||||||
|
prisma.reimbursement.findMany({
|
||||||
|
where: { status: 'PENDING' },
|
||||||
|
include: { user: true },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
}),
|
||||||
|
prisma.overtime.findMany({
|
||||||
|
where: { status: 'PENDING' },
|
||||||
|
include: { user: true },
|
||||||
|
orderBy: { date: 'desc' }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allRequests = [
|
||||||
|
...reimbursements.map(r => ({ ...r, type: 'reimbursement' as const })),
|
||||||
|
...overtimes.map(o => ({ ...o, type: 'overtime' as const, amount: o.hours * 45, description: o.reason }))
|
||||||
|
].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Approval Queue</h2>
|
||||||
|
<p className="text-neutral-500 mt-1">Review and process worker reimbursement and overtime requests.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800 p-1 rounded-2xl">
|
||||||
|
<Button variant="ghost" className="bg-white dark:bg-neutral-700 shadow-sm rounded-xl px-6 font-bold text-sm">Pending ({allRequests.length})</Button>
|
||||||
|
<Button variant="ghost" className="text-neutral-500 rounded-xl px-6 font-bold text-sm">Processed</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[2.5rem] overflow-hidden">
|
||||||
|
<div className="p-8 flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-neutral-100 dark:border-neutral-800">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||||
|
<Input placeholder="Search requests..." className="pl-10 h-12 rounded-xl bg-neutral-50 dark:bg-neutral-800 border-none shadow-none" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" className="rounded-xl border-neutral-200 h-12 flex items-center gap-2 font-bold transition-all hover:bg-neutral-50">
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
Request Type
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-neutral-50/50 dark:bg-neutral-800/50 border-b border-neutral-100 dark:border-neutral-800">
|
||||||
|
<th className="p-6 px-8 text-xs font-black uppercase tracking-[0.15em] text-neutral-400">User</th>
|
||||||
|
<th className="p-6 text-xs font-black uppercase tracking-[0.15em] text-neutral-400">Type</th>
|
||||||
|
<th className="p-6 text-xs font-black uppercase tracking-[0.15em] text-neutral-400">Description</th>
|
||||||
|
<th className="p-6 text-xs font-black uppercase tracking-[0.15em] text-neutral-400">Amount</th>
|
||||||
|
<th className="p-6 px-8 text-xs font-black uppercase tracking-[0.15em] text-neutral-400 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{allRequests.map((req) => (
|
||||||
|
<tr key={req.id} className="hover:bg-neutral-50/50 transition-all group">
|
||||||
|
<td className="p-6 px-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-indigo-50 dark:bg-indigo-900/20 flex items-center justify-center text-indigo-600 font-bold text-xs">
|
||||||
|
{req.user.name[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-neutral-900 dark:text-neutral-100">{req.user.name}</p>
|
||||||
|
<p className="text-[10px] text-neutral-400 font-bold uppercase tracking-tight">{req.user.department}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-6">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase ${req.type === 'reimbursement' ? 'bg-amber-50 text-amber-600' : 'bg-purple-50 text-purple-600'
|
||||||
|
}`}>
|
||||||
|
{req.type === 'reimbursement' ? <ArrowRight className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
|
||||||
|
{req.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-6">
|
||||||
|
<p className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">{req.description}</p>
|
||||||
|
<p className="text-[10px] text-neutral-400 mt-0.5">{new Date(req.createdAt).toLocaleDateString()}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-6">
|
||||||
|
<span className="text-lg font-black text-neutral-900 dark:text-neutral-100">
|
||||||
|
{formatCurrency(req.amount.toString())}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-6 px-8 flex justify-end gap-2">
|
||||||
|
<ApprovalActions id={req.id} type={req.type} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{allRequests.length === 0 && (
|
||||||
|
<div className="p-20 text-center">
|
||||||
|
<div className="w-20 h-20 bg-emerald-50 dark:bg-emerald-900/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<CheckCircle2 className="w-10 h-10 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black mb-2">Queue is Clear!</h3>
|
||||||
|
<p className="text-neutral-500 font-medium">All worker requests have been processed.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Plus,
|
||||||
|
Settings2,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowRight,
|
||||||
|
Target,
|
||||||
|
BarChart,
|
||||||
|
Edit2
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const initialBudgets = [
|
||||||
|
{ department: 'Engineering (IT)', limit: 250000, spent: 185000, status: 'Healthy', color: 'bg-indigo-600' },
|
||||||
|
{ department: 'Marketing', limit: 120000, spent: 115000, status: 'Warning', color: 'bg-rose-500' },
|
||||||
|
{ department: 'Human Resources', limit: 80000, spent: 42000, status: 'Healthy', color: 'bg-emerald-500' },
|
||||||
|
{ department: 'Customer Success', limit: 60000, spent: 58000, status: 'Critical', color: 'bg-amber-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BudgetingPage() {
|
||||||
|
const [budgets, setBudgets] = useState(initialBudgets);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Department Budgeting</h2>
|
||||||
|
<p className="text-neutral-500 mt-1 font-medium">Set and monitor expenditure limits across the organization.</p>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-neutral-900 text-white hover:bg-black rounded-2xl h-14 px-8 font-black shadow-2xl transition-all active:scale-95 group">
|
||||||
|
<Plus className="w-5 h-5 mr-1 group-hover:rotate-180 transition-transform duration-500" />
|
||||||
|
Create Budget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<Card className="border-none shadow-2xl shadow-indigo-500/10 bg-white dark:bg-neutral-900 rounded-[3rem] p-10 overflow-hidden relative">
|
||||||
|
<div className="relative z-10 flex flex-col h-full">
|
||||||
|
<div className="flex items-center gap-4 mb-10">
|
||||||
|
<div className="w-16 h-16 bg-indigo-50 dark:bg-indigo-900/20 rounded-3xl flex items-center justify-center">
|
||||||
|
<Target className="w-8 h-8 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-black text-neutral-900 dark:text-neutral-100 italic">Total Budget Allocation</h3>
|
||||||
|
<p className="font-bold text-neutral-400 uppercase tracking-widest text-xs mt-1">FY 2024 • Q1 ACTIVE</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col justify-center gap-12">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-end mb-4">
|
||||||
|
<p className="text-sm font-bold text-neutral-400 uppercase tracking-[0.2em]">Overall Utilization</p>
|
||||||
|
<p className="text-6xl font-black text-neutral-900 dark:text-neutral-100 tracking-tighter">78<span className="text-indigo-600 text-4xl">%</span></p>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-full bg-neutral-50 dark:bg-neutral-800 rounded-full overflow-hidden p-1.5 border border-neutral-100 dark:border-neutral-800 shadow-inner">
|
||||||
|
<div className="h-full bg-gradient-to-r from-indigo-600 to-indigo-400 rounded-full shadow-[0_0_20px_rgba(79,70,229,0.3)]" style={{ width: '78%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[10px] font-black text-neutral-400 uppercase tracking-widest">Total Limit</p>
|
||||||
|
<p className="text-2xl font-black text-neutral-900 dark:text-neutral-100">$510,000.00</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[10px] font-black text-neutral-400 uppercase tracking-widest">Spent to Date</p>
|
||||||
|
<p className="text-2xl font-black text-indigo-600">$400,000.00</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative blobs */}
|
||||||
|
<div className="absolute top-[-10%] right-[-10%] w-[40%] h-[40%] bg-indigo-500/5 blur-[80px] rounded-full" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-neutral-900 rounded-[3rem] p-10 text-white flex flex-col justify-center">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<div className="p-3 bg-white/10 rounded-2xl">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold">Budget Insights</h3>
|
||||||
|
<p className="text-neutral-400 text-sm">Automated analysis of current spend</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="p-6 bg-white/5 rounded-3xl border border-white/5 hover:bg-white/10 transition-colors group cursor-pointer">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<span className="text-xs font-black text-rose-500 uppercase tracking-widest bg-rose-500/10 px-2 py-1 rounded-lg">Critical Alert</span>
|
||||||
|
<ArrowRight className="w-4 h-4 text-neutral-600 group-hover:text-white transition-colors" />
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-lg mb-1">Marketing Budget at 96%</p>
|
||||||
|
<p className="text-sm text-neutral-400 font-medium leading-relaxed">Marketing spend is significantly higher than projected. Approvals for non-essential spend restricted.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-white/5 rounded-3xl border border-white/5 hover:bg-white/10 transition-colors group cursor-pointer">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<span className="text-xs font-black text-emerald-500 uppercase tracking-widest bg-emerald-500/10 px-2 py-1 rounded-lg">Optimization</span>
|
||||||
|
<ArrowRight className="w-4 h-4 text-neutral-600 group-hover:text-white transition-colors" />
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-lg mb-1">HR Savings Opportunity</p>
|
||||||
|
<p className="text-sm text-neutral-400 font-medium leading-relaxed">Current HR spend is 45% below budget. Consider reallocating $15k to Engineering Q2.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
<h3 className="text-xl font-black text-neutral-900 dark:text-neutral-100 flex items-center gap-3">
|
||||||
|
<BarChart className="w-6 h-6 text-indigo-600" />
|
||||||
|
Departmental Breakdown
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||||
|
{budgets.map((b, i) => {
|
||||||
|
const percent = (b.spent / b.limit) * 100;
|
||||||
|
return (
|
||||||
|
<Card key={i} className="border-none shadow-xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[2.5rem] p-8 flex flex-col justify-between group hover:scale-[1.03] transition-all duration-300">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div className={`p-3 rounded-2xl ${b.color} text-white shadow-lg shadow-black/5`}>
|
||||||
|
<Settings2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" className="rounded-full hover:bg-neutral-100">
|
||||||
|
<Edit2 className="w-4 h-4 text-neutral-400" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-black text-xl mb-1 text-neutral-900 dark:text-neutral-100">{b.department}</h4>
|
||||||
|
<p className="text-[10px] font-black text-neutral-400 uppercase tracking-[0.2em] mb-4">Current Period</p>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-8">
|
||||||
|
<div className="flex justify-between text-sm font-bold">
|
||||||
|
<span className="text-neutral-500">Utilization</span>
|
||||||
|
<span className={percent > 90 ? 'text-rose-600' : 'text-neutral-900 dark:text-neutral-100'}>{percent.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full bg-neutral-50 dark:bg-neutral-800 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${b.color} rounded-full transition-all duration-1000 ease-out shadow-[0_0_10px_rgba(0,0,0,0.1)]`} style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-neutral-100 dark:border-neutral-800 space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-xs font-bold text-neutral-400 uppercase tracking-tighter">Spent</span>
|
||||||
|
<span className="font-black text-neutral-900 dark:text-neutral-100 text-lg">${(b.spent / 1000).toFixed(0)}k</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center opacity-40">
|
||||||
|
<span className="text-xs font-bold text-neutral-400 uppercase tracking-tighter">Limit</span>
|
||||||
|
<span className="font-bold text-sm">${(b.limit / 1000).toFixed(0)}k</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
BarChart3,
|
||||||
|
Wallet,
|
||||||
|
Clock,
|
||||||
|
ArrowUpRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function AdminDashboard() {
|
||||||
|
const [pendingReimbursements, pendingOvertime, totalBudget, recentTransactions] = await Promise.all([
|
||||||
|
prisma.reimbursement.count({ where: { status: 'PENDING' } }),
|
||||||
|
prisma.overtime.count({ where: { status: 'PENDING' } }),
|
||||||
|
prisma.budget.aggregate({ _sum: { amount: true } }),
|
||||||
|
prisma.companyTransaction.findMany({
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
take: 5
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPending = pendingReimbursements + pendingOvertime;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-extrabold tracking-tight text-neutral-900 dark:text-white">Admin Console</h2>
|
||||||
|
<p className="text-neutral-500 mt-2 font-medium text-lg">Real-time overview of company finances and worker requests.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin/approvals">
|
||||||
|
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white rounded-2xl h-14 px-8 font-black shadow-2xl shadow-emerald-200 dark:shadow-none transition-all active:scale-95 flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
Review Requests ({totalPending})
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Budget Allocated', value: formatCurrency(totalBudget._sum.amount?.toString() || '0'), icon: Wallet, color: 'emerald', trend: '+5.2% vs last month' },
|
||||||
|
{ label: 'Pending Approvals', value: totalPending.toString(), icon: Clock, color: 'amber', trend: `${pendingReimbursements} claims, ${pendingOvertime} OT` },
|
||||||
|
{ label: 'Monthly Revenue', value: formatCurrency(245000), icon: BarChart3, color: 'indigo', trend: 'On track for Q1' },
|
||||||
|
{ label: 'Active Personnel', value: '142', icon: Users, color: 'violet', trend: '+3 new this week' },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<Card key={i} className="group border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[2.5rem] p-8 hover:-translate-y-2 transition-all duration-500 overflow-hidden relative">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className={`w-14 h-14 rounded-2xl bg-neutral-50 dark:bg-neutral-800 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-500`}>
|
||||||
|
<stat.icon className={`w-7 h-7 text-neutral-900 dark:text-neutral-100`} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-black text-neutral-400 uppercase tracking-widest mb-1">{stat.label}</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<h3 className="text-3xl font-black text-neutral-900 dark:text-neutral-100">{stat.value}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-[10px] font-bold text-neutral-500 flex items-center gap-1">
|
||||||
|
{stat.trend}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||||
|
<Card className="xl:col-span-2 border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[3rem] p-10 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between mb-10">
|
||||||
|
<h3 className="text-2xl font-black tracking-tight">Financial Ledger</h3>
|
||||||
|
<Link href="/admin/ledger">
|
||||||
|
<Button variant="ghost" className="text-emerald-600 font-bold hover:bg-emerald-50 rounded-xl px-4 py-2">View Full Ledger</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{(recentTransactions as any[]).map((tx) => (
|
||||||
|
<div key={tx.id} className="flex items-center justify-between p-6 rounded-[2rem] hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-all group border border-transparent hover:border-neutral-100 dark:hover:border-neutral-800">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className={`w-14 h-14 rounded-2xl ${tx.type === 'DEBIT' ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600'} flex items-center justify-center font-black shadow-sm`}>
|
||||||
|
{tx.type === 'DEBIT' ? '-' : '+'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-lg text-neutral-900 dark:text-neutral-100">{tx.description}</p>
|
||||||
|
<p className="text-xs text-neutral-400 font-medium">{new Date(tx.date).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`font-black text-xl ${tx.type === 'DEBIT' ? 'text-rose-600' : 'text-emerald-600'}`}>
|
||||||
|
{tx.type === 'DEBIT' ? '-' : '+'}{formatCurrency(tx.amount.toString())}
|
||||||
|
</p>
|
||||||
|
<span className="text-[10px] font-black uppercase text-neutral-400">Success</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card className="border-none shadow-2xl shadow-emerald-500/10 bg-gradient-to-br from-emerald-600 to-teal-700 rounded-[3rem] p-10 text-white relative overflow-hidden group">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-xl font-bold mb-6 flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-5 h-5" />
|
||||||
|
Budget Health
|
||||||
|
</h3>
|
||||||
|
<p className="text-emerald-100 font-medium leading-relaxed mb-8 text-sm">
|
||||||
|
Total budget utilization is at <span className="text-white font-bold text-lg">68%</span>. Marketing and Sales are nearing their monthly limits.
|
||||||
|
</p>
|
||||||
|
<Link href="/admin/budgeting">
|
||||||
|
<Button className="w-full bg-white text-emerald-700 hover:bg-emerald-50 rounded-2xl h-14 font-black shadow-xl transition-all active:scale-95">
|
||||||
|
Manage Budgets
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[3rem] p-10">
|
||||||
|
<h3 className="text-xl font-black mb-8">Departmental Spend</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[
|
||||||
|
{ label: 'Marketing', value: 85, color: 'bg-emerald-500' },
|
||||||
|
{ label: 'Engineering', value: 62, color: 'bg-indigo-500' },
|
||||||
|
{ label: 'Operations', value: 48, color: 'bg-violet-500' },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm font-bold">
|
||||||
|
<span className="text-neutral-500">{item.label}</span>
|
||||||
|
<span className={item.value > 80 ? 'text-rose-500' : ''}>{item.value}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${item.color} rounded-full transition-all duration-1000`} style={{ width: `${item.value}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { Sidebar } from "@/components/sidebar";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
UserCheck,
|
||||||
|
BookOpen,
|
||||||
|
PieChart,
|
||||||
|
BarChart3,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double check role
|
||||||
|
// if (session.user.role !== 'ADMIN') {
|
||||||
|
// redirect('/me/dashboard');
|
||||||
|
// }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-neutral-50 dark:bg-neutral-950">
|
||||||
|
<Sidebar
|
||||||
|
portal="admin"
|
||||||
|
user={{
|
||||||
|
name: session.user.name,
|
||||||
|
email: session.user.email,
|
||||||
|
role: "Administrator"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<main className="flex-1 overflow-y-auto p-8 relative">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Background Accents */}
|
||||||
|
<div className="fixed top-[-5%] right-[-5%] w-[30%] h-[30%] bg-emerald-500/5 blur-[100px] -z-10 rounded-full" />
|
||||||
|
<div className="fixed bottom-[-5%] left-[20%] w-[30%] h-[30%] bg-blue-500/5 blur-[100px] -z-10 rounded-full" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Download,
|
||||||
|
Calendar,
|
||||||
|
Wallet,
|
||||||
|
ArrowRightLeft
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default async function LedgerPage() {
|
||||||
|
const transactions = await prisma.companyTransaction.findMany({
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
account: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Company Ledger</h2>
|
||||||
|
<p className="text-neutral-500 mt-1">Full transaction history and cash flow tracking.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" className="rounded-xl border-neutral-200 gap-2 h-12 px-6 font-bold shadow-sm hover:bg-neutral-50 transition-all">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl gap-2 h-12 px-8 font-bold shadow-lg shadow-emerald-100 dark:shadow-none transition-all active:scale-95">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Entry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Inflow', amount: 452000, color: 'emerald', icon: ArrowUpRight },
|
||||||
|
{ label: 'Total Outflow', amount: 128400, color: 'rose', icon: ArrowDownRight },
|
||||||
|
{ label: 'Net Balance', amount: 323600, color: 'indigo', icon: Wallet },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<Card key={i} className="border-none shadow-xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-3xl p-8 relative overflow-hidden group">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className={`w-12 h-12 rounded-2xl bg-${stat.color}-50 dark:bg-${stat.color}-900/20 flex items-center justify-center mb-4`}>
|
||||||
|
<stat.icon className={`w-6 h-6 text-${stat.color}-600 dark:text-${stat.color}-400`} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest mb-1">{stat.label}</p>
|
||||||
|
<h3 className="text-3xl font-black text-neutral-900 dark:text-neutral-100">{formatCurrency(stat.amount.toString())}</h3>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[2.5rem] overflow-hidden">
|
||||||
|
<div className="p-8 border-b border-neutral-100 dark:border-neutral-800 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||||
|
<Input placeholder="Search transactions..." className="pl-10 h-12 rounded-xl bg-neutral-50 dark:bg-neutral-800 border-none shadow-none" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" className="rounded-xl border-neutral-200 h-12 flex items-center gap-2 font-bold transition-all hover:bg-neutral-50">
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
All Accounts
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="rounded-xl border-neutral-200 h-12 flex items-center gap-2 font-bold transition-all hover:bg-neutral-50">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
This Month
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-neutral-50/50 dark:bg-neutral-800/50 border-b border-neutral-100 dark:border-neutral-800">
|
||||||
|
<th className="p-6 px-8 text-xs font-black uppercase tracking-[0.15em] text-neutral-400">Transaction</th>
|
||||||
|
<th className="p-6 text-xs font-black uppercase tracking-[0.15em] text-neutral-400">Account</th>
|
||||||
|
<th className="p-6 text-xs font-black uppercase tracking-[0.15em] text-neutral-400">Category</th>
|
||||||
|
<th className="p-6 text-xs font-black uppercase tracking-[0.15em] text-neutral-400">Amount</th>
|
||||||
|
<th className="p-6 px-8 text-xs font-black uppercase tracking-[0.15em] text-neutral-400 text-right">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<tr key={tx.id} className="hover:bg-neutral-50/50 transition-all group">
|
||||||
|
<td className="p-6 px-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${tx.type === 'DEBIT' ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600'
|
||||||
|
}`}>
|
||||||
|
{tx.type === 'DEBIT' ? <ArrowDownRight className="w-5 h-5" /> : <ArrowUpRight className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-neutral-900 dark:text-neutral-100">{tx.description}</p>
|
||||||
|
<p className="text-[10px] text-neutral-400 font-bold uppercase tracking-tight">TX-{tx.id.substring(0, 8).toUpperCase()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-6">
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">{tx.account.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-6">
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-neutral-100 dark:bg-neutral-800 text-[10px] font-black uppercase text-neutral-500">
|
||||||
|
{tx.category?.name || 'Uncategorized'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-6">
|
||||||
|
<span className={`text-lg font-black ${tx.type === 'DEBIT' ? 'text-rose-600' : 'text-emerald-600'}`}>
|
||||||
|
{tx.type === 'DEBIT' ? '-' : '+'}{formatCurrency(tx.amount.toString())}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-6 px-8 text-right">
|
||||||
|
<p className="text-sm font-bold text-neutral-900 dark:text-neutral-100">{new Date(tx.date).toLocaleDateString()}</p>
|
||||||
|
<p className="text-[10px] text-neutral-400 uppercase tracking-tighter">14:20 PM</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{transactions.length === 0 && (
|
||||||
|
<div className="p-20 text-center">
|
||||||
|
<div className="w-20 h-20 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<ArrowRightLeft className="w-10 h-10 text-neutral-300" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black mb-2">No Transactions Found</h3>
|
||||||
|
<p className="text-neutral-500 font-medium">Start recording financial activities to see them here.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
PieChart as PieChartIcon,
|
||||||
|
Download,
|
||||||
|
Calendar,
|
||||||
|
ChevronDown,
|
||||||
|
TrendingUp,
|
||||||
|
ArrowUpRight,
|
||||||
|
FileText,
|
||||||
|
Filter
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
PieChart,
|
||||||
|
Cell,
|
||||||
|
Pie
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
const cashFlowData = [
|
||||||
|
{ name: 'Jul', incoming: 45000, outgoing: 32000 },
|
||||||
|
{ name: 'Aug', incoming: 52000, outgoing: 38000 },
|
||||||
|
{ name: 'Sep', incoming: 48000, outgoing: 41000 },
|
||||||
|
{ name: 'Oct', incoming: 61000, outgoing: 45000 },
|
||||||
|
{ name: 'Nov', incoming: 55000, outgoing: 42000 },
|
||||||
|
{ name: 'Dec', incoming: 67000, outgoing: 48000 },
|
||||||
|
{ name: 'Jan', incoming: 72000, outgoing: 51000 },
|
||||||
|
{ name: 'Feb', incoming: 69000, outgoing: 55000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const categoryData = [
|
||||||
|
{ name: 'Payroll', value: 45, color: '#4f46e5' },
|
||||||
|
{ name: 'Marketing', value: 20, color: '#8b5cf6' },
|
||||||
|
{ name: 'Infrastructure', value: 15, color: '#10b981' },
|
||||||
|
{ name: 'Operations', value: 12, color: '#f59e0b' },
|
||||||
|
{ name: 'Legal/Other', value: 8, color: '#ef4444' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500 pb-12">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-black tracking-tight italic">Financial Intel</h2>
|
||||||
|
<p className="text-neutral-500 mt-1 font-medium text-lg">Detailed P&L and Cash Flow analysis for the current fiscal year.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" className="rounded-2xl border-neutral-200 h-14 px-6 gap-2 font-bold group">
|
||||||
|
<Calendar className="w-5 h-5 text-neutral-400" />
|
||||||
|
FY 2024
|
||||||
|
<ChevronDown className="w-4 h-4 text-neutral-400" />
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl h-14 px-8 font-black shadow-2xl transition-all active:scale-95 flex items-center gap-2">
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
Executive Summary
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||||
|
<Card className="xl:col-span-2 border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[3rem] p-10 overflow-hidden relative">
|
||||||
|
<div className="flex justify-between items-start mb-10 relative z-10">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-black tracking-tighter">Net Cash Flow Trajectory</h3>
|
||||||
|
<p className="text-neutral-500 font-medium text-sm mt-1">Comparison of gross inflows vs primary outflows</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 rounded-2xl flex items-center gap-2 font-black text-sm shadow-sm">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
+18.4%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-[400px] w-full relative z-10">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={cashFlowData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorInc" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#4f46e5" stopOpacity={0.1} />
|
||||||
|
<stop offset="95%" stopColor="#4f46e5" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorOut" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.05} />
|
||||||
|
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#a3a3a3', fontSize: 12, fontWeight: 700 }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#a3a3a3', fontSize: 12, fontWeight: 700 }} tickFormatter={(v) => `$${v / 1000}k`} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: '24px', border: 'none', boxShadow: '0 20px 25px -5px rgb(0 0 0 / 0.1)', padding: '20px' }}
|
||||||
|
itemStyle={{ fontWeight: 800, fontSize: '14px' }}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="incoming" stroke="#4f46e5" strokeWidth={4} fillOpacity={1} fill="url(#colorInc)" />
|
||||||
|
<Area type="monotone" dataKey="outgoing" stroke="#ef4444" strokeWidth={4} fillOpacity={1} fill="url(#colorOut)" strokeDasharray="8 8" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/5 blur-[100px] rounded-full -mr-32 -mt-32" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[3rem] p-10 flex flex-col items-center">
|
||||||
|
<h3 className="text-xl font-black mb-8 w-full">Expense Allocation</h3>
|
||||||
|
<div className="h-[280px] w-full mb-10">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={categoryData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={80}
|
||||||
|
outerRadius={120}
|
||||||
|
paddingAngle={8}
|
||||||
|
dataKey="value"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{categoryData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: '20px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
{categoryData.map((c) => (
|
||||||
|
<div key={c.name} className="flex justify-between items-center group cursor-pointer">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: c.color }} />
|
||||||
|
<span className="font-bold text-sm text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-900 transition-colors">{c.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="font-black text-sm">{c.value}%</span>
|
||||||
|
<ChevronDown className="w-4 h-4 text-neutral-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-8">
|
||||||
|
{[
|
||||||
|
{ label: 'EBITDA', value: '$240k', trend: '+12%', sub: 'Earnings' },
|
||||||
|
{ label: 'OPEX', value: '$180k', trend: '-2.4%', sub: 'Operating' },
|
||||||
|
{ label: 'LTV/CAC', value: '4.2x', trend: '+0.5x', sub: 'Efficiency' },
|
||||||
|
{ label: 'Burn Rate', value: '$45k', trend: 'Stable', sub: 'Monthly' },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<Card key={i} className="border-none shadow-xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[2rem] p-8 hover:bg-neutral-50 transition-colors cursor-pointer group">
|
||||||
|
<p className="text-[10px] font-black text-neutral-400 uppercase tracking-widest mb-1">{stat.label}</p>
|
||||||
|
<p className="text-3xl font-black text-neutral-900 dark:text-neutral-100 group-hover:text-indigo-600 transition-colors">{stat.value}</p>
|
||||||
|
<div className="mt-4 flex justify-between items-center">
|
||||||
|
<span className="text-xs font-bold text-neutral-500">{stat.sub}</span>
|
||||||
|
<span className={`text-xs font-black p-1 px-2 rounded-lg ${stat.trend.startsWith('+') ? 'bg-emerald-50 text-emerald-600' :
|
||||||
|
stat.trend.startsWith('-') ? 'bg-rose-50 text-rose-600' : 'bg-neutral-100 text-neutral-400'
|
||||||
|
}`}>
|
||||||
|
{stat.trend}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-neutral-900 rounded-[3rem] p-10 text-white mt-8 overflow-hidden relative">
|
||||||
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-2xl font-black mb-2">Automated P&L Audit</h3>
|
||||||
|
<p className="text-neutral-400 font-medium">Generate a comprehensive profit and loss statement for external audit in minutes.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="outline" className="rounded-2xl h-14 px-8 border-white/20 text-white hover:bg-white/10 font-bold">
|
||||||
|
<Filter className="w-5 h-5 mr-1" />
|
||||||
|
Configure Rules
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-indigo-600 hover:bg-indigo-500 text-white rounded-2xl h-14 px-10 font-black shadow-2xl shadow-indigo-500/20">
|
||||||
|
<FileText className="w-6 h-6 mr-2" />
|
||||||
|
Generate P&L
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-1/2 left-0 -translate-y-1/2 -ml-20 w-64 h-64 bg-indigo-500/10 rounded-full blur-[80px]" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
import { handlers } from "@/auth" // Referring to the auth.ts we just created
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const defaultPassword = await bcrypt.hash('password123', 10);
|
||||||
|
|
||||||
|
// Create an Admin user
|
||||||
|
const adminUser = await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@tam-finance.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'TAM Admin',
|
||||||
|
email: 'admin@tam-finance.com',
|
||||||
|
role: 'ADMIN',
|
||||||
|
department: 'Operations',
|
||||||
|
password: defaultPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a regular worker
|
||||||
|
const workerUser = await prisma.user.upsert({
|
||||||
|
where: { email: 'worker@tam-finance.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'worker@tam-finance.com',
|
||||||
|
role: 'WORKER',
|
||||||
|
department: 'Engineering',
|
||||||
|
password: defaultPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Database specifically seeded for TAM Finance',
|
||||||
|
adminUser,
|
||||||
|
workerUser
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to seed the database', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to seed database' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { auth, signOut } from '@/auth';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen p-8 bg-neutral-50 dark:bg-neutral-950">
|
||||||
|
<header className="flex justify-between items-center bg-white dark:bg-neutral-900 shadow-sm p-4 rounded-lg mb-8">
|
||||||
|
<h1 className="text-xl font-bold">Dashboard</h1>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<p className="text-sm font-medium">Logged in as: {session?.user?.email}</p>
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
'use server';
|
||||||
|
await signOut();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="outline">Sign out</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Total Balance</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold">$124,500.00</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Monthly Spend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold text-red-600">-$24,300.00</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Pending Approvals</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold text-yellow-600">5</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,26 +1,126 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root {
|
||||||
:root {
|
--radius: 0.625rem;
|
||||||
--background: #0a0a0a;
|
--background: oklch(1 0 0);
|
||||||
--foreground: #ededed;
|
--foreground: oklch(0.145 0 0);
|
||||||
}
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.dark {
|
||||||
background: var(--background);
|
--background: oklch(0.145 0 0);
|
||||||
color: var(--foreground);
|
--foreground: oklch(0.985 0 0);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { signIn } from '@/auth';
|
||||||
|
import { AuthError } from 'next-auth';
|
||||||
|
|
||||||
|
export async function authenticate(
|
||||||
|
prevState: string | undefined,
|
||||||
|
formData: FormData,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await signIn('credentials', Object.fromEntries(formData));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
switch (error.type) {
|
||||||
|
case 'CredentialsSignin':
|
||||||
|
return 'Invalid credentials.';
|
||||||
|
default:
|
||||||
|
return 'Something went wrong.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { auth } from '@/auth';
|
||||||
|
|
||||||
|
export async function submitReimbursement(formData: FormData) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
const amount = formData.get('amount') as string;
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
const category = formData.get('category') as string;
|
||||||
|
const dateStr = formData.get('date') as string;
|
||||||
|
|
||||||
|
await prisma.reimbursement.create({
|
||||||
|
data: {
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
createdAt: new Date(dateStr),
|
||||||
|
userId: (session.user as any).id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/me/reimbursements');
|
||||||
|
revalidatePath('/me/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitOvertime(formData: FormData) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
const date = formData.get('date') as string;
|
||||||
|
const hours = formData.get('hours') as string;
|
||||||
|
const reason = formData.get('reason') as string;
|
||||||
|
|
||||||
|
await prisma.overtime.create({
|
||||||
|
data: {
|
||||||
|
date: new Date(date),
|
||||||
|
hours: parseFloat(hours),
|
||||||
|
reason,
|
||||||
|
userId: (session.user as any).id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/me/overtime');
|
||||||
|
revalidatePath('/me/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRequestStatus(id: string, type: 'reimbursement' | 'overtime', status: 'APPROVED' | 'REJECTED' | 'PAID') {
|
||||||
|
const session = await auth();
|
||||||
|
// Simple role check
|
||||||
|
if ((session?.user as any)?.role !== 'ADMIN') throw new Error('Admin only');
|
||||||
|
|
||||||
|
if (type === 'reimbursement') {
|
||||||
|
await prisma.reimbursement.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.overtime.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/approvals');
|
||||||
|
revalidatePath('/admin/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBudget(formData: FormData) {
|
||||||
|
const department = formData.get('department') as string;
|
||||||
|
const amount = formData.get('amount') as string;
|
||||||
|
const period = formData.get('period') as string;
|
||||||
|
|
||||||
|
await prisma.budget.create({
|
||||||
|
data: {
|
||||||
|
department,
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
period,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/admin/budgeting');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLedgerEntry(formData: FormData) {
|
||||||
|
const amount = formData.get('amount') as string;
|
||||||
|
const type = formData.get('type') as 'CREDIT' | 'DEBIT';
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
const categoryId = formData.get('categoryId') as string;
|
||||||
|
const accountId = formData.get('accountId') as string;
|
||||||
|
|
||||||
|
await prisma.companyTransaction.create({
|
||||||
|
data: {
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
categoryId,
|
||||||
|
accountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/admin/ledger');
|
||||||
|
revalidatePath('/admin/dashboard');
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import LoginForm from '@/app/ui/login-form';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<main className="flex items-center justify-center min-h-screen bg-neutral-50 dark:bg-neutral-950">
|
||||||
|
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
|
||||||
|
<div className="flex w-full items-end rounded-lg bg-blue-600 p-3 mb-2 shadow-sm">
|
||||||
|
<div className="w-32 text-white font-bold text-2xl">
|
||||||
|
TAM Finance
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
DollarSign,
|
||||||
|
Receipt,
|
||||||
|
Clock,
|
||||||
|
Wallet,
|
||||||
|
ArrowUpRight,
|
||||||
|
TrendingUp,
|
||||||
|
CreditCard,
|
||||||
|
Zap
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function WorkerDashboard() {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
|
||||||
|
const [reimbursements, overtimes] = await Promise.all([
|
||||||
|
prisma.reimbursement.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5
|
||||||
|
}),
|
||||||
|
prisma.overtime.findMany({
|
||||||
|
where: { userId },
|
||||||
|
take: 20
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pendingClaimsCount = await prisma.reimbursement.count({
|
||||||
|
where: { userId, status: 'PENDING' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSpendingAgg = await prisma.reimbursement.aggregate({
|
||||||
|
where: { userId, status: 'PAID' },
|
||||||
|
_sum: { amount: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const overtimeHours = overtimes.reduce((acc, curr) => acc + curr.hours, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-extrabold tracking-tight text-neutral-900 dark:text-white">
|
||||||
|
Welcome back, <span className="text-indigo-600">{(session?.user?.name || 'User').split(' ')[0]}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-neutral-500 mt-2 font-medium text-lg">Your financial activity and pending requests at a glance.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" className="rounded-2xl border-neutral-200 h-14 px-6 gap-2 font-bold hover:bg-white hover:shadow-xl transition-all">
|
||||||
|
Download Report
|
||||||
|
</Button>
|
||||||
|
<Link href="/me/reimbursements">
|
||||||
|
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl h-14 px-8 font-black shadow-2xl shadow-indigo-200 dark:shadow-none transition-all active:scale-95 flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 fill-white" />
|
||||||
|
New Request
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Spending', value: formatCurrency(totalSpendingAgg._sum.amount?.toString() || '0'), icon: DollarSign, color: 'indigo', trend: '+12% this month' },
|
||||||
|
{ label: 'Pending Claims', value: pendingClaimsCount.toString(), icon: Receipt, color: 'amber', trend: 'Wait time: ~2 days' },
|
||||||
|
{ label: 'Overtime Hours', value: `${overtimeHours}h`, icon: Clock, color: 'violet', trend: 'YTD tracking' },
|
||||||
|
{ label: 'Next Payout', value: formatCurrency(2450), icon: Wallet, color: 'emerald', trend: 'Due in 12 days' },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<Card key={i} className="group border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[2.5rem] p-8 hover:-translate-y-2 transition-all duration-500 overflow-hidden relative">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className={`w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-900/20 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-500`}>
|
||||||
|
<stat.icon className={`w-7 h-7 text-indigo-600 dark:text-indigo-400`} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-black text-neutral-400 uppercase tracking-widest mb-1">{stat.label}</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<h3 className="text-3xl font-black text-neutral-900 dark:text-neutral-100">{stat.value}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-[10px] font-bold text-neutral-500 flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3 text-emerald-500" />
|
||||||
|
{stat.trend}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||||
|
<Card className="xl:col-span-2 border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[3rem] p-10">
|
||||||
|
<div className="flex items-center justify-between mb-10">
|
||||||
|
<h3 className="text-2xl font-black tracking-tight">Recent Activity</h3>
|
||||||
|
<Button variant="ghost" className="text-indigo-600 font-bold hover:bg-indigo-50 rounded-xl px-4 py-2">View All</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{reimbursements.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between p-6 rounded-[2rem] hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-all group cursor-pointer border border-transparent hover:border-neutral-100 dark:hover:border-neutral-800">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center group-hover:bg-white dark:group-hover:bg-neutral-700 transition-colors shadow-sm">
|
||||||
|
<CreditCard className="w-6 h-6 text-neutral-400 group-hover:text-indigo-600 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-lg text-neutral-900 dark:text-neutral-100">{item.description}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-[10px] font-black uppercase text-neutral-400 tracking-wider bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 rounded-md">{item.category}</span>
|
||||||
|
<span className="text-xs text-neutral-400 font-medium">{new Date(item.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-black text-xl text-neutral-900 dark:text-neutral-100">{formatCurrency(item.amount.toString())}</p>
|
||||||
|
<span className={`text-[10px] font-black uppercase ${item.status === 'PAID' ? 'text-emerald-500' : 'text-amber-500'
|
||||||
|
}`}>{item.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card className="border-none shadow-2xl shadow-indigo-500/10 bg-gradient-to-br from-indigo-700 to-violet-800 rounded-[3rem] p-10 text-white relative overflow-hidden group">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-xl font-bold mb-6 flex items-center gap-2">
|
||||||
|
<ArrowUpRight className="w-5 h-5" />
|
||||||
|
Quick Wallet Tip
|
||||||
|
</h3>
|
||||||
|
<p className="text-indigo-100 font-medium leading-relaxed mb-8">
|
||||||
|
You have <span className="text-white font-bold underline decoration-indigo-400 underline-offset-4">{pendingClaimsCount} pending reimbursement requests</span>. Claims are usually processed within 48 hours.
|
||||||
|
</p>
|
||||||
|
<Link href="/me/reimbursements">
|
||||||
|
<Button className="w-full bg-white text-indigo-700 hover:bg-indigo-50 rounded-2xl h-14 font-black shadow-xl transition-all active:scale-95">
|
||||||
|
Submit New Claim
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[3rem] p-10">
|
||||||
|
<h3 className="text-xl font-black mb-8">Spending Snapshot</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[
|
||||||
|
{ label: 'Travel', value: 45, color: 'bg-indigo-500' },
|
||||||
|
{ label: 'Dining', value: 30, color: 'bg-emerald-500' },
|
||||||
|
{ label: 'Supplies', value: 25, color: 'bg-amber-500' },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm font-bold">
|
||||||
|
<span className="text-neutral-500">{item.label}</span>
|
||||||
|
<span>{item.value}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${item.color} rounded-full transition-all duration-1000`} style={{ width: `${item.value}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { Sidebar } from "@/components/sidebar";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Receipt,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default async function WorkerLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-neutral-50 dark:bg-neutral-950">
|
||||||
|
<Sidebar
|
||||||
|
portal="worker"
|
||||||
|
user={{
|
||||||
|
name: session.user.name,
|
||||||
|
email: session.user.email,
|
||||||
|
role: "Worker Account"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<main className="flex-1 overflow-y-auto p-8 relative">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtle Background Elements for Modern Look */}
|
||||||
|
<div className="fixed top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo-500/5 blur-[120px] -z-10 rounded-full" />
|
||||||
|
<div className="fixed bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-violet-500/5 blur-[120px] -z-10 rounded-full" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Zap, Loader2 } from "lucide-react";
|
||||||
|
import { submitOvertime } from "@/app/lib/actions";
|
||||||
|
|
||||||
|
export function OvertimeForm() {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitOvertime(formData);
|
||||||
|
alert("Overtime logged successfully!");
|
||||||
|
(document.getElementById('overtime-form') as HTMLFormElement).reset();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to log overtime.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-none shadow-2xl shadow-indigo-500/10 bg-white dark:bg-neutral-900 rounded-3xl overflow-hidden">
|
||||||
|
<CardHeader className="p-8 border-b border-neutral-100 dark:border-neutral-800 flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Log Extra Hours</CardTitle>
|
||||||
|
<CardDescription>Enter details for your additional work hours</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-2xl">
|
||||||
|
<Zap className="w-6 h-6 text-indigo-600 dark:text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<form id="overtime-form" action={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date" className="text-sm font-semibold">Work Date</Label>
|
||||||
|
<Input name="date" type="date" required className="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="hours" className="text-sm font-semibold">Hours Worked</Label>
|
||||||
|
<Input name="hours" type="number" step="0.5" placeholder="e.g. 2.5" required className="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reason" className="text-sm font-semibold">Reason / Project</Label>
|
||||||
|
<Textarea name="reason" placeholder="Briefly describe the work you performed..." className="rounded-2xl min-h-[120px] bg-neutral-50 dark:bg-neutral-800/50 border-none resize-none px-4 py-3" />
|
||||||
|
</div>
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="w-full bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl py-6 h-auto text-lg font-bold transition-all shadow-lg shadow-indigo-100 dark:shadow-none">
|
||||||
|
{isSubmitting ? <Loader2 className="w-5 h-5 animate-spin mx-auto" /> : "Log Overtime"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { OvertimeForm } from "./overtime-form";
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Zap
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default async function OvertimePage() {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
|
||||||
|
const logs = await prisma.overtime.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
take: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalYTD = logs.reduce((acc, curr) => acc + curr.hours, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Overtime Management</h2>
|
||||||
|
<p className="text-neutral-500 mt-1">Log your extra hours and track approval status.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2 space-y-8">
|
||||||
|
<OvertimeForm />
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-3xl overflow-hidden">
|
||||||
|
<div className="p-8 border-b border-neutral-100 dark:border-neutral-800 flex justify-between items-center">
|
||||||
|
<h3 className="text-xl font-bold">Recent Logs</h3>
|
||||||
|
<Button variant="ghost" className="text-indigo-600 font-semibold hover:bg-indigo-50 rounded-xl px-4 py-2 h-auto text-sm">
|
||||||
|
View History
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<div key={log.id} className="p-8 hover:bg-neutral-50/50 transition-colors flex items-center justify-between group">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="hidden sm:flex flex-col items-center">
|
||||||
|
<span className="text-xs font-bold text-neutral-400 uppercase tracking-tighter">Day</span>
|
||||||
|
<span className="text-2xl font-black text-neutral-300 group-hover:text-indigo-200 transition-colors">
|
||||||
|
{new Date(log.date).getDate().toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-neutral-900 dark:text-neutral-100">{log.reason}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-sm text-neutral-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(log.date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{log.hours} hours
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<span className={`text-[10px] font-bold uppercase px-2 py-1 rounded-full ${log.status === 'PAID' ? 'bg-emerald-100 text-emerald-700' :
|
||||||
|
log.status === 'APPROVED' ? 'bg-indigo-100 text-indigo-700' : 'bg-amber-100 text-amber-700'
|
||||||
|
}`}>
|
||||||
|
{log.status}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs font-medium text-neutral-400">Payroll: Current Cycle</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card className="border-none shadow-2xl shadow-indigo-500/10 bg-gradient-to-br from-indigo-600 to-violet-700 rounded-3xl p-8 text-white relative overflow-hidden">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-md">
|
||||||
|
<Clock className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold mb-2">Total Overtime (YTD)</h3>
|
||||||
|
<p className="text-4xl font-black mb-4">{totalYTD}h</p>
|
||||||
|
<div className="w-full bg-white/10 rounded-full h-2 mb-2">
|
||||||
|
<div className="bg-white h-full rounded-full w-[65%]" style={{ width: `${Math.min(100, (totalYTD / 60) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-indigo-100">Relative to monthly capacity</p>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-[-20%] right-[-20%] w-48 h-48 bg-white/5 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-3xl p-8">
|
||||||
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||||
|
Important Note
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500 leading-relaxed">
|
||||||
|
Overtime logs must be submitted within 48 hours of completion. All logs are subject to manager approval before being processed for payroll.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
Search,
|
||||||
|
ChevronRight,
|
||||||
|
CreditCard
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default async function PayslipsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
|
||||||
|
const payslips = await prisma.payslip.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: [
|
||||||
|
{ year: 'desc' },
|
||||||
|
{ month: 'desc' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-extrabold tracking-tight">My Payslips</h2>
|
||||||
|
<p className="text-neutral-500 mt-2 font-medium">Securely access and download your past salary statements.</p>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl h-14 px-8 font-black shadow-2xl shadow-indigo-100 dark:shadow-none transition-all active:scale-95 flex items-center gap-2">
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
Download All (2024)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-[2.5rem] overflow-hidden">
|
||||||
|
<div className="p-8 border-b border-neutral-100 dark:border-neutral-800 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||||
|
<Input placeholder="Search payslips..." className="pl-12 h-14 rounded-2xl bg-neutral-50 dark:bg-neutral-800 border-none shadow-none text-base" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" className="rounded-2xl border-neutral-200 h-14 px-6 font-bold hover:bg-white transition-all">
|
||||||
|
2024
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="rounded-2xl border-neutral-200 h-14 px-6 font-bold hover:bg-white transition-all">
|
||||||
|
All Years
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-100 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-800/50">
|
||||||
|
<th className="p-8 text-xs font-black uppercase tracking-[0.2em] text-neutral-400">Statement Ref</th>
|
||||||
|
<th className="p-8 text-xs font-black uppercase tracking-[0.2em] text-neutral-400">Period</th>
|
||||||
|
<th className="p-8 text-xs font-black uppercase tracking-[0.2em] text-neutral-400">Net Pay</th>
|
||||||
|
<th className="p-8 text-xs font-black uppercase tracking-[0.2em] text-neutral-400">Date Issued</th>
|
||||||
|
<th className="p-8 text-xs font-black uppercase tracking-[0.2em] text-neutral-400 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{payslips.map((slip) => (
|
||||||
|
<tr key={slip.id} className="hover:bg-neutral-50/50 transition-all group">
|
||||||
|
<td className="p-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-900/20 flex items-center justify-center">
|
||||||
|
<FileText className="w-6 h-6 text-indigo-600 dark:text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-neutral-900 dark:text-neutral-100">PAY-{slip.year}-{slip.month.substring(0, 3).toUpperCase()}</p>
|
||||||
|
<p className="text-[10px] font-black text-neutral-400 uppercase tracking-tighter">PDF Statement</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-8">
|
||||||
|
<p className="font-bold text-neutral-900 dark:text-neutral-100">{slip.month} {slip.year}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-8">
|
||||||
|
<span className="text-xl font-black text-indigo-600">
|
||||||
|
{formatCurrency(slip.amount.toString())}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-8 text-neutral-500 font-medium">
|
||||||
|
{new Date(slip.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="p-8 flex justify-end gap-3 px-8">
|
||||||
|
<Button variant="ghost" className="rounded-2xl w-14 h-14 p-0 text-neutral-400 hover:text-indigo-600 hover:bg-white transition-all shadow-sm border border-transparent hover:border-neutral-100">
|
||||||
|
<Eye className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="rounded-2xl w-14 h-14 p-0 text-neutral-400 hover:text-emerald-600 hover:bg-white transition-all shadow-sm border border-transparent hover:border-neutral-100">
|
||||||
|
<Download className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{payslips.length === 0 && (
|
||||||
|
<div className="p-20 text-center">
|
||||||
|
<div className="w-20 h-20 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<FileText className="w-10 h-10 text-neutral-300" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black mb-2">No Payslips Yet</h3>
|
||||||
|
<p className="text-neutral-500 font-medium">Your payslips will appear here once issued by payroll.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-indigo-500/5 bg-gradient-to-br from-white to-indigo-50/30 dark:from-neutral-900 dark:to-indigo-950/10 rounded-[2.5rem] p-10 flex flex-col md:flex-row items-center gap-8 border border-indigo-100/50">
|
||||||
|
<div className="w-20 h-20 bg-indigo-600 rounded-3xl flex items-center justify-center shrink-0 shadow-2xl shadow-indigo-200">
|
||||||
|
<CreditCard className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center md:text-left">
|
||||||
|
<h3 className="text-2xl font-black mb-2">Payroll Information</h3>
|
||||||
|
<p className="text-neutral-500 font-medium leading-relaxed">
|
||||||
|
Your salary is deposited into your primary bank account ending in <span className="text-indigo-600 font-bold font-mono">****4590</span> on the last business day of each month.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="rounded-2xl h-14 px-8 border-neutral-200 font-bold hover:bg-white shrink-0 group">
|
||||||
|
Update Banking
|
||||||
|
<ChevronRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { submitReimbursement } from "@/app/lib/actions";
|
||||||
|
import { ReimbursementForm } from "./reimbursement-form";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Tag,
|
||||||
|
Calendar,
|
||||||
|
Search,
|
||||||
|
Filter
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default async function ReimbursementsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
|
||||||
|
const reimbursements = await prisma.reimbursement.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Reimbursements</h2>
|
||||||
|
<p className="text-neutral-500 mt-1">Manage and track your business expense claims.</p>
|
||||||
|
</div>
|
||||||
|
<ReimbursementForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-2xl shadow-black/5 bg-white dark:bg-neutral-900 rounded-3xl overflow-hidden">
|
||||||
|
<div className="p-8 flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-neutral-100 dark:border-neutral-800">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||||
|
<Input placeholder="Search claims..." className="pl-10 rounded-xl bg-neutral-50 dark:bg-neutral-800 border-none shadow-none" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" className="rounded-xl border-neutral-200 flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-neutral-50/50 dark:bg-neutral-800/50">
|
||||||
|
<th className="p-4 px-8 text-xs font-bold uppercase tracking-wider text-neutral-500">Status</th>
|
||||||
|
<th className="p-4 text-xs font-bold uppercase tracking-wider text-neutral-500">Details</th>
|
||||||
|
<th className="p-4 text-xs font-bold uppercase tracking-wider text-neutral-500">Category</th>
|
||||||
|
<th className="p-4 text-xs font-bold uppercase tracking-wider text-neutral-500">Date</th>
|
||||||
|
<th className="p-4 px-8 text-xs font-bold uppercase tracking-wider text-neutral-500 text-right">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{reimbursements.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors group cursor-pointer">
|
||||||
|
<td className="p-4 px-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.status === 'PAID' && <CheckCircle2 className="w-4 h-4 text-emerald-500" />}
|
||||||
|
{item.status === 'APPROVED' && <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />}
|
||||||
|
{item.status === 'PENDING' && <Clock className="w-4 h-4 text-amber-500" />}
|
||||||
|
<span className={`text-[10px] font-bold uppercase px-2 py-1 rounded-full ${item.status === 'PAID' ? 'bg-emerald-100 text-emerald-700' :
|
||||||
|
item.status === 'APPROVED' ? 'bg-indigo-100 text-indigo-700' :
|
||||||
|
'bg-amber-100 text-amber-700'
|
||||||
|
}`}>
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<p className="font-semibold text-neutral-900 dark:text-neutral-100">{item.description}</p>
|
||||||
|
<p className="text-[10px] text-neutral-400 uppercase tracking-tight">Ref: #{item.id.slice(-8)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 border-none">
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm text-neutral-500">
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 border-none">
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm text-neutral-500">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(item.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 px-8 border-none text-right">
|
||||||
|
<span className="font-bold text-lg text-neutral-900 dark:text-neutral-100">
|
||||||
|
{formatCurrency(item.amount.toString())}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { submitReimbursement } from "@/app/lib/actions";
|
||||||
|
|
||||||
|
export function ReimbursementForm() {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitReimbursement(formData);
|
||||||
|
setShowForm(false);
|
||||||
|
alert("Claim submitted successfully!");
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to submit claim.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl px-6 py-6 h-auto shadow-lg shadow-indigo-200 dark:shadow-none transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
{showForm ? "Cancel Request" : (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
New Request
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<Card className="border-none shadow-2xl shadow-indigo-500/10 bg-white dark:bg-neutral-900 rounded-3xl overflow-hidden animate-in zoom-in-95 duration-300 absolute top-24 right-0 left-0 z-20 mx-8">
|
||||||
|
<CardHeader className="p-8">
|
||||||
|
<CardTitle>Submit New Request</CardTitle>
|
||||||
|
<CardDescription>Fill in the details and attach your receipt</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-8 pt-0">
|
||||||
|
<form action={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-sm font-semibold">Description</Label>
|
||||||
|
<Input name="description" placeholder="e.g. Flight to Berlin" required className="rounded-xl border-neutral-200 focus:ring-indigo-500" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="amount" className="text-sm font-semibold">Amount ($)</Label>
|
||||||
|
<Input name="amount" type="number" step="0.01" placeholder="0.00" required className="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category" className="text-sm font-semibold">Category</Label>
|
||||||
|
<select name="category" className="w-full rounded-xl border-neutral-200 bg-white dark:bg-neutral-950 p-2 text-sm focus:ring-2 focus:ring-indigo-500 outline-none h-10 border">
|
||||||
|
<option>Travel</option>
|
||||||
|
<option>Food</option>
|
||||||
|
<option>Equipment</option>
|
||||||
|
<option>Medical</option>
|
||||||
|
<option>Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date" className="text-sm font-semibold">Transaction Date</Label>
|
||||||
|
<Input name="date" type="date" required className="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Receipt Image / PDF</Label>
|
||||||
|
<div className="border-2 border-dashed border-neutral-200 dark:border-neutral-800 rounded-3xl p-12 text-center hover:border-indigo-400 hover:bg-indigo-50/50 transition-all cursor-pointer group">
|
||||||
|
<div className="w-16 h-16 bg-neutral-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center mx-auto mb-4 group-hover:bg-white dark:group-hover:bg-neutral-700 transition-colors shadow-sm">
|
||||||
|
<Upload className="w-8 h-8 text-neutral-400 group-hover:text-indigo-600 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Click to upload or drag and drop</p>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">PNG, JPG or PDF up to 10MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 flex justify-end">
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="w-full md:w-auto bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl px-12 py-6 h-auto transition-all">
|
||||||
|
{isSubmitting ? <Loader2 className="w-5 h-5 animate-spin" /> : "Submit Claim"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,65 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
redirect('/dashboard');
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useActionState } from 'react';
|
||||||
|
import { authenticate } from '@/app/lib/actions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import { useFormStatus } from 'react-dom';
|
||||||
|
|
||||||
|
export default function LoginForm() {
|
||||||
|
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">Login</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email below to login to your account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form action={dispatch}>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="flex h-8 items-end space-x-1" aria-live="polite" aria-atomic="true">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||||
|
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<LoginButton />
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button className="w-full" aria-disabled={pending} disabled={pending}>
|
||||||
|
{pending ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
import type { NextAuthConfig } from 'next-auth';
|
||||||
|
|
||||||
|
export const authConfig = {
|
||||||
|
pages: {
|
||||||
|
signIn: '/login',
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
authorized({ auth, request: { nextUrl } }) {
|
||||||
|
const isLoggedIn = !!auth?.user;
|
||||||
|
const isOnLogin = nextUrl.pathname.startsWith('/login');
|
||||||
|
const isPublicPath = nextUrl.pathname === '/' || nextUrl.pathname.startsWith('/api') || isOnLogin;
|
||||||
|
|
||||||
|
if (isOnLogin) {
|
||||||
|
if (isLoggedIn) return Response.redirect(new URL('/dashboard', nextUrl));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn && !isPublicPath) {
|
||||||
|
return false; // Redirect unauthenticated users to login page
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providers: [], // Add providers with an empty array for now
|
||||||
|
} satisfies NextAuthConfig;
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import NextAuth from 'next-auth';
|
||||||
|
import { authConfig } from './auth.config';
|
||||||
|
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { prisma } from './lib/prisma';
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
...authConfig,
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: 'Credentials',
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" }
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) return null;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: credentials.email as string }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.password) return null;
|
||||||
|
|
||||||
|
const passwordsMatch = await bcrypt.compare(
|
||||||
|
credentials.password as string,
|
||||||
|
user.password
|
||||||
|
);
|
||||||
|
|
||||||
|
if (passwordsMatch) return user;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.role = (user as any).role;
|
||||||
|
token.id = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token) {
|
||||||
|
(session.user as any).role = token.role;
|
||||||
|
(session.user as any).id = token.id;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@ -0,0 +1,242 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Receipt,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
UserCheck,
|
||||||
|
BookOpen,
|
||||||
|
PieChart,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
ChevronRight,
|
||||||
|
Wallet,
|
||||||
|
Shield,
|
||||||
|
Briefcase,
|
||||||
|
Layers
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
interface SidebarItem {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
icon: any;
|
||||||
|
badge?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarGroup {
|
||||||
|
label: string;
|
||||||
|
items: SidebarItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerGroups: SidebarGroup[] = [
|
||||||
|
{
|
||||||
|
label: "Main",
|
||||||
|
items: [
|
||||||
|
{ title: "Dashboard", href: "/me/dashboard", icon: LayoutDashboard },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Activities",
|
||||||
|
items: [
|
||||||
|
{ title: "Reimbursements", href: "/me/reimbursements", icon: Receipt, badge: "3" },
|
||||||
|
{ title: "Overtime", href: "/me/overtime", icon: Clock },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Documents",
|
||||||
|
items: [
|
||||||
|
{ title: "Payslips", href: "/me/payslips", icon: FileText },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminGroups: SidebarGroup[] = [
|
||||||
|
{
|
||||||
|
label: "Management",
|
||||||
|
items: [
|
||||||
|
{ title: "Dashboard", href: "/admin/dashboard", icon: LayoutDashboard },
|
||||||
|
{ title: "Approvals", href: "/admin/approvals", icon: UserCheck, badge: "12" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Accounting",
|
||||||
|
items: [
|
||||||
|
{ title: "Company Ledger", href: "/admin/ledger", icon: BookOpen },
|
||||||
|
{ title: "Budgeting", href: "/admin/budgeting", icon: PieChart },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Analytics",
|
||||||
|
items: [
|
||||||
|
{ title: "Reports", href: "/admin/reports", icon: BarChart3 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
portal: "worker" | "admin";
|
||||||
|
user: {
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ portal, user }: SidebarProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isAdmin = portal === 'admin';
|
||||||
|
const groups = isAdmin ? adminGroups : workerGroups;
|
||||||
|
|
||||||
|
// Role-based theme configuration
|
||||||
|
const theme = {
|
||||||
|
accent: isAdmin ? "emerald" : "indigo",
|
||||||
|
gradient: isAdmin
|
||||||
|
? "from-emerald-600 to-teal-600"
|
||||||
|
: "from-indigo-600 to-violet-600",
|
||||||
|
bgActive: isAdmin
|
||||||
|
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400"
|
||||||
|
: "bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-400",
|
||||||
|
iconActive: isAdmin ? "text-emerald-600 dark:text-emerald-400" : "text-indigo-600 dark:text-indigo-400",
|
||||||
|
glow: isAdmin ? "shadow-emerald-200/50" : "shadow-indigo-200/50"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-72 h-screen flex flex-col bg-white dark:bg-neutral-900 border-r border-neutral-100 dark:border-neutral-800 shadow-sm z-50">
|
||||||
|
{/* Brand Section */}
|
||||||
|
<div className="p-8 pb-6">
|
||||||
|
<div className="flex items-center gap-4 group cursor-pointer">
|
||||||
|
<div className={cn(
|
||||||
|
"w-12 h-12 rounded-2xl bg-gradient-to-br flex items-center justify-center shadow-xl transition-all duration-500 group-hover:scale-110 group-hover:rotate-3",
|
||||||
|
theme.gradient,
|
||||||
|
theme.glow
|
||||||
|
)}>
|
||||||
|
<Wallet className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-extrabold text-xl tracking-tight text-neutral-900 dark:text-white leading-none">
|
||||||
|
Finance<span className={isAdmin ? "text-emerald-600" : "text-indigo-600"}>TAM</span>
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] font-bold uppercase tracking-[0.15em] text-neutral-400">
|
||||||
|
{isAdmin ? <Shield className="w-3 h-3" /> : <Layers className="w-3 h-3" />}
|
||||||
|
{user.role || 'Portal'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Groups */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-8 scrollbar-none">
|
||||||
|
{groups.map((group, groupIdx) => (
|
||||||
|
<div key={groupIdx} className="space-y-3">
|
||||||
|
<h3 className="px-4 text-[10px] font-black uppercase tracking-[0.2em] text-neutral-400/80">
|
||||||
|
{group.label}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center gap-3.5 px-4 py-3.5 rounded-2xl text-[13px] font-bold transition-all duration-300 group",
|
||||||
|
isActive
|
||||||
|
? theme.bgActive
|
||||||
|
: "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100 hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Active Background Pill */}
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="active-pill"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 rounded-2xl z-0",
|
||||||
|
isAdmin ? "bg-emerald-500/5" : "bg-indigo-500/5"
|
||||||
|
)}
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<item.icon className={cn(
|
||||||
|
"w-5 h-5 z-10 transition-transform duration-300 group-hover:scale-110",
|
||||||
|
isActive ? theme.iconActive : "text-neutral-400 group-hover:text-neutral-600 dark:group-hover:text-neutral-300"
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<span className="z-10">{item.title}</span>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{item.badge && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className={cn(
|
||||||
|
"ml-auto px-2 py-0.5 rounded-lg text-[10px] font-black tracking-tight z-10",
|
||||||
|
isActive
|
||||||
|
? (isAdmin ? "bg-emerald-600 text-white" : "bg-indigo-600 text-white")
|
||||||
|
: "bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.badge}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<div className={cn(
|
||||||
|
"absolute left-0 w-1 h-6 rounded-r-full",
|
||||||
|
isAdmin ? "bg-emerald-600 shadow-[2px_0_8px_rgba(16,185,129,0.4)]" : "bg-indigo-600 shadow-[2px_0_8px_rgba(79,70,229,0.4)]"
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User & Footer */}
|
||||||
|
<div className="p-4 mt-auto">
|
||||||
|
<div className="bg-neutral-50 dark:bg-neutral-800/50 rounded-3xl p-4 border border-neutral-100 dark:border-neutral-800 relative group transition-all hover:bg-white dark:hover:bg-neutral-800 hover:shadow-xl hover:shadow-black/5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-2xl bg-gradient-to-br flex items-center justify-center text-sm font-black text-white shadow-md",
|
||||||
|
theme.gradient
|
||||||
|
)}>
|
||||||
|
{user.name?.charAt(0) || user.email?.charAt(0) || 'U'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-black text-neutral-900 dark:text-neutral-100 truncate tracking-tight">{user.name || 'User Name'}</p>
|
||||||
|
<p className="text-[10px] font-bold text-neutral-400 truncate tracking-tight">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-neutral-200/50 dark:border-neutral-700/50">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start gap-3 text-neutral-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-900/10 rounded-2xl px-3 py-2.5 h-auto transition-all duration-300 font-bold text-xs group"
|
||||||
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center group-hover:bg-rose-100 dark:group-hover:bg-rose-900/30 transition-colors">
|
||||||
|
<LogOut className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</div>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[9px] text-center mt-4 font-bold uppercase tracking-[0.25em] text-neutral-300 dark:text-neutral-600">
|
||||||
|
Vers. 2.4.0 • Secured
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client"
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg"
|
||||||
|
import pg from "pg"
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString })
|
||||||
|
const adapter = new PrismaPg(pool)
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ?? new PrismaClient({ adapter })
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number | string) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(Number(amount));
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import NextAuth from 'next-auth';
|
||||||
|
import { authConfig } from './auth.config';
|
||||||
|
|
||||||
|
export default NextAuth(authConfig).auth;
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
|
||||||
|
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
MANAGER
|
||||||
|
WORKER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
PAID
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
email String @unique
|
||||||
|
password String?
|
||||||
|
role Role @default(WORKER)
|
||||||
|
department String?
|
||||||
|
reimbursements Reimbursement[]
|
||||||
|
overtimes Overtime[]
|
||||||
|
payslips Payslip[]
|
||||||
|
logs AuditLog[]
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
balance Decimal @db.Decimal(20, 2)
|
||||||
|
type String // "ASSET" or "LIABILITY"
|
||||||
|
transactions CompanyTransaction[]
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
parentId String?
|
||||||
|
parent Category? @relation("CategoryToCategory", fields: [parentId], references: [id])
|
||||||
|
children Category[] @relation("CategoryToCategory")
|
||||||
|
transactions CompanyTransaction[]
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Reimbursement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
amount Decimal @db.Decimal(20, 2)
|
||||||
|
description String
|
||||||
|
category String // e.g., "Travel", "Medical"
|
||||||
|
receiptUrl String? // URL from S3/Uploadthing
|
||||||
|
status Status @default(PENDING)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Overtime {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
date DateTime
|
||||||
|
hours Float
|
||||||
|
reason String
|
||||||
|
status Status @default(PENDING)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model CompanyTransaction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
amount Decimal @db.Decimal(20, 2)
|
||||||
|
type String // "CREDIT" or "DEBIT"
|
||||||
|
description String
|
||||||
|
accountId String
|
||||||
|
account Account @relation(fields: [accountId], references: [id])
|
||||||
|
categoryId String?
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
date DateTime @default(now())
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
action String // e.g., "UPDATE_REIMBURSEMENT"
|
||||||
|
entityId String?
|
||||||
|
oldValue String?
|
||||||
|
newValue String?
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
}
|
||||||
|
model Budget {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
department String
|
||||||
|
amount Decimal @db.Decimal(20, 2)
|
||||||
|
period String // e.g., "2024-Q1", "2024-01"
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Payslip {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
month Int // 1-12
|
||||||
|
year Int
|
||||||
|
pdfUrl String
|
||||||
|
amount Decimal @db.Decimal(20, 2)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue