diff --git a/App/pelagia-portal/components/dashboard/spend-charts.tsx b/App/pelagia-portal/components/dashboard/spend-charts.tsx new file mode 100644 index 0000000..9ee6fc7 --- /dev/null +++ b/App/pelagia-portal/components/dashboard/spend-charts.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, +} from "recharts"; + +interface VesselData { + name: string; + amount: number; +} + +interface MonthData { + month: string; + amount: number; +} + +interface Props { + vesselData: VesselData[]; + monthData: MonthData[]; +} + +function currencyTick(value: number): string { + if (value >= 1_000_000) return `₹${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `₹${(value / 1_000).toFixed(0)}K`; + return `₹${value}`; +} + +function currencyTooltip(value: number): string { + return value.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 }); +} + +export function SpendCharts({ vesselData, monthData }: Props) { + return ( +
+ {vesselData.length > 0 && ( +
+

Approved Spend by Vessel (Top 5)

+ + + + + + [currencyTooltip(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} /> + + + +
+ )} + + {monthData.length > 0 && ( +
+

Approved Spend by Month

+ + + + + + [currencyTooltip(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} /> + + + +
+ )} +
+ ); +} diff --git a/App/pelagia-portal/components/dashboard/stat-card.tsx b/App/pelagia-portal/components/dashboard/stat-card.tsx new file mode 100644 index 0000000..6083b32 --- /dev/null +++ b/App/pelagia-portal/components/dashboard/stat-card.tsx @@ -0,0 +1,60 @@ +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import type { LucideIcon } from "lucide-react"; + +interface StatCardProps { + label: string; + value: string | number; + icon: LucideIcon; + color?: "blue" | "green" | "orange" | "red"; + href?: string; +} + +const COLOR_MAP = { + blue: { + bg: "bg-primary-50", + icon: "text-primary-600", + iconBg: "bg-primary-100", + }, + green: { + bg: "bg-success-50", + icon: "text-success", + iconBg: "bg-success-100", + }, + orange: { + bg: "bg-warning-50", + icon: "text-warning", + iconBg: "bg-warning-100", + }, + red: { + bg: "bg-danger-50", + icon: "text-danger", + iconBg: "bg-danger-100", + }, +}; + +export function StatCard({ label, value, icon: Icon, color = "blue", href }: StatCardProps) { + const colors = COLOR_MAP[color]; + + const card = ( +
+
+ +
+
+

{value}

+

{label}

+
+
+ ); + + if (href) { + return {card}; + } + return card; +} diff --git a/App/pelagia-portal/components/layout/header.tsx b/App/pelagia-portal/components/layout/header.tsx new file mode 100644 index 0000000..c8a5497 --- /dev/null +++ b/App/pelagia-portal/components/layout/header.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { signOut } from "next-auth/react"; +import { LogOut } from "lucide-react"; +import type { Role } from "@prisma/client"; + +const ROLE_LABELS: Record = { + TECHNICAL: "Technical", + MANNING: "Manning", + ACCOUNTS: "Accounts", + MANAGER: "Manager", + SUPERUSER: "SuperUser", + AUDITOR: "Auditor", + ADMIN: "Admin", +}; + +interface HeaderProps { + user: { name: string; email: string; role: Role }; +} + +export function Header({ user }: HeaderProps) { + return ( +
+
+
+
+

{user.name}

+

{ROLE_LABELS[user.role]}

+
+ +
+
+ ); +} diff --git a/App/pelagia-portal/components/layout/sidebar.tsx b/App/pelagia-portal/components/layout/sidebar.tsx new file mode 100644 index 0000000..6f684fc --- /dev/null +++ b/App/pelagia-portal/components/layout/sidebar.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { + LayoutDashboard, + FileText, + Plus, + CheckSquare, + CreditCard, + History, + Users, + Ship, + Building2, + Store, + Anchor, + Package, +} from "lucide-react"; +import type { Role } from "@prisma/client"; + +interface NavItem { + href: string; + label: string; + icon: React.ElementType; + roles?: Role[]; +} + +const NAV_ITEMS: NavItem[] = [ + { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] }, + { href: "/my-orders", label: "My Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] }, + { href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] }, + { href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] }, + { href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "ACCOUNTS", "AUDITOR", "ADMIN"] }, +]; + +const ADMIN_ITEMS: NavItem[] = [ + { href: "/admin/users", label: "Users", icon: Users }, + { href: "/admin/vessels", label: "Vessels", icon: Ship }, + { href: "/admin/accounts", label: "Accounts", icon: Building2 }, + { href: "/admin/vendors", label: "Vendors", icon: Store }, + { href: "/admin/products", label: "Products", icon: Package }, +]; + +export function Sidebar({ userRole }: { userRole: Role }) { + const pathname = usePathname(); + const isAdmin = userRole === "ADMIN"; + + const visible = NAV_ITEMS.filter( + (item) => !item.roles || item.roles.includes(userRole) + ); + + return ( + + ); +} + +function NavLink({ item, pathname }: { item: NavItem; pathname: string }) { + const isActive = + pathname === item.href || pathname.startsWith(item.href + "/"); + const Icon = item.icon; + + return ( + + + {item.label} + + ); +} diff --git a/App/pelagia-portal/components/ui/admin-dialog.tsx b/App/pelagia-portal/components/ui/admin-dialog.tsx new file mode 100644 index 0000000..ddfec1e --- /dev/null +++ b/App/pelagia-portal/components/ui/admin-dialog.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect } from "react"; + +interface Props { + title: string; + open: boolean; + onClose: () => void; + children: React.ReactNode; +} + +export function AdminDialog({ title, open, onClose, children }: Props) { + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + if (open) document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ); +} diff --git a/App/pelagia-portal/components/ui/badge.tsx b/App/pelagia-portal/components/ui/badge.tsx new file mode 100644 index 0000000..e3ebb26 --- /dev/null +++ b/App/pelagia-portal/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", + { + variants: { + variant: { + default: "bg-primary-100 text-primary-700", + secondary: "bg-neutral-100 text-neutral-700", + success: "bg-success-100 text-success-700", + warning: "bg-warning-100 text-warning-700", + danger: "bg-danger-100 text-danger-700", + outline: "border border-neutral-300 text-neutral-600", + }, + }, + defaultVariants: { variant: "default" }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +export function Badge({ className, variant, ...props }: BadgeProps) { + return ; +} + +export { badgeVariants }; diff --git a/App/pelagia-portal/components/ui/button.tsx b/App/pelagia-portal/components/ui/button.tsx new file mode 100644 index 0000000..1f9258b --- /dev/null +++ b/App/pelagia-portal/components/ui/button.tsx @@ -0,0 +1,40 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60", + { + variants: { + variant: { + default: "bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500", + secondary: "border border-neutral-300 bg-white text-neutral-700 hover:bg-neutral-50 focus-visible:ring-neutral-400", + destructive: "bg-danger text-white hover:opacity-90 focus-visible:ring-danger", + success: "bg-success text-white hover:opacity-90 focus-visible:ring-success", + warning: "border border-warning bg-warning-50 text-warning-700 hover:bg-warning-100 focus-visible:ring-warning", + ghost: "text-neutral-700 hover:bg-neutral-100 focus-visible:ring-neutral-400", + link: "text-primary-600 underline-offset-4 hover:underline focus-visible:ring-primary-500", + }, + size: { + sm: "h-8 px-3 py-1.5 text-xs", + md: "h-10 px-4 py-2.5", + lg: "h-11 px-5 py-3", + icon: "h-10 w-10", + }, + }, + defaultVariants: { variant: "default", size: "md" }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +export function Button({ className, variant, size, asChild, ...props }: ButtonProps) { + const Comp = asChild ? Slot : "button"; + return ; +} + +export { buttonVariants }; diff --git a/App/pelagia-portal/components/ui/card.tsx b/App/pelagia-portal/components/ui/card.tsx new file mode 100644 index 0000000..6184ca3 --- /dev/null +++ b/App/pelagia-portal/components/ui/card.tsx @@ -0,0 +1,32 @@ +import { cn } from "@/lib/utils"; + +export function Card({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +export function CardHeader({ className, ...props }: React.HTMLAttributes) { + return
; +} + +export function CardTitle({ className, ...props }: React.HTMLAttributes) { + return

; +} + +export function CardDescription({ className, ...props }: React.HTMLAttributes) { + return

; +} + +export function CardContent({ className, ...props }: React.HTMLAttributes) { + return

; +} + +export function CardFooter({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} diff --git a/App/pelagia-portal/components/ui/input.tsx b/App/pelagia-portal/components/ui/input.tsx new file mode 100644 index 0000000..6d890c5 --- /dev/null +++ b/App/pelagia-portal/components/ui/input.tsx @@ -0,0 +1,18 @@ +import { cn } from "@/lib/utils"; + +export interface InputProps extends React.InputHTMLAttributes {} + +export function Input({ className, type, ...props }: InputProps) { + return ( + + ); +} diff --git a/App/pelagia-portal/components/ui/label.tsx b/App/pelagia-portal/components/ui/label.tsx new file mode 100644 index 0000000..7d2c28a --- /dev/null +++ b/App/pelagia-portal/components/ui/label.tsx @@ -0,0 +1,17 @@ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cn } from "@/lib/utils"; + +export function Label({ + className, + ...props +}: React.ComponentPropsWithoutRef) { + return ( + + ); +} diff --git a/App/pelagia-portal/components/ui/textarea.tsx b/App/pelagia-portal/components/ui/textarea.tsx new file mode 100644 index 0000000..fa30a6f --- /dev/null +++ b/App/pelagia-portal/components/ui/textarea.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils"; + +export interface TextareaProps extends React.TextareaHTMLAttributes {} + +export function Textarea({ className, ...props }: TextareaProps) { + return ( +