feat(ui): shared component library — buttons, badges, cards, inputs, header, stat cards
This commit is contained in:
parent
62c5a52fc0
commit
77aafcce99
11 changed files with 472 additions and 0 deletions
72
App/pelagia-portal/components/dashboard/spend-charts.tsx
Normal file
72
App/pelagia-portal/components/dashboard/spend-charts.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{vesselData.length > 0 && (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 mb-4">Approved Spend by Vessel (Top 5)</h2>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={vesselData} margin={{ top: 4, right: 8, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||
<YAxis tickFormatter={currencyTick} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} width={60} />
|
||||
<Tooltip formatter={(v) => [currencyTooltip(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
|
||||
<Bar dataKey="amount" fill="#4f46e5" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{monthData.length > 0 && (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 mb-4">Approved Spend by Month</h2>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={monthData} margin={{ top: 4, right: 8, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||
<YAxis tickFormatter={currencyTick} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} width={60} />
|
||||
<Tooltip formatter={(v) => [currencyTooltip(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
|
||||
<Bar dataKey="amount" fill="#0ea5e9" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
App/pelagia-portal/components/dashboard/stat-card.tsx
Normal file
60
App/pelagia-portal/components/dashboard/stat-card.tsx
Normal file
|
|
@ -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 = (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-neutral-200 bg-white p-5 flex items-center gap-4",
|
||||
href && "hover:border-neutral-300 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex h-12 w-12 shrink-0 items-center justify-center rounded-xl", colors.iconBg)}>
|
||||
<Icon className={cn("h-6 w-6", colors.icon)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-neutral-900">{value}</p>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return <Link href={href}>{card}</Link>;
|
||||
}
|
||||
return card;
|
||||
}
|
||||
40
App/pelagia-portal/components/layout/header.tsx
Normal file
40
App/pelagia-portal/components/layout/header.tsx
Normal file
|
|
@ -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<Role, string> = {
|
||||
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 (
|
||||
<header className="flex h-16 items-center justify-between border-b border-neutral-200 bg-white px-6">
|
||||
<div />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-neutral-900">{user.name}</p>
|
||||
<p className="text-xs text-neutral-500">{ROLE_LABELS[user.role]}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="flex items-center gap-1.5 rounded-lg p-2 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
104
App/pelagia-portal/components/layout/sidebar.tsx
Normal file
104
App/pelagia-portal/components/layout/sidebar.tsx
Normal file
|
|
@ -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 (
|
||||
<aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
|
||||
<div className="flex h-16 items-center gap-2.5 border-b border-neutral-200 px-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
|
||||
<Anchor className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-neutral-900">Pelagia Portal</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-0.5">
|
||||
{visible.map((item) => (
|
||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="pt-4 pb-1 px-3">
|
||||
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider">
|
||||
Administration
|
||||
</p>
|
||||
</div>
|
||||
{ADMIN_ITEMS.map((item) => (
|
||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary-50 text-primary-700"
|
||||
: "text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
43
App/pelagia-portal/components/ui/admin-dialog.tsx
Normal file
43
App/pelagia-portal/components/ui/admin-dialog.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
|
||||
<h2 className="text-base font-semibold text-neutral-900">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 transition-colors text-lg leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
App/pelagia-portal/components/ui/badge.tsx
Normal file
29
App/pelagia-portal/components/ui/badge.tsx
Normal file
|
|
@ -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<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { badgeVariants };
|
||||
40
App/pelagia-portal/components/ui/button.tsx
Normal file
40
App/pelagia-portal/components/ui/button.tsx
Normal file
|
|
@ -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<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ className, variant, size, asChild, ...props }: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { buttonVariants };
|
||||
32
App/pelagia-portal/components/ui/card.tsx
Normal file
32
App/pelagia-portal/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-lg border border-neutral-200 bg-white shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1 p-6 pb-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn("text-base font-semibold text-neutral-900", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-sm text-neutral-500", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
}
|
||||
18
App/pelagia-portal/components/ui/input.tsx
Normal file
18
App/pelagia-portal/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export function Input({ className, type, ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-400",
|
||||
"focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
App/pelagia-portal/components/ui/label.tsx
Normal file
17
App/pelagia-portal/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
className={cn(
|
||||
"block text-sm font-medium text-neutral-700 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
App/pelagia-portal/components/ui/textarea.tsx
Normal file
17
App/pelagia-portal/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
export function Textarea({ className, ...props }: TextareaProps) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-lg border border-neutral-300 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 resize-none",
|
||||
"focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue