diff --git a/.gitignore b/.gitignore index 5ef6a52..45254b6 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/app/generated/prisma diff --git a/app/admin/approvals/approval-actions.tsx b/app/admin/approvals/approval-actions.tsx new file mode 100644 index 0000000..3655524 --- /dev/null +++ b/app/admin/approvals/approval-actions.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/app/admin/approvals/page.tsx b/app/admin/approvals/page.tsx new file mode 100644 index 0000000..1633bdc --- /dev/null +++ b/app/admin/approvals/page.tsx @@ -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 ( +
+
+
+

Approval Queue

+

Review and process worker reimbursement and overtime requests.

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + + + + + + + + + + + {allRequests.map((req) => ( + + + + + + + + ))} + +
UserTypeDescriptionAmountActions
+
+
+ {req.user.name[0]} +
+
+

{req.user.name}

+

{req.user.department}

+
+
+
+ + {req.type === 'reimbursement' ? : } + {req.type} + + +

{req.description}

+

{new Date(req.createdAt).toLocaleDateString()}

+
+ + {formatCurrency(req.amount.toString())} + + + +
+ {allRequests.length === 0 && ( +
+
+ +
+

Queue is Clear!

+

All worker requests have been processed.

+
+ )} +
+
+
+ ); +} diff --git a/app/admin/budgeting/page.tsx b/app/admin/budgeting/page.tsx new file mode 100644 index 0000000..922fe36 --- /dev/null +++ b/app/admin/budgeting/page.tsx @@ -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 ( +
+
+
+

Department Budgeting

+

Set and monitor expenditure limits across the organization.

+
+ +
+ +
+ +
+
+
+ +
+
+

Total Budget Allocation

+

FY 2024 • Q1 ACTIVE

+
+
+ +
+
+
+

Overall Utilization

+

78%

+
+
+
+
+
+ +
+
+

Total Limit

+

$510,000.00

+
+
+

Spent to Date

+

$400,000.00

+
+
+
+
+ + {/* Decorative blobs */} +
+ + + +
+
+ +
+
+

Budget Insights

+

Automated analysis of current spend

+
+
+
+
+
+ Critical Alert + +
+

Marketing Budget at 96%

+

Marketing spend is significantly higher than projected. Approvals for non-essential spend restricted.

+
+ +
+
+ Optimization + +
+

HR Savings Opportunity

+

Current HR spend is 45% below budget. Consider reallocating $15k to Engineering Q2.

+
+
+
+
+ +
+

+ + Departmental Breakdown +

+
+ {budgets.map((b, i) => { + const percent = (b.spent / b.limit) * 100; + return ( + +
+
+
+ +
+ +
+

{b.department}

+

Current Period

+ +
+
+ Utilization + 90 ? 'text-rose-600' : 'text-neutral-900 dark:text-neutral-100'}>{percent.toFixed(0)}% +
+
+
+
+
+
+ +
+
+ Spent + ${(b.spent / 1000).toFixed(0)}k +
+
+ Limit + ${(b.limit / 1000).toFixed(0)}k +
+
+ + ); + })} +
+
+
+ ); +} diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..66ba53e --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -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 ( +
+
+
+

Admin Console

+

Real-time overview of company finances and worker requests.

+
+ + + +
+ +
+ {[ + { 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) => ( + +
+
+ +
+

{stat.label}

+
+

{stat.value}

+
+

+ {stat.trend} +

+
+
+ ))} +
+ +
+ +
+

Financial Ledger

+ + + +
+
+ {(recentTransactions as any[]).map((tx) => ( +
+
+
+ {tx.type === 'DEBIT' ? '-' : '+'} +
+
+

{tx.description}

+

{new Date(tx.date).toLocaleDateString()}

+
+
+
+

+ {tx.type === 'DEBIT' ? '-' : '+'}{formatCurrency(tx.amount.toString())} +

+ Success +
+
+ ))} +
+
+ +
+ +
+

+ + Budget Health +

+

+ Total budget utilization is at 68%. Marketing and Sales are nearing their monthly limits. +

+ + + +
+
+ + +

Departmental Spend

+
+ {[ + { 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) => ( +
+
+ {item.label} + 80 ? 'text-rose-500' : ''}>{item.value}% +
+
+
+
+
+ ))} +
+ +
+
+
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..693c4b3 --- /dev/null +++ b/app/admin/layout.tsx @@ -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 ( +
+ +
+
+ {children} +
+ + {/* Background Accents */} +
+
+
+
+ ); +} diff --git a/app/admin/ledger/page.tsx b/app/admin/ledger/page.tsx new file mode 100644 index 0000000..b003253 --- /dev/null +++ b/app/admin/ledger/page.tsx @@ -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 ( +
+
+
+

Company Ledger

+

Full transaction history and cash flow tracking.

+
+
+ + +
+
+ +
+ {[ + { 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) => ( + +
+
+ +
+

{stat.label}

+

{formatCurrency(stat.amount.toString())}

+
+
+ ))} +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + + ))} + +
TransactionAccountCategoryAmountDate
+
+
+ {tx.type === 'DEBIT' ? : } +
+
+

{tx.description}

+

TX-{tx.id.substring(0, 8).toUpperCase()}

+
+
+
+ {tx.account.name} + + + {tx.category?.name || 'Uncategorized'} + + + + {tx.type === 'DEBIT' ? '-' : '+'}{formatCurrency(tx.amount.toString())} + + +

{new Date(tx.date).toLocaleDateString()}

+

14:20 PM

+
+ {transactions.length === 0 && ( +
+
+ +
+

No Transactions Found

+

Start recording financial activities to see them here.

+
+ )} +
+
+
+ ); +} diff --git a/app/admin/reports/page.tsx b/app/admin/reports/page.tsx new file mode 100644 index 0000000..765de30 --- /dev/null +++ b/app/admin/reports/page.tsx @@ -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 ( +
+
+
+

Financial Intel

+

Detailed P&L and Cash Flow analysis for the current fiscal year.

+
+
+ + +
+
+ +
+ +
+
+

Net Cash Flow Trajectory

+

Comparison of gross inflows vs primary outflows

+
+
+ + +18.4% +
+
+ +
+ + + + + + + + + + + + + + + `$${v / 1000}k`} /> + + + + + +
+ +
+ + + +

Expense Allocation

+
+ + + + {categoryData.map((entry, index) => ( + + ))} + + + + +
+
+ {categoryData.map((c) => ( +
+
+
+ {c.name} +
+
+ {c.value}% + +
+
+ ))} +
+ +
+ +
+ {[ + { 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) => ( + +

{stat.label}

+

{stat.value}

+
+ {stat.sub} + + {stat.trend} + +
+
+ ))} +
+ + +
+
+

Automated P&L Audit

+

Generate a comprehensive profit and loss statement for external audit in minutes.

+
+
+ + +
+
+
+ +
+ ); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..af30e32 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from "@/auth" // Referring to the auth.ts we just created +export const { GET, POST } = handlers; diff --git a/app/api/seed/route.ts b/app/api/seed/route.ts new file mode 100644 index 0000000..a394351 --- /dev/null +++ b/app/api/seed/route.ts @@ -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 }); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..2f61485 --- /dev/null +++ b/app/dashboard/page.tsx @@ -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 ( +
+
+

Dashboard

+
+

Logged in as: {session?.user?.email}

+
{ + 'use server'; + await signOut(); + }} + > + +
+
+
+ +
+ + + Total Balance + + +

$124,500.00

+
+
+ + + Monthly Spend + + +

-$24,300.00

+
+
+ + + Pending Approvals + + +

5

+
+
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..382ca14 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,126 @@ @import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --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 { - --background: #0a0a0a; - --foreground: #ededed; - } +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --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 { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --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; + } +} \ No newline at end of file diff --git a/app/lib/actions.ts b/app/lib/actions.ts new file mode 100644 index 0000000..396657f --- /dev/null +++ b/app/lib/actions.ts @@ -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'); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..b7c7afe --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,16 @@ +import LoginForm from '@/app/ui/login-form'; + +export default function LoginPage() { + return ( +
+
+
+
+ TAM Finance +
+
+ +
+
+ ); +} diff --git a/app/me/dashboard/page.tsx b/app/me/dashboard/page.tsx new file mode 100644 index 0000000..2edf6fa --- /dev/null +++ b/app/me/dashboard/page.tsx @@ -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 ( +
+
+
+

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

+

Your financial activity and pending requests at a glance.

+
+
+ + + + +
+
+ +
+ {[ + { 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) => ( + +
+
+ +
+

{stat.label}

+
+

{stat.value}

+
+

+ + {stat.trend} +

+
+
+ ))} +
+ +
+ +
+

Recent Activity

+ +
+
+ {reimbursements.map((item) => ( +
+
+
+ +
+
+

{item.description}

+
+ {item.category} + {new Date(item.createdAt).toLocaleDateString()} +
+
+
+
+

{formatCurrency(item.amount.toString())}

+ {item.status} +
+
+ ))} +
+
+ +
+ +
+

+ + Quick Wallet Tip +

+

+ You have {pendingClaimsCount} pending reimbursement requests. Claims are usually processed within 48 hours. +

+ + + +
+
+ + +

Spending Snapshot

+
+ {[ + { 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) => ( +
+
+ {item.label} + {item.value}% +
+
+
+
+
+ ))} +
+ +
+
+
+ ); +} diff --git a/app/me/layout.tsx b/app/me/layout.tsx new file mode 100644 index 0000000..18e20be --- /dev/null +++ b/app/me/layout.tsx @@ -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 ( +
+ +
+
+ {children} +
+ + {/* Subtle Background Elements for Modern Look */} +
+
+
+
+ ); +} diff --git a/app/me/overtime/overtime-form.tsx b/app/me/overtime/overtime-form.tsx new file mode 100644 index 0000000..b59dbbf --- /dev/null +++ b/app/me/overtime/overtime-form.tsx @@ -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 ( + + +
+ Log Extra Hours + Enter details for your additional work hours +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ +