feat(frontend): wire Vite app to FastAPI parser with NDJSON streaming + Upload page
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Banknote,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Pill,
|
||||
UserPlus,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { fmt } from "@/lib/format";
|
||||
import type { Activity } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const kindConfig: Record<
|
||||
Activity["kind"],
|
||||
{ icon: LucideIcon; tone: string }
|
||||
> = {
|
||||
claim_submitted: { icon: FileText, tone: "text-[hsl(var(--accent))]" },
|
||||
claim_accepted: { icon: CheckCircle2, tone: "text-[hsl(var(--success))]" },
|
||||
claim_paid: { icon: Banknote, tone: "text-[hsl(var(--success))]" },
|
||||
claim_denied: { icon: XCircle, tone: "text-destructive" },
|
||||
remit_received: { icon: Pill, tone: "text-[hsl(var(--warning))]" },
|
||||
provider_added: { icon: UserPlus, tone: "text-foreground" },
|
||||
};
|
||||
|
||||
export function ActivityFeed({
|
||||
items,
|
||||
emptyMessage = "No activity yet.",
|
||||
}: {
|
||||
items: Activity[];
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground px-2 py-6 text-center">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="divide-y divide-border/40">
|
||||
{items.map((a) => {
|
||||
const cfg = kindConfig[a.kind];
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 h-7 w-7 shrink-0 rounded-md bg-muted/50 flex items-center justify-center",
|
||||
cfg.tone
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-foreground/95 leading-snug">
|
||||
{a.message}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 flex items-center gap-2">
|
||||
<span>{fmt.relative(a.timestamp)}</span>
|
||||
{a.npi ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="num">{a.npi}</span>
|
||||
</>
|
||||
) : null}
|
||||
{a.amount ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="num">{fmt.usd(a.amount)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useCountUp } from "@/hooks/useCountUp";
|
||||
|
||||
interface AnimatedNumberProps {
|
||||
value: number;
|
||||
format: (n: number) => string;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
format,
|
||||
duration,
|
||||
className,
|
||||
}: AnimatedNumberProps) {
|
||||
const n = useCountUp(value, duration);
|
||||
return <span className={className}>{format(n)}</span>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ArrowDownRight, ArrowUpRight, type LucideIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface KpiCardProps {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
delta?: { value: string; direction: "up" | "down"; positive: boolean };
|
||||
hint?: string;
|
||||
icon?: LucideIcon;
|
||||
sparkline?: number[];
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
hint,
|
||||
icon: Icon,
|
||||
sparkline,
|
||||
className,
|
||||
style,
|
||||
}: KpiCardProps) {
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={cn(
|
||||
"surface rounded-xl p-5 flex flex-col gap-3 transition-colors hover:bg-muted/20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
{Icon ? (
|
||||
<div className="h-6 w-6 rounded-md bg-muted/60 flex items-center justify-center">
|
||||
<Icon className="h-3 w-3 text-muted-foreground" strokeWidth={1.75} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="display text-[32px] leading-none text-foreground mt-0.5">
|
||||
{value}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs min-h-[16px]">
|
||||
{delta ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 font-medium num",
|
||||
delta.positive ? "text-[hsl(var(--success))]" : "text-destructive"
|
||||
)}
|
||||
>
|
||||
{delta.direction === "up" ? (
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-3 w-3" />
|
||||
)}
|
||||
{delta.value}
|
||||
</span>
|
||||
) : null}
|
||||
{hint ? (
|
||||
<span className="text-muted-foreground">{hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{sparkline ? (
|
||||
<div className="-mx-5 -mb-5 mt-2 pt-3 pb-4 border-t border-border/40">
|
||||
<Sparkline values={sparkline} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<div className="relative min-h-screen z-10">
|
||||
<Sidebar />
|
||||
<main className="md:pl-60">
|
||||
<div className="mx-auto max-w-[1400px] px-8 py-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAppStore } from "@/store";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Claim } from "@/types";
|
||||
|
||||
interface FormState {
|
||||
patientName: string;
|
||||
providerNpi: string;
|
||||
payerName: string;
|
||||
cptCode: string;
|
||||
billedAmount: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const initial: FormState = {
|
||||
patientName: "",
|
||||
providerNpi: "",
|
||||
payerName: "Blue Cross Blue Shield",
|
||||
cptCode: "99213",
|
||||
billedAmount: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export function NewClaimDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(initial);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const addClaim = useAppStore((s) => s.addClaim);
|
||||
const providers = useAppStore((s) => s.providers);
|
||||
|
||||
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.patientName || !form.providerNpi || !form.billedAmount) {
|
||||
toast.error("Please fill in patient, provider, and billed amount.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
const claim: Claim = {
|
||||
id: `CLM-${Math.floor(10000 + Math.random() * 89999)}`,
|
||||
patientName: form.patientName,
|
||||
providerNpi: form.providerNpi,
|
||||
payerName: form.payerName,
|
||||
cptCode: form.cptCode,
|
||||
billedAmount: Number(form.billedAmount) || 0,
|
||||
receivedAmount: 0,
|
||||
status: "submitted",
|
||||
submissionDate: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
if (api.isConfigured) {
|
||||
await api.createClaim(claim);
|
||||
}
|
||||
addClaim(claim);
|
||||
toast.success(`Claim ${claim.id} submitted`);
|
||||
setForm(initial);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to submit claim");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Claim
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Submit a new claim</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create an 837P claim. The submission will be queued for the next
|
||||
clearinghouse cycle.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="patientName">Patient</Label>
|
||||
<Input
|
||||
id="patientName"
|
||||
autoFocus
|
||||
placeholder="Full name"
|
||||
value={form.patientName}
|
||||
onChange={(e) => update("patientName", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>Provider (NPI)</Label>
|
||||
<Select
|
||||
value={form.providerNpi}
|
||||
onValueChange={(v) => update("providerNpi", v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p.npi} value={p.npi}>
|
||||
{p.npi} — {p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cptCode">CPT</Label>
|
||||
<Input
|
||||
id="cptCode"
|
||||
placeholder="99213"
|
||||
value={form.cptCode}
|
||||
onChange={(e) => update("cptCode", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>Payer</Label>
|
||||
<Select
|
||||
value={form.payerName}
|
||||
onValueChange={(v) => update("payerName", v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[
|
||||
"Blue Cross Blue Shield",
|
||||
"United Healthcare",
|
||||
"Aetna",
|
||||
"Cigna",
|
||||
"Humana",
|
||||
"Medicare",
|
||||
"Medicaid TX",
|
||||
].map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="billedAmount">Billed amount (USD)</Label>
|
||||
<Input
|
||||
id="billedAmount"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={form.billedAmount}
|
||||
onChange={(e) => update("billedAmount", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Notes (optional)</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Internal notes — not transmitted to payer"
|
||||
value={form.notes}
|
||||
onChange={(e) => update("notes", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? "Submitting…" : "Submit claim"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
LayoutDashboard,
|
||||
Receipt,
|
||||
Stethoscope,
|
||||
Upload as UploadIcon,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const nav = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
||||
{ to: "/claims", label: "Claims", icon: Receipt },
|
||||
{ to: "/remittances", label: "Remittances", icon: Stethoscope },
|
||||
{ to: "/providers", label: "Providers", icon: Users },
|
||||
{ to: "/upload", label: "Upload", icon: UploadIcon },
|
||||
{ to: "/activity", label: "Activity Log", icon: Activity },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 z-30 border-r border-border/60 bg-sidebar/60 backdrop-blur-xl">
|
||||
<div className="flex h-16 items-center px-6 border-b border-border/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-7 w-7 rounded-md bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5 text-white" fill="none">
|
||||
<path
|
||||
d="M6 8h8a3 3 0 0 1 0 6h-5a3 3 0 0 0 0 6h9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="leading-none">
|
||||
<div className="text-[16px] font-semibold tracking-tight text-foreground">
|
||||
Cyclone
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-muted-foreground mt-0.5">
|
||||
CuNtx · Claims
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 py-6">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground px-3 mb-2">
|
||||
Workspace
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{nav.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "nav-active"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" strokeWidth={1.75} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="px-3 py-4 border-t border-border/60">
|
||||
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 cursor-default">
|
||||
<div className="h-8 w-8 rounded-full bg-accent flex items-center justify-center text-[11px] font-semibold text-white ring-1 ring-inset ring-white/10">
|
||||
JK
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-medium">Jordan K.</div>
|
||||
<div className="text-[11px] text-muted-foreground">Administrator</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
interface SparklineProps {
|
||||
values: number[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal SVG sparkline. Normalises to its own range, draws a stroked line
|
||||
* with a soft fill, and marks the last point. Stretch-friendly via
|
||||
* preserveAspectRatio="none" + non-scaling-stroke.
|
||||
*/
|
||||
export function Sparkline({ values, className }: SparklineProps) {
|
||||
if (!values || values.length < 2) return null;
|
||||
const w = 100;
|
||||
const h = 28;
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = h - ((v - min) / range) * (h - 4) - 2;
|
||||
return [x, y] as const;
|
||||
});
|
||||
|
||||
const line = pts
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)} ${p[1].toFixed(2)}`)
|
||||
.join(" ");
|
||||
const area = `${line} L ${w} ${h} L 0 ${h} Z`;
|
||||
const last = pts[pts.length - 1]!;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
width="100%"
|
||||
height="36"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={area} fill="hsl(var(--accent) / 0.13)" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="hsl(var(--accent))"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
cx={last[0]}
|
||||
cy={last[1]}
|
||||
r="2"
|
||||
fill="hsl(var(--accent))"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { ClaimStatus, RemittanceStatus } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const claimStyles: Record<ClaimStatus, { dot: string; label: string }> = {
|
||||
draft: { dot: "bg-muted-foreground", label: "Draft" },
|
||||
submitted: { dot: "bg-[hsl(var(--accent))]", label: "Submitted" },
|
||||
accepted: { dot: "bg-[hsl(var(--success))]", label: "Accepted" },
|
||||
denied: { dot: "bg-destructive", label: "Denied" },
|
||||
paid: { dot: "bg-[hsl(var(--success))]", label: "Paid" },
|
||||
pending: { dot: "bg-[hsl(var(--warning))]", label: "Pending" },
|
||||
};
|
||||
|
||||
const remitStyles: Record<RemittanceStatus, { dot: string; label: string }> = {
|
||||
received: { dot: "bg-[hsl(var(--accent))]", label: "Received" },
|
||||
posted: { dot: "bg-[hsl(var(--warning))]", label: "Posted" },
|
||||
reconciled: { dot: "bg-[hsl(var(--success))]", label: "Reconciled" },
|
||||
};
|
||||
|
||||
export function ClaimStatusBadge({ status }: { status: ClaimStatus }) {
|
||||
const s = claimStyles[status];
|
||||
return (
|
||||
<Badge variant="outline" className="bg-transparent border-border/60">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", s.dot)} />
|
||||
<span className="font-normal">{s.label}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function RemitStatusBadge({ status }: { status: RemittanceStatus }) {
|
||||
const s = remitStyles[status];
|
||||
return (
|
||||
<Badge variant="outline" className="bg-transparent border-border/60">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", s.dot)} />
|
||||
<span className="font-normal">{s.label}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary/15 text-primary",
|
||||
secondary: "border-transparent bg-muted text-muted-foreground",
|
||||
success:
|
||||
"border-transparent bg-[hsl(var(--success)/0.15)] text-[hsl(var(--success))]",
|
||||
warning:
|
||||
"border-transparent bg-[hsl(var(--warning)/0.15)] text-[hsl(var(--warning))]",
|
||||
destructive:
|
||||
"border-transparent bg-destructive/15 text-destructive",
|
||||
outline: "border-border text-foreground",
|
||||
muted: "border-transparent bg-muted/60 text-muted-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
accent:
|
||||
"bg-accent text-accent-foreground shadow-sm hover:bg-accent/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-border bg-transparent text-foreground hover:bg-muted",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-muted",
|
||||
ghost: "bg-transparent text-foreground hover:bg-muted",
|
||||
link: "bg-transparent text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-lg px-6",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"surface rounded-xl text-card-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1 p-6", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-base font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 surface rounded-2xl p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Input, Textarea };
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xs font-medium uppercase tracking-wider text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md surface text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn("[&_tr]:border-b [&_tr]:border-border/60", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b border-border/40 transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-4 text-left align-middle text-[11px] font-medium uppercase tracking-wider text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||
Reference in New Issue
Block a user