feat(frontend): wire Vite app to FastAPI parser with NDJSON streaming + Upload page

This commit is contained in:
Tyler
2026-06-19 17:31:59 -06:00
parent 999889ecb3
commit 3d3bdfb07c
44 changed files with 7811 additions and 0 deletions
+83
View File
@@ -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>
);
}
+18
View File
@@ -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>;
}
+78
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+212
View File
@@ -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>
);
}
+88
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+34
View File
@@ -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 };
+58
View File
@@ -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 };
+65
View File
@@ -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 };
+101
View File
@@ -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,
};
+36
View File
@@ -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 };
+20
View File
@@ -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 };
+89
View File
@@ -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,
};
+83
View File
@@ -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 };