diff --git a/apps/loan-qc/next-env.d.ts b/apps/loan-qc/next-env.d.ts new file mode 100644 index 000000000..c4b7818fb --- /dev/null +++ b/apps/loan-qc/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/loan-qc/next.config.ts b/apps/loan-qc/next.config.ts new file mode 100644 index 000000000..3cefa3f70 --- /dev/null +++ b/apps/loan-qc/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const config: NextConfig = { + reactStrictMode: true, +}; + +export default config; diff --git a/apps/loan-qc/package.json b/apps/loan-qc/package.json new file mode 100644 index 000000000..bce736ad7 --- /dev/null +++ b/apps/loan-qc/package.json @@ -0,0 +1,43 @@ +{ + "name": "loan-qc", + "version": "1.0.0", + "private": true, + "description": "Loan Document Quality Control Interface", + "scripts": { + "dev": "next --turbopack", + "build": "next build", + "start": "next start", + "format": "biome format --write .", + "format:check": "biome format .", + "lint": "biome check ." + }, + "packageManager": "pnpm@10.18.1", + "dependencies": { + "@radix-ui/react-avatar": "^1.1.6", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^11.18.2", + "lucide-react": "^0.468.0", + "next": "16.1.1", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-resizable-panels": "^3.0.6", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.6", + "@tailwindcss/postcss": "^4.1.17", + "@types/node": "^24.10.1", + "@types/react": "^19.2.6", + "@types/react-dom": "^19.2.2", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.3" + } +} diff --git a/apps/loan-qc/postcss.config.mjs b/apps/loan-qc/postcss.config.mjs new file mode 100644 index 000000000..7059fe95a --- /dev/null +++ b/apps/loan-qc/postcss.config.mjs @@ -0,0 +1,6 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; +export default config; diff --git a/apps/loan-qc/public/documents/agreement-to-provide-insurance.png b/apps/loan-qc/public/documents/agreement-to-provide-insurance.png new file mode 100644 index 000000000..f0a87e5bf Binary files /dev/null and b/apps/loan-qc/public/documents/agreement-to-provide-insurance.png differ diff --git a/apps/loan-qc/public/documents/credit-approval-memo.png b/apps/loan-qc/public/documents/credit-approval-memo.png new file mode 100644 index 000000000..731d26d51 Binary files /dev/null and b/apps/loan-qc/public/documents/credit-approval-memo.png differ diff --git a/apps/loan-qc/src/app/globals.css b/apps/loan-qc/src/app/globals.css new file mode 100644 index 000000000..e7e114b3a --- /dev/null +++ b/apps/loan-qc/src/app/globals.css @@ -0,0 +1,122 @@ +@import 'tailwindcss'; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-review: var(--review); + --color-review-foreground: var(--review-foreground); + + /* Spacing tokens */ + --p-0: 0px; + --p-0\,5: 2px; + --p-1: 4px; + --p-2: 8px; + --p-2\,5: 10px; + --p-4: 16px; + --p-5: 20px; + + /* Shadow tokens */ + --shadow-xs: rgba(26, 26, 26, 0.05); + --shadow-2xs: rgba(26, 26, 26, 0.05); + + /* Font tokens */ + --text-xs: 12px; + --text-sm: 14px; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --leading-4: 16px; + --leading-5: 20px; +} + +:root { + --background: oklch(1 0 89.8800); /* white */ + --foreground: oklch(0.2394 0.0455 252.4500); /* dark gray */ + --card: oklch(1 0 89.8800); + --card-foreground: oklch(0.2394 0.0455 252.4500); + --popover: oklch(0.9723 0.0074 260.7300); + --popover-foreground: oklch(0.2394 0.0455 252.4500); + --primary: oklch(0.6256 0.1350 192.1770); /* teal for AI "Yes" */ + --primary-foreground: oklch(0.1677 0.0533 192.1770); /* dark teal */ + --secondary: oklch(0.9593 0.0069 247.9000); /* light gray */ + --secondary-foreground: oklch(0.2394 0.0455 252.4500); /* dark gray */ + --muted: oklch(0.9630 0.0062 255.4800); + --muted-foreground: oklch(0.4594 0.0280 264.2500); + --accent: oklch(0.9593 0.0069 247.9000); + --accent-foreground: oklch(0.2394 0.0455 252.4500); + --destructive: oklch(0.6256 0.1933 23.0300); /* red for AI "No" */ + --destructive-foreground: oklch(0.2 0.0800 23.0300); /* dark red */ + --border: oklch(0.9229 0.0065 252.1300); + --input: oklch(0.9229 0.0065 252.1300); + --ring: oklch(0.6920 0.1119 207.0600); + --radius: 10px; + --warning: oklch(0.8485 0.1486 86.7370); /* yellow for "Inconclusive" */ + --warning-foreground: oklch(0.2091 0.0409 81.7000); /* dark yellow/brown */ + --review: oklch(0.6911 0.1601 38.7050); /* orange for "Needs review" */ + --review-foreground: oklch(0.1677 0.0513 30.0000); /* dark orange */ +} + +.dark { + --background: oklch(0.1393 0.0201 264.5200); + --foreground: oklch(0.9719 0.0069 264.5400); + --card: oklch(0.1393 0.0201 264.5200); + --card-foreground: oklch(0.9719 0.0069 264.5400); + --popover: oklch(0.1598 0.0230 264.3700); + --popover-foreground: oklch(0.9719 0.0069 264.5400); + --primary: oklch(0.6256 0.1350 192.1770); + --primary-foreground: oklch(1 0 89.8800); + --secondary: oklch(0.2197 0.0253 264.5200); + --secondary-foreground: oklch(0.9719 0.0069 264.5400); + --muted: oklch(0.2197 0.0253 264.5200); + --muted-foreground: oklch(0.6493 0.0331 264.3400); + --accent: oklch(0.2197 0.0253 264.5200); + --accent-foreground: oklch(0.9719 0.0069 264.5400); + --destructive: oklch(0.6256 0.1933 23.0300); + --destructive-foreground: oklch(1 0 89.8800); + --border: oklch(0.2197 0.0253 264.5200); + --input: oklch(0.2197 0.0253 264.5200); + --ring: oklch(0.7050 0.1070 207.0600); + --warning: oklch(0.8485 0.1486 86.7370); + --warning-foreground: oklch(1 0 89.8800); + --review: oklch(0.6911 0.1601 38.7050); + --review-foreground: oklch(1 0 89.8800); +} + +* { + box-sizing: border-box; +} + +body { + font-family: var(--font-sans, system-ui, sans-serif); + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/apps/loan-qc/src/app/layout.tsx b/apps/loan-qc/src/app/layout.tsx new file mode 100644 index 000000000..ba70eb461 --- /dev/null +++ b/apps/loan-qc/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Loan QC - Document Quality Control", + description: "AI-assisted loan document quality control interface", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/apps/loan-qc/src/app/page.tsx b/apps/loan-qc/src/app/page.tsx new file mode 100644 index 000000000..135ae4466 --- /dev/null +++ b/apps/loan-qc/src/app/page.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { motion, AnimatePresence } from 'framer-motion'; +import { Minimize, Maximize } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { MinHeader } from '@/components/layout/MinHeader'; +import { ObjectHeader } from '@/components/layout/ObjectHeader'; +import { SplitViewLayout } from '@/components/layout/SplitViewLayout'; +import { ValidationChecklist } from '@/components/validation/ValidationChecklist'; +import { DocumentViewer } from '@/components/document/DocumentViewer'; +import { DocumentToggle } from '@/components/document/DocumentToggle'; +import { useValidationState } from '@/hooks/useValidationState'; +import { useDocumentView } from '@/hooks/useDocumentView'; +import { mockDocuments } from '@/lib/validation-data'; + +export default function LoanQCPage() { + const { items, expandedItemId, setExpandedItemId, updateHumanEvaluation, addComment } = useValidationState(); + const { viewMode, activeDocument, enterImmersiveMode, exitImmersiveMode, switchDocument } = + useDocumentView(); + + const [doc1, doc2] = mockDocuments; + + // Extract highlights for each document from validation items + const doc1Highlights = items.flatMap( + (item) => + item.documentHighlights?.filter((h) => h.documentId === doc1.id) || [], + ); + const doc2Highlights = items.flatMap( + (item) => + item.documentHighlights?.filter((h) => h.documentId === doc2.id) || [], + ); + + const isImmersive = viewMode === 'immersive-left' || viewMode === 'immersive-right'; + const isSplit = viewMode === 'split'; + + return ( +
+ + +
+ + } + documentArea={ + + {/* Document header buttons in split view */} + + {isSplit && ( + +
+ {/* Left button */} + + + + + {/* Right button */} + + + +
+
+ )} +
+ + {/* Immersive controls */} + + {isImmersive && ( + + + + )} + + + + {isImmersive && ( + + + + )} + + + {/* Documents container */} +
+ {isSplit ? ( + + +
+ } + rightDocument={ +
+ +
+ } + /> + ) : ( +
+ {/* Left document */} + + + + + {/* Right document */} + + + +
+ )} +
+ + } + /> +
+ + ); +} diff --git a/apps/loan-qc/src/components/document/DocumentToggle.tsx b/apps/loan-qc/src/components/document/DocumentToggle.tsx new file mode 100644 index 000000000..74ee356a6 --- /dev/null +++ b/apps/loan-qc/src/components/document/DocumentToggle.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import type { Document } from '@/lib/types'; + +interface DocumentToggleProps { + documents: Document[]; + activeDocumentId: string; + onDocumentChange: (documentId: string) => void; +} + +export function DocumentToggle({ + documents, + activeDocumentId, + onDocumentChange, +}: DocumentToggleProps) { + return ( + { + if (value) onDocumentChange(value); + }} + variant="outline" + spacing={0} + className="shadow-[0px_1px_2px_0px_var(--shadow-xs)]" + > + {documents.map((doc) => ( + + {doc.name} + + ))} + + ); +} diff --git a/apps/loan-qc/src/components/document/DocumentViewer.tsx b/apps/loan-qc/src/components/document/DocumentViewer.tsx new file mode 100644 index 000000000..d3be79b02 --- /dev/null +++ b/apps/loan-qc/src/components/document/DocumentViewer.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; +import type { Document, DocumentHighlight } from '@/lib/types'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; + +interface DocumentViewerProps { + document: Document; + highlights?: DocumentHighlight[]; +} + +const ZOOM_LEVELS = [1, 1.25, 1.5, 1.75, 2, 2.5, 3]; +const DEFAULT_ZOOM_INDEX = 0; + +export function DocumentViewer({ + document, + highlights = [], +}: DocumentViewerProps) { + const [zoomedHighlight, setZoomedHighlight] = useState(null); + const [manualZoomIndex, setManualZoomIndex] = useState(DEFAULT_ZOOM_INDEX); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + + const handleHighlightClick = (highlight: DocumentHighlight, e: React.MouseEvent) => { + // Don't trigger highlight click if we were dragging + if (isDragging) { + e.stopPropagation(); + return; + } + + if (zoomedHighlight?.id === highlight.id) { + setZoomedHighlight(null); + setDragOffset({ x: 0, y: 0 }); + } else { + setZoomedHighlight(highlight); + setDragOffset({ x: 0, y: 0 }); + } + }; + + const handleZoomIn = () => { + setZoomedHighlight(null); + setManualZoomIndex((prev) => Math.min(prev + 1, ZOOM_LEVELS.length - 1)); + }; + + const handleZoomOut = () => { + setZoomedHighlight(null); + setManualZoomIndex((prev) => Math.max(prev - 1, 0)); + }; + + const handleZoomReset = () => { + setZoomedHighlight(null); + setManualZoomIndex(DEFAULT_ZOOM_INDEX); + setDragOffset({ x: 0, y: 0 }); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + // Only enable dragging when zoomed in + const isZoomed = manualZoomIndex > 0 || zoomedHighlight !== null; + if (!isZoomed) return; + + setIsDragging(true); + setDragStart({ + x: e.clientX - dragOffset.x, + y: e.clientY - dragOffset.y, + }); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging) return; + + setDragOffset({ + x: e.clientX - dragStart.x, + y: e.clientY - dragStart.y, + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleMouseLeave = () => { + setIsDragging(false); + }; + + // Calculate zoom transform based on highlight position + const getZoomTransform = (highlight: DocumentHighlight) => { + const { x, y, width, height } = highlight.boundingBox; + + // Calculate center of the highlight + const centerX = x + width / 2; + const centerY = y + height / 2; + + // Calculate scale to make the highlight area fill ~60% of viewport + const targetWidthPercent = 60; + const scale = targetWidthPercent / width; + + // Calculate translation to center the highlight + // We need to translate so the center of the highlight is at viewport center (50%, 50%) + const translateX = (50 - centerX) / (scale / 100); + const translateY = (50 - centerY) / (scale / 100); + + return { + scale: Math.min(scale, 3), // Cap maximum scale at 3x + x: translateX, + y: translateY, + }; + }; + + const baseZoom = zoomedHighlight + ? getZoomTransform(zoomedHighlight) + : { scale: ZOOM_LEVELS[manualZoomIndex], x: 0, y: 0 }; + + // Add drag offset to the transform + const currentZoom = { + scale: baseZoom.scale, + x: baseZoom.x + dragOffset.x, + y: baseZoom.y + dragOffset.y, + }; + + const canZoomIn = manualZoomIndex < ZOOM_LEVELS.length - 1; + const canZoomOut = manualZoomIndex > 0; + const isZoomedOut = manualZoomIndex === DEFAULT_ZOOM_INDEX && !zoomedHighlight; + const isZoomed = !isZoomedOut; + + return ( + +
+ {/* Scrollable Container - scrollbar sits outside */} +
+ {/* Document Container with border */} +
+ + {/* Document Image */} + {document.name} + + {/* Highlights Overlay */} + {highlights.map((highlight) => ( + + +
{ + e.stopPropagation(); + handleHighlightClick(highlight, e); + }} + /> + + {highlight.label && ( + +

{highlight.label}

+
+ )} + + ))} + +
+
+ + {/* Floating Zoom Controls - Positioned outside scrollable area */} +
+ + + + + +

Zoom in

+
+
+ + + + + + +

Zoom to fit

+
+
+ + + + + + +

Zoom out

+
+
+
+
+ + ); +} diff --git a/apps/loan-qc/src/components/layout/Header.tsx b/apps/loan-qc/src/components/layout/Header.tsx new file mode 100644 index 000000000..710967775 --- /dev/null +++ b/apps/loan-qc/src/components/layout/Header.tsx @@ -0,0 +1,21 @@ +"use client"; + +interface HeaderProps { + title: string; + subtitle?: string; +} + +export function Header({ title, subtitle }: HeaderProps) { + return ( +
+
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+
+ ); +} diff --git a/apps/loan-qc/src/components/layout/MinHeader.tsx b/apps/loan-qc/src/components/layout/MinHeader.tsx new file mode 100644 index 000000000..6356a86e5 --- /dev/null +++ b/apps/loan-qc/src/components/layout/MinHeader.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Box, Bell } from 'lucide-react'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; + +export function MinHeader() { + return ( +
+
+ {/* Left section - App branding */} +
+
+ +
+ + Loan processing - QC + +
+ + {/* Right section - User controls */} +
+ + + + JD + + +
+
+
+ ); +} diff --git a/apps/loan-qc/src/components/layout/ObjectHeader.tsx b/apps/loan-qc/src/components/layout/ObjectHeader.tsx new file mode 100644 index 000000000..f40e60179 --- /dev/null +++ b/apps/loan-qc/src/components/layout/ObjectHeader.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { ArrowLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface ObjectHeaderProps { + onBack?: () => void; +} + +export function ObjectHeader({ onBack }: ObjectHeaderProps) { + return ( +
+ +
+

Agreement to provide insurance

+

CRE #00000000000-00

+
+
+ ); +} diff --git a/apps/loan-qc/src/components/layout/SidebarFooter.tsx b/apps/loan-qc/src/components/layout/SidebarFooter.tsx new file mode 100644 index 000000000..b34c5f175 --- /dev/null +++ b/apps/loan-qc/src/components/layout/SidebarFooter.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface SidebarFooterProps { + title: string; + currentIndex: number; + totalCount: number; + onPrevious?: () => void; + onNext?: () => void; +} + +export function SidebarFooter({ + title, + currentIndex, + totalCount, + onPrevious, + onNext, +}: SidebarFooterProps) { + return ( +
+

+ {title} + {` ${currentIndex} of ${totalCount}`} +

+
+ + +
+
+ ); +} diff --git a/apps/loan-qc/src/components/layout/SplitViewLayout.tsx b/apps/loan-qc/src/components/layout/SplitViewLayout.tsx new file mode 100644 index 000000000..766adcc6f --- /dev/null +++ b/apps/loan-qc/src/components/layout/SplitViewLayout.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from '@/components/ui/resizable'; +import type { ReactNode } from 'react'; +import { SidebarFooter } from './SidebarFooter'; + +interface SplitViewLayoutProps { + validationPanel?: ReactNode; + documentArea?: ReactNode; + leftDocument?: ReactNode; + rightDocument?: ReactNode; +} + +export function SplitViewLayout({ + validationPanel, + documentArea, + leftDocument, + rightDocument, +}: SplitViewLayoutProps) { + // If validationPanel is provided, render the main layout with sidebar + if (validationPanel) { + return ( +
+ {/* Decorative ellipse background */} +
+ + + {/* Validation Checklist Panel */} + +
+
+ {validationPanel} +
+ +
+
+ + + + {/* Documents Area */} + + {documentArea} + +
+
+ ); + } + + // Otherwise, render just the document split view + return ( +
+ + {/* Left Document */} + +
+ {leftDocument} +
+
+ + + + {/* Right Document */} + +
+ {rightDocument} +
+
+
+
+ ); +} diff --git a/apps/loan-qc/src/components/ui/avatar.tsx b/apps/loan-qc/src/components/ui/avatar.tsx new file mode 100644 index 000000000..1393f5097 --- /dev/null +++ b/apps/loan-qc/src/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/loan-qc/src/components/ui/badge.tsx b/apps/loan-qc/src/components/ui/badge.tsx new file mode 100644 index 000000000..ac9d712a8 --- /dev/null +++ b/apps/loan-qc/src/components/ui/badge.tsx @@ -0,0 +1,50 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary/20 text-primary-foreground [a&]:hover:bg-primary/30", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive/20 text-destructive-foreground [a&]:hover:bg-destructive/30 dark:bg-destructive/30", + warning: + "border-transparent bg-warning/40 text-warning-foreground [a&]:hover:bg-warning/50", + review: + "border-transparent bg-review text-review-foreground [a&]:hover:bg-review/90", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/apps/loan-qc/src/components/ui/button.tsx b/apps/loan-qc/src/components/ui/button.tsx new file mode 100644 index 000000000..087f24ff9 --- /dev/null +++ b/apps/loan-qc/src/components/ui/button.tsx @@ -0,0 +1,62 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/loan-qc/src/components/ui/popover.tsx b/apps/loan-qc/src/components/ui/popover.tsx new file mode 100644 index 000000000..8b33a1821 --- /dev/null +++ b/apps/loan-qc/src/components/ui/popover.tsx @@ -0,0 +1,48 @@ +"use client"; + +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Popover({ + ...props +}: React.ComponentProps) { + return ; +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return ; +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/apps/loan-qc/src/components/ui/resizable.tsx b/apps/loan-qc/src/components/ui/resizable.tsx new file mode 100644 index 000000000..39caf3c40 --- /dev/null +++ b/apps/loan-qc/src/components/ui/resizable.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { GripVerticalIcon } from "lucide-react"; +import * as React from "react"; +import * as ResizablePrimitive from "react-resizable-panels"; + +import { cn } from "@/lib/utils"; + +function ResizablePanelGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ResizablePanel({ + ...props +}: React.ComponentProps) { + return ; +} + +function ResizableHandle({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean; +}) { + return ( + div]:rotate-90", + className, + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+ ); +} + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/apps/loan-qc/src/components/ui/separator.tsx b/apps/loan-qc/src/components/ui/separator.tsx new file mode 100644 index 000000000..ee7836062 --- /dev/null +++ b/apps/loan-qc/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/loan-qc/src/components/ui/textarea.tsx b/apps/loan-qc/src/components/ui/textarea.tsx new file mode 100644 index 000000000..0735a8ca6 --- /dev/null +++ b/apps/loan-qc/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +