First template code generated from replit.com
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.local/state/replit/agent/.agent_state_main.bin
Normal file
BIN
.local/state/replit/agent/.agent_state_main.bin
Normal file
Binary file not shown.
1
.local/state/replit/agent/.latest.json
Normal file
1
.local/state/replit/agent/.latest.json
Normal file
@@ -0,0 +1 @@
|
||||
{"latest": "main"}
|
||||
@@ -0,0 +1,543 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WarehouseTrack Pro - Inventory & Transportation Management</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'roboto': ['Roboto', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: '#1976D2',
|
||||
'primary-dark': '#1565C0',
|
||||
secondary: '#388E3C',
|
||||
warning: '#F57C00',
|
||||
error: '#D32F2F',
|
||||
surface: '#FFFFFF',
|
||||
background: '#FAFAFA'
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'22': '5.5rem'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
body { background: white !important; }
|
||||
}
|
||||
.print-only { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-roboto bg-background text-gray-900 min-h-screen">
|
||||
<!-- @COMPONENT: AppHeader -->
|
||||
<header class="bg-primary text-white shadow-lg sticky top-0 z-50">
|
||||
<div class="container mx-auto px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-warehouse text-2xl"></i>
|
||||
<h1 class="text-xl font-bold">WarehouseTrack Pro</h1>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- @STATE: notificationCount:number = 3 -->
|
||||
<div class="relative">
|
||||
<button class="p-2 rounded-full hover:bg-primary-dark transition-colors" data-event="click:toggleNotifications">
|
||||
<i class="fas fa-bell text-lg"></i>
|
||||
<span class="absolute -top-1 -right-1 bg-error text-white text-xs rounded-full w-5 h-5 flex items-center justify-center" data-bind="notificationCount">3</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="p-2 rounded-full hover:bg-primary-dark transition-colors" data-event="click:toggleMenu">
|
||||
<i class="fas fa-user-circle text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- @END_COMPONENT: AppHeader -->
|
||||
|
||||
<!-- @COMPONENT: BottomNavigation -->
|
||||
<nav class="no-print fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg md:hidden">
|
||||
<div class="flex justify-around py-2">
|
||||
<button class="flex flex-col items-center p-2 text-primary" data-event="click:navigateTo" data-route="dashboard">
|
||||
<i class="fas fa-home text-xl mb-1"></i>
|
||||
<span class="text-xs font-medium">Home</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center p-2 text-gray-500 hover:text-primary" data-event="click:navigateTo" data-route="inventory">
|
||||
<i class="fas fa-boxes text-xl mb-1"></i>
|
||||
<span class="text-xs font-medium">Inventory</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center p-2 text-gray-500 hover:text-primary" data-event="click:navigateTo" data-route="scan">
|
||||
<i class="fas fa-qrcode text-xl mb-1"></i>
|
||||
<span class="text-xs font-medium">Scan</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center p-2 text-gray-500 hover:text-primary" data-event="click:navigateTo" data-route="documents">
|
||||
<i class="fas fa-file-alt text-xl mb-1"></i>
|
||||
<span class="text-xs font-medium">Documents</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center p-2 text-gray-500 hover:text-primary" data-event="click:navigateTo" data-route="reports">
|
||||
<i class="fas fa-chart-bar text-xl mb-1"></i>
|
||||
<span class="text-xs font-medium">Reports</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- @END_COMPONENT: BottomNavigation -->
|
||||
|
||||
<!-- @COMPONENT: MainContent -->
|
||||
<main class="container mx-auto px-4 py-6 pb-20 md:pb-6">
|
||||
<!-- @COMPONENT: AlertBanner -->
|
||||
<div class="mb-6 bg-warning text-white p-4 rounded-lg shadow-md flex items-center justify-between" data-mock="true">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-exclamation-triangle text-xl"></i>
|
||||
<div>
|
||||
<p class="font-semibold">Low Stock Alert</p>
|
||||
<p class="text-sm opacity-90">5 items are running low on inventory</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="p-1 hover:bg-orange-600 rounded" data-event="click:dismissAlert">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- @END_COMPONENT: AlertBanner -->
|
||||
|
||||
<!-- @COMPONENT: QuickActions -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800">Quick Actions</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<button class="bg-white p-6 rounded-xl shadow-md hover:shadow-lg transition-all duration-200 border border-gray-100 touch-manipulation min-h-[120px]" data-event="click:openScanner">
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<i class="fas fa-qrcode text-3xl text-primary"></i>
|
||||
<span class="font-semibold text-gray-800">Scan Product</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="bg-white p-6 rounded-xl shadow-md hover:shadow-lg transition-all duration-200 border border-gray-100 touch-manipulation min-h-[120px]" data-event="click:addStock">
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<i class="fas fa-plus-circle text-3xl text-secondary"></i>
|
||||
<span class="font-semibold text-gray-800">Add Stock</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="bg-white p-6 rounded-xl shadow-md hover:shadow-lg transition-all duration-200 border border-gray-100 touch-manipulation min-h-[120px]" data-event="click:createDocument">
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<i class="fas fa-file-plus text-3xl text-primary"></i>
|
||||
<span class="font-semibold text-gray-800">New Document</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="bg-white p-6 rounded-xl shadow-md hover:shadow-lg transition-all duration-200 border border-gray-100 touch-manipulation min-h-[120px]" data-event="click:viewReports">
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<i class="fas fa-chart-line text-3xl text-secondary"></i>
|
||||
<span class="font-semibold text-gray-800">View Reports</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- @END_COMPONENT: QuickActions -->
|
||||
|
||||
<!-- @COMPONENT: StockOverview -->
|
||||
<section class="mb-8">
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">Stock Overview</h2>
|
||||
<button class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark transition-colors font-medium" data-event="click:refreshStock">
|
||||
<i class="fas fa-sync-alt mr-2"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-blue-50 p-4 rounded-lg border border-blue-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-blue-600 text-sm font-medium uppercase tracking-wide">Total Items</p>
|
||||
<p class="text-2xl font-bold text-blue-800" data-bind="totalItems" data-mock="true">1,247</p>
|
||||
</div>
|
||||
<i class="fas fa-boxes text-blue-400 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 p-4 rounded-lg border border-green-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-green-600 text-sm font-medium uppercase tracking-wide">In Stock</p>
|
||||
<p class="text-2xl font-bold text-green-800" data-bind="inStockItems" data-mock="true">1,089</p>
|
||||
</div>
|
||||
<i class="fas fa-check-circle text-green-400 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-orange-50 p-4 rounded-lg border border-orange-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-orange-600 text-sm font-medium uppercase tracking-wide">Low Stock</p>
|
||||
<p class="text-2xl font-bold text-orange-800" data-bind="lowStockItems" data-mock="true">23</p>
|
||||
</div>
|
||||
<i class="fas fa-exclamation-triangle text-orange-400 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 p-4 rounded-lg border border-red-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-red-600 text-sm font-medium uppercase tracking-wide">Out of Stock</p>
|
||||
<p class="text-2xl font-bold text-red-800" data-bind="outOfStockItems" data-mock="true">8</p>
|
||||
</div>
|
||||
<i class="fas fa-times-circle text-red-400 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- @COMPONENT: ProductSearchBar -->
|
||||
<div class="mb-6">
|
||||
<div class="relative">
|
||||
<input type="text" placeholder="Search products by name, SKU, or barcode..."
|
||||
class="w-full pl-12 pr-4 py-4 text-lg border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
data-event="input:searchProducts" data-mock="true">
|
||||
<i class="fas fa-search absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- @END_COMPONENT: ProductSearchBar -->
|
||||
</div>
|
||||
</section>
|
||||
<!-- @END_COMPONENT: StockOverview -->
|
||||
|
||||
<!-- @COMPONENT: RecentActivity -->
|
||||
<section class="mb-8">
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800">Recent Activity</h2>
|
||||
|
||||
<!-- @MAP: recentActivities.map(activity => ( -->
|
||||
<div class="space-y-4" data-mock="true">
|
||||
<div class="flex items-center space-x-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="w-10 h-10 bg-secondary text-white rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-plus text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold text-gray-800">Stock Added</p>
|
||||
<p class="text-sm text-gray-600">50 units of <span class="font-medium">Safety Helmets (SKU: SH-001)</span></p>
|
||||
<p class="text-xs text-gray-500">2 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="w-10 h-10 bg-primary text-white rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-truck text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold text-gray-800">Shipment Created</p>
|
||||
<p class="text-sm text-gray-600">Delivery note #DN-2024-001 generated</p>
|
||||
<p class="text-xs text-gray-500">15 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="w-10 h-10 bg-orange-500 text-white rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-exclamation text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold text-gray-800">Low Stock Alert</p>
|
||||
<p class="text-sm text-gray-600"><span class="font-medium">Work Gloves (SKU: WG-003)</span> below minimum threshold</p>
|
||||
<p class="text-xs text-gray-500">1 hour ago</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- @END_MAP )) -->
|
||||
</div>
|
||||
</section>
|
||||
<!-- @END_COMPONENT: RecentActivity -->
|
||||
|
||||
<!-- @COMPONENT: DocumentTemplates -->
|
||||
<section class="mb-8">
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800">Document Templates</h2>
|
||||
<p class="text-gray-600 mb-6">Generate transportation and inventory documents quickly using pre-configured templates</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<button class="text-left p-4 border border-gray-200 rounded-lg hover:border-primary hover:bg-blue-50 transition-all duration-200 touch-manipulation" data-event="click:createDeliveryNote">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<i class="fas fa-truck text-primary text-xl"></i>
|
||||
<h3 class="font-semibold text-gray-800">Delivery Note</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">Create delivery documentation for outbound shipments</p>
|
||||
</button>
|
||||
|
||||
<button class="text-left p-4 border border-gray-200 rounded-lg hover:border-primary hover:bg-blue-50 transition-all duration-200 touch-manipulation" data-event="click:createPackingList">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<i class="fas fa-list text-primary text-xl"></i>
|
||||
<h3 class="font-semibold text-gray-800">Packing List</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">Generate detailed packing lists for shipments</p>
|
||||
</button>
|
||||
|
||||
<button class="text-left p-4 border border-gray-200 rounded-lg hover:border-primary hover:bg-blue-50 transition-all duration-200 touch-manipulation" data-event="click:createShippingLabel">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<i class="fas fa-tag text-primary text-xl"></i>
|
||||
<h3 class="font-semibold text-gray-800">Shipping Label</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">Print shipping labels with tracking information</p>
|
||||
</button>
|
||||
|
||||
<button class="text-left p-4 border border-gray-200 rounded-lg hover:border-primary hover:bg-blue-50 transition-all duration-200 touch-manipulation" data-event="click:createGoodsReceipt">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<i class="fas fa-clipboard-check text-primary text-xl"></i>
|
||||
<h3 class="font-semibold text-gray-800">Goods Receipt</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">Document incoming inventory and deliveries</p>
|
||||
</button>
|
||||
|
||||
<button class="text-left p-4 border border-gray-200 rounded-lg hover:border-primary hover:bg-blue-50 transition-all duration-200 touch-manipulation" data-event="click:createStockReport">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<i class="fas fa-chart-bar text-primary text-xl"></i>
|
||||
<h3 class="font-semibold text-gray-800">Stock Report</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">Generate comprehensive inventory reports</p>
|
||||
</button>
|
||||
|
||||
<button class="text-left p-4 border border-gray-200 rounded-lg hover:border-primary hover:bg-blue-50 transition-all duration-200 touch-manipulation" data-event="click:createDispatchNote">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<i class="fas fa-shipping-fast text-primary text-xl"></i>
|
||||
<h3 class="font-semibold text-gray-800">Dispatch Note</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">Create dispatch documentation for outbound goods</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- @END_COMPONENT: DocumentTemplates -->
|
||||
|
||||
<!-- @COMPONENT: InventoryTable -->
|
||||
<section class="mb-8">
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">Current Inventory</h2>
|
||||
<div class="flex space-x-2">
|
||||
<button class="bg-secondary text-white px-4 py-2 rounded-lg hover:bg-green-600 transition-colors font-medium" data-event="click:exportInventory">
|
||||
<i class="fas fa-download mr-2"></i>Export
|
||||
</button>
|
||||
<button class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark transition-colors font-medium" data-event="click:addNewItem">
|
||||
<i class="fas fa-plus mr-2"></i>Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 border-b border-gray-200">
|
||||
<th class="text-left p-4 font-semibold text-gray-700">SKU</th>
|
||||
<th class="text-left p-4 font-semibold text-gray-700">Product Name</th>
|
||||
<th class="text-left p-4 font-semibold text-gray-700">Current Stock</th>
|
||||
<th class="text-left p-4 font-semibold text-gray-700">Min Threshold</th>
|
||||
<th class="text-left p-4 font-semibold text-gray-700">Status</th>
|
||||
<th class="text-left p-4 font-semibold text-gray-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-mock="true">
|
||||
<!-- @MAP: inventoryItems.map(item => ( -->
|
||||
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td class="p-4 font-mono text-sm">SH-001</td>
|
||||
<td class="p-4 font-medium">Safety Helmets - White</td>
|
||||
<td class="p-4">
|
||||
<span class="text-lg font-semibold">125</span>
|
||||
<span class="text-sm text-gray-500 ml-1">units</span>
|
||||
</td>
|
||||
<td class="p-4 text-gray-600">20</td>
|
||||
<td class="p-4">
|
||||
<span class="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium">In Stock</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<div class="flex space-x-2">
|
||||
<button class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" data-event="click:editItem" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors" data-event="click:addStock" title="Add Stock">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<button class="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors" data-event="click:viewHistory" title="View History">
|
||||
<i class="fas fa-history"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td class="p-4 font-mono text-sm">WG-003</td>
|
||||
<td class="p-4 font-medium">Work Gloves - Medium</td>
|
||||
<td class="p-4">
|
||||
<span class="text-lg font-semibold">8</span>
|
||||
<span class="text-sm text-gray-500 ml-1">pairs</span>
|
||||
</td>
|
||||
<td class="p-4 text-gray-600">15</td>
|
||||
<td class="p-4">
|
||||
<span class="px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm font-medium">Low Stock</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<div class="flex space-x-2">
|
||||
<button class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" data-event="click:editItem" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors" data-event="click:addStock" title="Add Stock">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<button class="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors" data-event="click:viewHistory" title="View History">
|
||||
<i class="fas fa-history"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td class="p-4 font-mono text-sm">HV-005</td>
|
||||
<td class="p-4 font-medium">Hi-Vis Vests - Large</td>
|
||||
<td class="p-4">
|
||||
<span class="text-lg font-semibold">0</span>
|
||||
<span class="text-sm text-gray-500 ml-1">units</span>
|
||||
</td>
|
||||
<td class="p-4 text-gray-600">10</td>
|
||||
<td class="p-4">
|
||||
<span class="px-3 py-1 bg-red-100 text-red-800 rounded-full text-sm font-medium">Out of Stock</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<div class="flex space-x-2">
|
||||
<button class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" data-event="click:editItem" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors" data-event="click:addStock" title="Add Stock">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<button class="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors" data-event="click:viewHistory" title="View History">
|
||||
<i class="fas fa-history"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- @END_MAP )) -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
|
||||
<p class="text-sm text-gray-600">Showing 1-3 of 1,247 items</p>
|
||||
<div class="flex space-x-2">
|
||||
<button class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" data-event="click:previousPage">
|
||||
<i class="fas fa-chevron-left mr-2"></i>Previous
|
||||
</button>
|
||||
<button class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" data-event="click:nextPage">
|
||||
Next<i class="fas fa-chevron-right ml-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- @END_COMPONENT: InventoryTable -->
|
||||
|
||||
<!-- @COMPONENT: Scanner Modal (Hidden by default) -->
|
||||
<div id="scannerModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center p-4" data-mock="true">
|
||||
<div class="bg-white rounded-xl p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-bold">Scan Product</h3>
|
||||
<button class="p-2 hover:bg-gray-100 rounded-lg" data-event="click:closeScannerModal">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- @FUNCTIONALITY: This should implement camera-based barcode scanning -->
|
||||
<div class="bg-gray-100 rounded-lg p-8 mb-4 text-center">
|
||||
<i class="fas fa-camera text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-600">Position barcode within frame</p>
|
||||
<p class="text-sm text-gray-500 mt-2">Camera will activate automatically</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<button class="w-full bg-primary text-white py-3 rounded-lg font-medium hover:bg-primary-dark transition-colors" data-event="click:startScanning">
|
||||
<i class="fas fa-camera mr-2"></i>Start Camera
|
||||
</button>
|
||||
<button class="w-full border border-gray-300 py-3 rounded-lg font-medium hover:bg-gray-50 transition-colors" data-event="click:manualEntry">
|
||||
<i class="fas fa-keyboard mr-2"></i>Manual Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- @END_COMPONENT: Scanner Modal -->
|
||||
</main>
|
||||
<!-- @END_COMPONENT: MainContent -->
|
||||
|
||||
<!-- @COMPONENT: FloatingActionButton (Mobile Only) -->
|
||||
<button class="no-print fixed bottom-20 right-4 w-14 h-14 bg-primary text-white rounded-full shadow-lg hover:bg-primary-dark transition-all duration-200 flex items-center justify-center md:hidden" data-event="click:quickAdd">
|
||||
<i class="fas fa-plus text-xl"></i>
|
||||
</button>
|
||||
<!-- @END_COMPONENT: FloatingActionButton -->
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// TODO: Implement business logic, API calls, or state management
|
||||
|
||||
// Demo functionality for showing scanner modal
|
||||
const scannerButtons = document.querySelectorAll('[data-event*="openScanner"]');
|
||||
const scannerModal = document.getElementById('scannerModal');
|
||||
const closeModalButtons = document.querySelectorAll('[data-event*="closeScannerModal"]');
|
||||
|
||||
scannerButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
scannerModal.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
closeModalButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
scannerModal.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
scannerModal.addEventListener('click', (e) => {
|
||||
if (e.target === scannerModal) {
|
||||
scannerModal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Demo navigation for bottom nav
|
||||
const navButtons = document.querySelectorAll('[data-route]');
|
||||
navButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
// Remove active class from all buttons
|
||||
navButtons.forEach(btn => {
|
||||
btn.classList.remove('text-primary');
|
||||
btn.classList.add('text-gray-500');
|
||||
});
|
||||
|
||||
// Add active class to clicked button
|
||||
button.classList.remove('text-gray-500');
|
||||
button.classList.add('text-primary');
|
||||
});
|
||||
});
|
||||
|
||||
// Demo alert dismissal
|
||||
const alertDismissButtons = document.querySelectorAll('[data-event*="dismissAlert"]');
|
||||
alertDismissButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
button.closest('.bg-warning').style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Demo print functionality
|
||||
window.addEventListener('beforeprint', () => {
|
||||
document.body.classList.add('printing');
|
||||
});
|
||||
|
||||
window.addEventListener('afterprint', () => {
|
||||
document.body.classList.remove('printing');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,543 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SkillHub - Find Local Classes & Workshops</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'sans': ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
secondary: '#10b981',
|
||||
accent: '#f59e0b',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-50 font-sans">
|
||||
|
||||
<!-- @COMPONENT: Header [navigation, logo, user menu] -->
|
||||
<header class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-xl font-bold text-primary">SkillHub</h1>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="p-2 text-gray-600 hover:text-primary" data-event="click:toggleSearch">
|
||||
<i class="fas fa-search text-lg"></i>
|
||||
</button>
|
||||
<button class="p-2 text-gray-600 hover:text-primary" data-event="click:showNotifications">
|
||||
<i class="fas fa-bell text-lg"></i>
|
||||
<span class="absolute -mt-2 -mr-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">3</span>
|
||||
</button>
|
||||
<button class="p-2 text-gray-600 hover:text-primary" data-event="click:showProfile">
|
||||
<i class="fas fa-user-circle text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- @END_COMPONENT: Header -->
|
||||
|
||||
<!-- @COMPONENT: SearchBar [location, category, keyword filters] -->
|
||||
<div class="bg-white shadow-sm border-b border-gray-100 p-4" id="searchSection">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
|
||||
<input type="text" placeholder="Search classes, workshops, skills..."
|
||||
class="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-200 rounded-xl text-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
data-bind="searchQuery" data-mock="true">
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3 overflow-x-auto pb-2">
|
||||
<button class="flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-full whitespace-nowrap border-2 border-transparent hover:border-primary"
|
||||
data-event="click:filterLocation">
|
||||
<i class="fas fa-map-marker-alt mr-2"></i>
|
||||
<span data-bind="selectedLocation">Near me</span>
|
||||
</button>
|
||||
<button class="flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-full whitespace-nowrap border-2 border-transparent hover:border-primary"
|
||||
data-event="click:filterCategory">
|
||||
<i class="fas fa-tags mr-2"></i>
|
||||
<span data-bind="selectedCategory">All categories</span>
|
||||
</button>
|
||||
<button class="flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-full whitespace-nowrap border-2 border-transparent hover:border-primary"
|
||||
data-event="click:filterTime">
|
||||
<i class="fas fa-clock mr-2"></i>
|
||||
<span data-bind="selectedTime">Anytime</span>
|
||||
</button>
|
||||
<button class="flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-full whitespace-nowrap border-2 border-transparent hover:border-primary"
|
||||
data-event="click:filterAvailability">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
Available now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- @END_COMPONENT: SearchBar -->
|
||||
|
||||
<!-- @COMPONENT: QuickCategories [popular skill categories] -->
|
||||
<section class="px-4 py-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Popular Categories</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<!-- @MAP: categories.map(category => ( -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 text-center hover:shadow-md transition-shadow cursor-pointer"
|
||||
data-event="click:selectCategory" data-mock="true">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-code text-primary text-xl"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900" data-bind="category.name">Tech Skills</span>
|
||||
<p class="text-xs text-gray-500 mt-1" data-bind="category.count">120+ classes</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 text-center hover:shadow-md transition-shadow cursor-pointer"
|
||||
data-event="click:selectCategory" data-mock="true">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-chart-line text-secondary text-xl"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900" data-bind="category.name">Business</span>
|
||||
<p class="text-xs text-gray-500 mt-1" data-bind="category.count">85+ classes</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 text-center hover:shadow-md transition-shadow cursor-pointer"
|
||||
data-event="click:selectCategory" data-mock="true">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-palette text-purple-600 text-xl"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900" data-bind="category.name">Creative</span>
|
||||
<p class="text-xs text-gray-500 mt-1" data-bind="category.count">65+ classes</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 text-center hover:shadow-md transition-shadow cursor-pointer"
|
||||
data-event="click:selectCategory" data-mock="true">
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-tools text-accent text-xl"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900" data-bind="category.name">Trades</span>
|
||||
<p class="text-xs text-gray-500 mt-1" data-bind="category.count">45+ classes</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 text-center hover:shadow-md transition-shadow cursor-pointer"
|
||||
data-event="click:selectCategory" data-mock="true">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-heartbeat text-red-500 text-xl"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900" data-bind="category.name">Healthcare</span>
|
||||
<p class="text-xs text-gray-500 mt-1" data-bind="category.count">38+ classes</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 text-center hover:shadow-md transition-shadow cursor-pointer"
|
||||
data-event="click:selectCategory" data-mock="true">
|
||||
<div class="w-12 h-12 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-language text-indigo-600 text-xl"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900" data-bind="category.name">Languages</span>
|
||||
<p class="text-xs text-gray-500 mt-1" data-bind="category.count">52+ classes</p>
|
||||
</div>
|
||||
<!-- @END_MAP )) -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- @END_COMPONENT: QuickCategories -->
|
||||
|
||||
<!-- @COMPONENT: FeaturedClasses [highlighted classes with real-time availability] -->
|
||||
<section class="px-4 py-6 bg-white">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Featured Classes</h2>
|
||||
<button class="text-primary text-sm font-medium hover:text-blue-700" data-event="click:viewAllFeatured">
|
||||
View all <i class="fas fa-arrow-right ml-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- @MAP: featuredClasses.map(class => ( -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4 shadow-sm hover:shadow-md transition-shadow" data-mock="true">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-gray-900 text-lg mb-1" data-bind="class.title">
|
||||
Web Development Bootcamp
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm mb-2" data-bind="class.provider">
|
||||
TechSpace Academy
|
||||
</p>
|
||||
<div class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<i class="fas fa-map-marker-alt mr-1"></i>
|
||||
<span data-bind="class.location">Downtown Seattle, WA</span>
|
||||
<span class="mx-2">•</span>
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span data-bind="class.duration">12 weeks</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="flex items-center justify-end mb-1">
|
||||
<span class="text-xs bg-secondary text-white px-2 py-1 rounded-full" data-bind="class.availability">
|
||||
3 spots left
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-lg font-bold text-gray-900" data-bind="class.price">$2,499</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<div class="flex text-yellow-400 text-sm mr-2">
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600" data-bind="class.rating">4.8 (124 reviews)</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500" data-bind="class.startDate">
|
||||
Starts Jan 15
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button class="flex-1 bg-primary text-white py-3 rounded-xl font-medium text-center hover:bg-blue-700 transition-colors"
|
||||
data-event="click:enrollNow">
|
||||
Enroll Now
|
||||
</button>
|
||||
<button class="px-4 py-3 border border-gray-200 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
data-event="click:saveClass">
|
||||
<i class="fas fa-bookmark"></i>
|
||||
</button>
|
||||
<button class="px-4 py-3 border border-gray-200 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
data-event="click:shareClass">
|
||||
<i class="fas fa-share"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4 shadow-sm hover:shadow-md transition-shadow" data-mock="true">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-gray-900 text-lg mb-1" data-bind="class.title">
|
||||
Digital Marketing Fundamentals
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm mb-2" data-bind="class.provider">
|
||||
Marketing Pro Institute
|
||||
</p>
|
||||
<div class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<i class="fas fa-map-marker-alt mr-1"></i>
|
||||
<span data-bind="class.location">Capitol Hill, Seattle</span>
|
||||
<span class="mx-2">•</span>
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span data-bind="class.duration">6 weeks</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="flex items-center justify-end mb-1">
|
||||
<span class="text-xs bg-accent text-white px-2 py-1 rounded-full" data-bind="class.availability">
|
||||
8 spots left
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-lg font-bold text-gray-900" data-bind="class.price">$899</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<div class="flex text-yellow-400 text-sm mr-2">
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="far fa-star"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600" data-bind="class.rating">4.6 (89 reviews)</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500" data-bind="class.startDate">
|
||||
Starts Jan 22
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button class="flex-1 bg-primary text-white py-3 rounded-xl font-medium text-center hover:bg-blue-700 transition-colors"
|
||||
data-event="click:enrollNow">
|
||||
Enroll Now
|
||||
</button>
|
||||
<button class="px-4 py-3 border border-gray-200 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
data-event="click:saveClass">
|
||||
<i class="fas fa-bookmark"></i>
|
||||
</button>
|
||||
<button class="px-4 py-3 border border-gray-200 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
data-event="click:shareClass">
|
||||
<i class="fas fa-share"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-4 shadow-sm hover:shadow-md transition-shadow" data-mock="true">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-gray-900 text-lg mb-1" data-bind="class.title">
|
||||
Certified Nursing Assistant
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm mb-2" data-bind="class.provider">
|
||||
Seattle Health Training Center
|
||||
</p>
|
||||
<div class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<i class="fas fa-map-marker-alt mr-1"></i>
|
||||
<span data-bind="class.location">Bellevue, WA</span>
|
||||
<span class="mx-2">•</span>
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span data-bind="class.duration">8 weeks</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="flex items-center justify-end mb-1">
|
||||
<span class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded-full" data-bind="class.availability">
|
||||
Almost full
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-lg font-bold text-gray-900" data-bind="class.price">$1,299</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<div class="flex text-yellow-400 text-sm mr-2">
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
<i class="fas fa-star"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600" data-bind="class.rating">4.9 (156 reviews)</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500" data-bind="class.startDate">
|
||||
Starts Feb 5
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button class="flex-1 bg-primary text-white py-3 rounded-xl font-medium text-center hover:bg-blue-700 transition-colors"
|
||||
data-event="click:enrollNow">
|
||||
Enroll Now
|
||||
</button>
|
||||
<button class="px-4 py-3 border border-gray-200 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
data-event="click:saveClass">
|
||||
<i class="fas fa-bookmark"></i>
|
||||
</button>
|
||||
<button class="px-4 py-3 border border-gray-200 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
data-event="click:shareClass">
|
||||
<i class="fas fa-share"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- @END_MAP )) -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- @END_COMPONENT: FeaturedClasses -->
|
||||
|
||||
<!-- @COMPONENT: QuickActions [shortcuts for common tasks] -->
|
||||
<section class="px-4 py-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<button class="bg-white border border-gray-200 rounded-xl p-4 text-center hover:shadow-md transition-shadow"
|
||||
data-event="click:findNearMe">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-location-arrow text-primary text-xl"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900">Find Near Me</span>
|
||||
<p class="text-xs text-gray-500 mt-1">Classes within 10 miles</p>
|
||||
</button>
|
||||
|
||||
<button class="bg-white border border-gray-200 rounded-xl p-4 text-center hover:shadow-md transition-shadow"
|
||||
data-event="click:availableToday">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-calendar-day text-secondary text-xl"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900">Available Today</span>
|
||||
<p class="text-xs text-gray-500 mt-1">Start immediately</p>
|
||||
</button>
|
||||
|
||||
<button class="bg-white border border-gray-200 rounded-xl p-4 text-center hover:shadow-md transition-shadow"
|
||||
data-event="click:savedClasses">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-bookmark text-purple-600 text-xl"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900">Saved Classes</span>
|
||||
<p class="text-xs text-gray-500 mt-1" data-bind="savedCount">12 saved</p>
|
||||
</button>
|
||||
|
||||
<button class="bg-white border border-gray-200 rounded-xl p-4 text-center hover:shadow-md transition-shadow"
|
||||
data-event="click:myProgress">
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-chart-line text-accent text-xl"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900">My Progress</span>
|
||||
<p class="text-xs text-gray-500 mt-1">Track your learning</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- @END_COMPONENT: QuickActions -->
|
||||
|
||||
<!-- @COMPONENT: ContentManagement [admin interface for easy updates] -->
|
||||
<section class="px-4 py-6 bg-gray-100 border-t-4 border-accent" id="adminSection" style="display: none;" data-implementation="Show only for admin users">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">
|
||||
<i class="fas fa-cog mr-2"></i>Content Management
|
||||
</h2>
|
||||
<button class="text-sm text-gray-600 hover:text-gray-800" data-event="click:toggleAdmin">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-white rounded-xl p-4 border border-gray-200">
|
||||
<div class="flex items-center mb-3">
|
||||
<i class="fas fa-plus-circle text-secondary text-xl mr-3"></i>
|
||||
<h3 class="font-medium">Add New Class</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-4">Create and publish new classes or workshops</p>
|
||||
<button class="w-full bg-secondary text-white py-2 rounded-lg hover:bg-green-600 transition-colors"
|
||||
data-event="click:addNewClass">
|
||||
Add Class
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 border border-gray-200">
|
||||
<div class="flex items-center mb-3">
|
||||
<i class="fas fa-edit text-primary text-xl mr-3"></i>
|
||||
<h3 class="font-medium">Edit Content</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-4">Update class details, availability, and pricing</p>
|
||||
<button class="w-full bg-primary text-white py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
data-event="click:editContent">
|
||||
Edit Classes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 border border-gray-200">
|
||||
<div class="flex items-center mb-3">
|
||||
<i class="fas fa-chart-bar text-accent text-xl mr-3"></i>
|
||||
<h3 class="font-medium">Analytics</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-4">View enrollment stats and user engagement</p>
|
||||
<button class="w-full bg-accent text-white py-2 rounded-lg hover:bg-yellow-600 transition-colors"
|
||||
data-event="click:viewAnalytics">
|
||||
View Reports
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- @STATE: realtimeUpdates:boolean = true -->
|
||||
<div class="mt-6 bg-white rounded-xl p-4 border border-gray-200">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-medium">Real-time Availability Updates</h3>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" checked class="sr-only peer" data-bind="realtimeUpdates">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">Automatically sync class availability across all platforms</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- @END_COMPONENT: ContentManagement -->
|
||||
|
||||
<!-- @COMPONENT: BottomNavigation [mobile-optimized navigation] -->
|
||||
<nav class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg md:hidden">
|
||||
<div class="grid grid-cols-5 h-16">
|
||||
<button class="flex flex-col items-center justify-center text-primary border-t-2 border-primary"
|
||||
data-event="click:goHome">
|
||||
<i class="fas fa-home text-lg"></i>
|
||||
<span class="text-xs mt-1">Home</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-gray-600 hover:text-primary"
|
||||
data-event="click:goSearch">
|
||||
<i class="fas fa-search text-lg"></i>
|
||||
<span class="text-xs mt-1">Search</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-gray-600 hover:text-primary"
|
||||
data-event="click:goSaved">
|
||||
<i class="fas fa-bookmark text-lg"></i>
|
||||
<span class="text-xs mt-1">Saved</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-gray-600 hover:text-primary"
|
||||
data-event="click:goProgress">
|
||||
<i class="fas fa-chart-line text-lg"></i>
|
||||
<span class="text-xs mt-1">Progress</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-gray-600 hover:text-primary"
|
||||
data-event="click:goProfile">
|
||||
<i class="fas fa-user text-lg"></i>
|
||||
<span class="text-xs mt-1">Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- @END_COMPONENT: BottomNavigation -->
|
||||
|
||||
<!-- @FUNCTIONALITY: Add floating action button for quick class submission by providers -->
|
||||
<button class="fixed bottom-20 right-4 md:bottom-6 w-14 h-14 bg-accent text-white rounded-full shadow-lg hover:shadow-xl transition-shadow flex items-center justify-center"
|
||||
data-event="click:quickSubmit"
|
||||
data-implementation="Should open a form for providers to quickly submit new classes">
|
||||
<i class="fas fa-plus text-xl"></i>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
// TODO: Implement business logic, API calls, and state management
|
||||
|
||||
(function() {
|
||||
// Mock real-time availability updates
|
||||
function updateAvailability() {
|
||||
const availabilityBadges = document.querySelectorAll('[data-bind="class.availability"]');
|
||||
const statuses = ['3 spots left', '8 spots left', 'Almost full', 'Available now', '1 spot left'];
|
||||
const colors = ['bg-secondary', 'bg-accent', 'bg-red-100 text-red-800', 'bg-green-500', 'bg-red-500'];
|
||||
|
||||
availabilityBadges.forEach(badge => {
|
||||
if (Math.random() < 0.1) { // 10% chance to update
|
||||
const randomIndex = Math.floor(Math.random() * statuses.length);
|
||||
badge.textContent = statuses[randomIndex];
|
||||
badge.className = `text-xs text-white px-2 py-1 rounded-full ${colors[randomIndex]}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate real-time updates every 30 seconds
|
||||
setInterval(updateAvailability, 30000);
|
||||
|
||||
// Touch-friendly interaction feedback
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
if (e.target.matches('button, .cursor-pointer')) {
|
||||
e.target.style.transform = 'scale(0.95)';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', function(e) {
|
||||
if (e.target.matches('button, .cursor-pointer')) {
|
||||
e.target.style.transform = 'scale(1)';
|
||||
}
|
||||
});
|
||||
|
||||
// Show admin section for demo (normally would check user permissions)
|
||||
setTimeout(() => {
|
||||
if (window.location.hash === '#admin') {
|
||||
document.getElementById('adminSection').style.display = 'block';
|
||||
}
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
0
.local/state/replit/agent/rapid_build_started
Normal file
0
.local/state/replit/agent/rapid_build_started
Normal file
0
.local/state/replit/agent/rapid_build_success
Normal file
0
.local/state/replit/agent/rapid_build_success
Normal file
BIN
.local/state/replit/agent/repl_state.bin
Normal file
BIN
.local/state/replit/agent/repl_state.bin
Normal file
Binary file not shown.
42
.replit
Normal file
42
.replit
Normal file
@@ -0,0 +1,42 @@
|
||||
modules = ["nodejs-20", "web", "postgresql-16"]
|
||||
run = "npm run dev"
|
||||
hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"]
|
||||
|
||||
[nix]
|
||||
channel = "stable-24_05"
|
||||
|
||||
[deployment]
|
||||
deploymentTarget = "autoscale"
|
||||
build = ["npm", "run", "build"]
|
||||
run = ["npm", "run", "start"]
|
||||
|
||||
[env]
|
||||
PORT = "5000"
|
||||
|
||||
[workflows]
|
||||
runButton = "Project"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "Project"
|
||||
mode = "parallel"
|
||||
author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "workflow.run"
|
||||
args = "Start application"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "Start application"
|
||||
author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "shell.exec"
|
||||
args = "npm run dev"
|
||||
waitForPort = 5000
|
||||
|
||||
[agent]
|
||||
integrations = ["javascript_mem_db==1.0.0"]
|
||||
|
||||
[[ports]]
|
||||
localPort = 5000
|
||||
externalPort = 80
|
||||
48
client/index.html
Normal file
48
client/index.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<!-- Mobile viewport optimized for Android and iOS -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover" />
|
||||
|
||||
<!-- Meta tags for mobile app behavior -->
|
||||
<meta name="description" content="Mobile warehouse inventory management system with document generation and barcode scanning" />
|
||||
<meta name="keywords" content="warehouse, inventory, management, mobile, android, ios, barcode, scanner" />
|
||||
<meta name="author" content="WarehouseTrack Pro" />
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Mobile browser theme colors -->
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="msapplication-TileColor" content="#2563eb" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="WarehouseTrack Pro" />
|
||||
|
||||
<!-- iOS Safari specific -->
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="apple-touch-fullscreen" content="yes" />
|
||||
|
||||
<!-- Android Chrome specific -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<!-- Disable automatic phone number detection -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||
|
||||
<!-- Preload critical fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- App title -->
|
||||
<title>WarehouseTrack Pro - Mobile Inventory Management</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
|
||||
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
109
client/public/manifest.json
Normal file
109
client/public/manifest.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"name": "WarehouseTrack Pro",
|
||||
"short_name": "WarehouseTrack",
|
||||
"description": "Mobile warehouse inventory management system with document generation and barcode scanning",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#2563eb",
|
||||
"background_color": "#ffffff",
|
||||
"orientation": "any",
|
||||
"scope": "/",
|
||||
"categories": ["business", "productivity", "utilities"],
|
||||
"lang": "en",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Scan Product",
|
||||
"short_name": "Scan",
|
||||
"description": "Quick access to barcode scanner",
|
||||
"url": "/scanner",
|
||||
"icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Inventory",
|
||||
"short_name": "Inventory",
|
||||
"description": "View and manage inventory",
|
||||
"url": "/inventory",
|
||||
"icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Documents",
|
||||
"short_name": "Documents",
|
||||
"description": "Create transportation documents",
|
||||
"url": "/documents",
|
||||
"icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }]
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshots/mobile-dashboard.png",
|
||||
"sizes": "390x844",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow",
|
||||
"label": "Mobile Dashboard - Inventory Overview"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/tablet-dashboard.png",
|
||||
"sizes": "768x1024",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "Tablet Dashboard - Enhanced Layout"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/tablet-landscape.png",
|
||||
"sizes": "1024x768",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "Tablet Landscape - Sidebar Navigation"
|
||||
}
|
||||
]
|
||||
}
|
||||
95
client/public/sw.js
Normal file
95
client/public/sw.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// Service Worker for WarehouseTrack Pro PWA
|
||||
const CACHE_NAME = 'warehousetrack-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/src/index.css',
|
||||
'/src/main.tsx',
|
||||
'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap'
|
||||
];
|
||||
|
||||
// Install event
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(urlsToCache))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - Network First Strategy for API calls, Cache First for static assets
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
|
||||
// Handle API requests with network-first strategy
|
||||
if (request.url.includes('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
// Don't cache POST/PUT/DELETE requests
|
||||
if (request.method === 'GET' && response.status === 200) {
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(request, responseClone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to cache if network fails
|
||||
return caches.match(request);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle static assets with cache-first strategy
|
||||
event.respondWith(
|
||||
caches.match(request)
|
||||
.then((response) => {
|
||||
// Return cached version or fetch from network
|
||||
return response || fetch(request)
|
||||
.then((response) => {
|
||||
// Don't cache non-successful responses
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response;
|
||||
}
|
||||
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(request, responseToCache));
|
||||
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Handle background sync for offline actions
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'stock-update') {
|
||||
event.waitUntil(
|
||||
// Handle offline stock updates when back online
|
||||
handleOfflineStockUpdates()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Placeholder function for handling offline updates
|
||||
function handleOfflineStockUpdates() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
62
client/src/App.tsx
Normal file
62
client/src/App.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Switch, Route } from "wouter";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import Dashboard from "@/pages/dashboard";
|
||||
import Inventory from "@/pages/inventory";
|
||||
import Documents from "@/pages/documents";
|
||||
import Scanner from "@/pages/scanner";
|
||||
import Reports from "@/pages/reports";
|
||||
import Header from "@/components/layout/header";
|
||||
import BottomNavigation from "@/components/layout/bottom-navigation";
|
||||
import TabletNavigation from "@/components/layout/tablet-navigation";
|
||||
import FloatingActionButton from "@/components/layout/floating-action-button";
|
||||
import InstallPrompt from "@/components/pwa/install-prompt";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/inventory" component={Inventory} />
|
||||
<Route path="/documents" component={Documents} />
|
||||
<Route path="/scanner" component={Scanner} />
|
||||
<Route path="/reports" component={Reports} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<div className="min-h-screen bg-background font-roboto">
|
||||
{/* Mobile Header - hidden on tablets */}
|
||||
<div className="md:hidden">
|
||||
<Header />
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
{/* Tablet Sidebar Navigation - visible on tablets only */}
|
||||
<TabletNavigation />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 container mx-auto px-4 py-6 pb-20 md:pb-6 lg:pb-6 safe-area md:px-8 lg:px-4">
|
||||
<Router />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Navigation - hidden on tablets */}
|
||||
<BottomNavigation />
|
||||
<FloatingActionButton />
|
||||
<InstallPrompt />
|
||||
<Toaster />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
101
client/src/components/common/alert-banner.tsx
Normal file
101
client/src/components/common/alert-banner.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, X, Info, CheckCircle, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AlertBannerProps {
|
||||
type: "warning" | "error" | "info" | "success";
|
||||
title: string;
|
||||
message: string;
|
||||
dismissible?: boolean;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
const alertConfig = {
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
bgColor: "bg-warning",
|
||||
textColor: "text-warning-foreground",
|
||||
iconColor: "text-warning-foreground",
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
bgColor: "bg-error",
|
||||
textColor: "text-error-foreground",
|
||||
iconColor: "text-error-foreground",
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
bgColor: "bg-primary",
|
||||
textColor: "text-primary-foreground",
|
||||
iconColor: "text-primary-foreground",
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
bgColor: "bg-secondary",
|
||||
textColor: "text-secondary-foreground",
|
||||
iconColor: "text-secondary-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
export default function AlertBanner({
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
dismissible = true,
|
||||
onDismiss,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}: AlertBannerProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
const config = alertConfig[type];
|
||||
const Icon = config.icon;
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between p-4 rounded-lg shadow-md",
|
||||
config.bgColor,
|
||||
config.textColor,
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Icon className={cn("text-xl flex-shrink-0", config.iconColor)} />
|
||||
<div>
|
||||
<p className="font-semibold" data-testid={`${testId}-title`}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-sm opacity-90" data-testid={`${testId}-message`}>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{dismissible && (
|
||||
<Button
|
||||
onClick={handleDismiss}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"p-1 hover:bg-black/10 rounded",
|
||||
config.textColor
|
||||
)}
|
||||
data-testid={`${testId}-dismiss`}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
client/src/components/common/quick-actions.tsx
Normal file
111
client/src/components/common/quick-actions.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QrCode, Plus, FileText, BarChart3 } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import AddStockDialog from "@/components/inventory/add-stock-dialog";
|
||||
import CreateDocumentDialog from "@/components/documents/create-document-dialog";
|
||||
import ScannerModal from "@/components/scanner/scanner-modal";
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
id: "scan",
|
||||
title: "Scan Product",
|
||||
icon: QrCode,
|
||||
color: "text-primary",
|
||||
action: "openScanner",
|
||||
},
|
||||
{
|
||||
id: "add-stock",
|
||||
title: "Add Stock",
|
||||
icon: Plus,
|
||||
color: "text-secondary",
|
||||
action: "addStock",
|
||||
},
|
||||
{
|
||||
id: "create-document",
|
||||
title: "New Document",
|
||||
icon: FileText,
|
||||
color: "text-primary",
|
||||
action: "createDocument",
|
||||
},
|
||||
{
|
||||
id: "view-reports",
|
||||
title: "View Reports",
|
||||
icon: BarChart3,
|
||||
color: "text-secondary",
|
||||
action: "viewReports",
|
||||
},
|
||||
];
|
||||
|
||||
export default function QuickActions() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [isScannerOpen, setIsScannerOpen] = useState(false);
|
||||
const [isAddStockOpen, setIsAddStockOpen] = useState(false);
|
||||
const [isCreateDocumentOpen, setIsCreateDocumentOpen] = useState(false);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
switch (action) {
|
||||
case "openScanner":
|
||||
setIsScannerOpen(true);
|
||||
break;
|
||||
case "addStock":
|
||||
setIsAddStockOpen(true);
|
||||
break;
|
||||
case "createDocument":
|
||||
setIsCreateDocumentOpen(true);
|
||||
break;
|
||||
case "viewReports":
|
||||
setLocation("/reports");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold text-gray-800">
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="tablet-spacing">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 tablet-grid">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
onClick={() => handleAction(action.action)}
|
||||
variant="outline"
|
||||
className="bg-white p-6 rounded-xl shadow-md hover:shadow-lg transition-all duration-200 border border-gray-100 touch-manipulation min-h-[120px] md:min-h-[140px] flex flex-col items-center justify-center space-y-3"
|
||||
data-testid={`button-${action.id}`}
|
||||
>
|
||||
<Icon className={`text-3xl ${action.color}`} />
|
||||
<span className="font-semibold text-gray-800 text-center">
|
||||
{action.title}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ScannerModal
|
||||
open={isScannerOpen}
|
||||
onOpenChange={setIsScannerOpen}
|
||||
/>
|
||||
|
||||
<AddStockDialog
|
||||
open={isAddStockOpen}
|
||||
onOpenChange={setIsAddStockOpen}
|
||||
/>
|
||||
|
||||
<CreateDocumentDialog
|
||||
open={isCreateDocumentOpen}
|
||||
onOpenChange={setIsCreateDocumentOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
161
client/src/components/common/recent-activity.tsx
Normal file
161
client/src/components/common/recent-activity.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Plus, Truck, AlertTriangle, FileText, Package } from "lucide-react";
|
||||
import type { StockMovement, Document, Product } from "@shared/schema";
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: "stock_movement" | "document_created" | "low_stock" | "out_of_stock";
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: Date;
|
||||
icon: typeof Plus;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
export default function RecentActivity() {
|
||||
const { data: stockMovements } = useQuery<StockMovement[]>({
|
||||
queryKey: ["/api/stock-movements"],
|
||||
});
|
||||
|
||||
const { data: documents } = useQuery<Document[]>({
|
||||
queryKey: ["/api/documents"],
|
||||
});
|
||||
|
||||
const { data: products } = useQuery<Product[]>({
|
||||
queryKey: ["/api/products"],
|
||||
});
|
||||
|
||||
// Generate activity items from different sources
|
||||
const generateActivityItems = (): ActivityItem[] => {
|
||||
const activities: ActivityItem[] = [];
|
||||
|
||||
// Add stock movements
|
||||
if (stockMovements) {
|
||||
stockMovements.slice(0, 3).forEach((movement) => {
|
||||
const product = products?.find(p => p.id === movement.productId);
|
||||
if (product) {
|
||||
activities.push({
|
||||
id: `stock-${movement.id}`,
|
||||
type: "stock_movement",
|
||||
title: movement.type === "in" ? "Stock Added" : movement.type === "out" ? "Stock Removed" : "Stock Adjusted",
|
||||
description: `${movement.quantity} units of ${product.name} (SKU: ${product.sku})`,
|
||||
timestamp: movement.createdAt || new Date(),
|
||||
icon: movement.type === "in" ? Plus : Package,
|
||||
iconBg: movement.type === "in" ? "bg-secondary" : "bg-primary",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add document creation activities
|
||||
if (documents) {
|
||||
documents.slice(0, 2).forEach((document) => {
|
||||
activities.push({
|
||||
id: `doc-${document.id}`,
|
||||
type: "document_created",
|
||||
title: "Document Created",
|
||||
description: `${document.title} (${document.documentNumber})`,
|
||||
timestamp: document.createdAt || new Date(),
|
||||
icon: document.type === "delivery_note" ? Truck : FileText,
|
||||
iconBg: "bg-primary",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add stock alerts
|
||||
if (products) {
|
||||
const lowStockProducts = products.filter(p => p.currentStock > 0 && p.currentStock <= p.minThreshold);
|
||||
const outOfStockProducts = products.filter(p => p.currentStock === 0);
|
||||
|
||||
if (lowStockProducts.length > 0) {
|
||||
activities.push({
|
||||
id: "low-stock-alert",
|
||||
type: "low_stock",
|
||||
title: "Low Stock Alert",
|
||||
description: `${lowStockProducts[0].name} (SKU: ${lowStockProducts[0].sku}) below minimum threshold`,
|
||||
timestamp: new Date(Date.now() - 3600000), // 1 hour ago
|
||||
icon: AlertTriangle,
|
||||
iconBg: "bg-warning",
|
||||
});
|
||||
}
|
||||
|
||||
if (outOfStockProducts.length > 0) {
|
||||
activities.push({
|
||||
id: "out-of-stock-alert",
|
||||
type: "out_of_stock",
|
||||
title: "Out of Stock Alert",
|
||||
description: `${outOfStockProducts[0].name} (SKU: ${outOfStockProducts[0].sku}) is out of stock`,
|
||||
timestamp: new Date(Date.now() - 7200000), // 2 hours ago
|
||||
icon: AlertTriangle,
|
||||
iconBg: "bg-error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first) and limit to 5 items
|
||||
return activities
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
.slice(0, 5);
|
||||
};
|
||||
|
||||
const activities = generateActivityItems();
|
||||
|
||||
const formatTimeAgo = (timestamp: Date) => {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - timestamp.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold text-gray-800">
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activities.length > 0 ? (
|
||||
<div className="space-y-4" data-testid="recent-activity-list">
|
||||
{activities.map((activity) => {
|
||||
const Icon = activity.icon;
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-center space-x-4 p-4 bg-gray-50 rounded-lg"
|
||||
data-testid={`activity-item-${activity.id}`}
|
||||
>
|
||||
<div className={`w-10 h-10 ${activity.iconBg} text-white rounded-full flex items-center justify-center`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-800" data-testid={`activity-title-${activity.id}`}>
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600" data-testid={`activity-description-${activity.id}`}>
|
||||
{activity.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500" data-testid={`activity-time-${activity.id}`}>
|
||||
{formatTimeAgo(activity.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500" data-testid="no-recent-activity">
|
||||
No recent activity to display
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
367
client/src/components/documents/create-document-dialog.tsx
Normal file
367
client/src/components/documents/create-document-dialog.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { insertDocumentSchema } from "@shared/schema";
|
||||
import type { Product } from "@shared/schema";
|
||||
import { Truck, List, Tag, ClipboardCheck, BarChart3, Zap, Plus, Minus } from "lucide-react";
|
||||
|
||||
const documentFormSchema = insertDocumentSchema.extend({
|
||||
customerName: z.string().optional(),
|
||||
customerAddress: z.string().optional(),
|
||||
items: z.array(z.object({
|
||||
productId: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
notes: z.string().optional(),
|
||||
})).optional(),
|
||||
});
|
||||
|
||||
interface CreateDocumentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
templateType?: string | null;
|
||||
}
|
||||
|
||||
const templateIcons = {
|
||||
delivery_note: Truck,
|
||||
packing_list: List,
|
||||
shipping_label: Tag,
|
||||
goods_receipt: ClipboardCheck,
|
||||
stock_report: BarChart3,
|
||||
dispatch_note: Zap,
|
||||
};
|
||||
|
||||
const templateTitles = {
|
||||
delivery_note: "Delivery Note",
|
||||
packing_list: "Packing List",
|
||||
shipping_label: "Shipping Label",
|
||||
goods_receipt: "Goods Receipt",
|
||||
stock_report: "Stock Report",
|
||||
dispatch_note: "Dispatch Note",
|
||||
};
|
||||
|
||||
export default function CreateDocumentDialog({ open, onOpenChange, templateType }: CreateDocumentDialogProps) {
|
||||
const [selectedItems, setSelectedItems] = useState<Array<{ productId: string; quantity: number; notes?: string }>>([]);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: products } = useQuery<Product[]>({
|
||||
queryKey: ["/api/products"],
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(documentFormSchema),
|
||||
defaultValues: {
|
||||
type: templateType || "delivery_note",
|
||||
documentNumber: "",
|
||||
title: "",
|
||||
content: {},
|
||||
status: "draft",
|
||||
customerName: "",
|
||||
customerAddress: "",
|
||||
items: [],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (templateType && open) {
|
||||
const docNumber = `${templateType.toUpperCase().replace('_', '-')}-${Date.now()}`;
|
||||
form.setValue("type", templateType);
|
||||
form.setValue("documentNumber", docNumber);
|
||||
form.setValue("title", templateTitles[templateType as keyof typeof templateTitles] || "New Document");
|
||||
setSelectedItems([]);
|
||||
}
|
||||
}, [templateType, open, form]);
|
||||
|
||||
const createDocumentMutation = useMutation({
|
||||
mutationFn: (data: z.infer<typeof documentFormSchema>) => {
|
||||
const documentData = {
|
||||
...data,
|
||||
content: {
|
||||
customerName: data.customerName,
|
||||
customerAddress: data.customerAddress,
|
||||
items: selectedItems.map(item => {
|
||||
const product = products?.find(p => p.id === item.productId);
|
||||
return {
|
||||
...item,
|
||||
productName: product?.name,
|
||||
productSku: product?.sku,
|
||||
unit: product?.unit,
|
||||
};
|
||||
}),
|
||||
createdAt: new Date().toISOString(),
|
||||
totalItems: selectedItems.reduce((sum, item) => sum + item.quantity, 0),
|
||||
},
|
||||
};
|
||||
return apiRequest("POST", "/api/documents", documentData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/documents"] });
|
||||
toast({
|
||||
title: "Document Created",
|
||||
description: "Document has been created successfully",
|
||||
});
|
||||
form.reset();
|
||||
setSelectedItems([]);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to create document",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const addItem = () => {
|
||||
setSelectedItems([...selectedItems, { productId: "", quantity: 1 }]);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setSelectedItems(selectedItems.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: string, value: any) => {
|
||||
const updatedItems = [...selectedItems];
|
||||
updatedItems[index] = { ...updatedItems[index], [field]: value };
|
||||
setSelectedItems(updatedItems);
|
||||
};
|
||||
|
||||
const onSubmit = (data: z.infer<typeof documentFormSchema>) => {
|
||||
createDocumentMutation.mutate(data);
|
||||
};
|
||||
|
||||
const Icon = templateType ? templateIcons[templateType as keyof typeof templateIcons] : Truck;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center space-x-2" data-testid="text-create-document-title">
|
||||
{Icon && <Icon className="w-5 h-5 text-primary" />}
|
||||
<span>Create {templateTitles[templateType as keyof typeof templateTitles] || "Document"}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Document Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} data-testid="input-document-number" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger data-testid="select-document-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="finalized">Finalized</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Document Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} data-testid="input-document-title" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(templateType === "delivery_note" || templateType === "packing_list" || templateType === "shipping_label") && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Customer Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} data-testid="input-customer-name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customerAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Customer Address</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} data-testid="input-customer-address" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold">Items</h3>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="button-add-item"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedItems.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-4" data-testid="text-no-items">
|
||||
No items added. Click "Add Item" to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{selectedItems.map((item, index) => (
|
||||
<div key={index} className="flex items-end space-x-3 p-3 border border-gray-200 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Product</label>
|
||||
<Select
|
||||
value={item.productId}
|
||||
onValueChange={(value) => updateItem(index, "productId", value)}
|
||||
>
|
||||
<SelectTrigger data-testid={`select-product-${index}`}>
|
||||
<SelectValue placeholder="Select product" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products?.map((product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name} ({product.sku})
|
||||
<Badge className="ml-2 bg-gray-100 text-gray-600">
|
||||
{product.currentStock} {product.unit}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Quantity</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(index, "quantity", parseInt(e.target.value) || 1)}
|
||||
min="1"
|
||||
data-testid={`input-quantity-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||
<Input
|
||||
value={item.notes || ""}
|
||||
onChange={(e) => updateItem(index, "notes", e.target.value)}
|
||||
placeholder="Optional notes"
|
||||
data-testid={`input-notes-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeItem(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
data-testid={`button-remove-item-${index}`}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="button-cancel-document"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createDocumentMutation.isPending}
|
||||
className="bg-primary hover:bg-primary-dark"
|
||||
data-testid="button-create-document"
|
||||
>
|
||||
Create Document
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
97
client/src/components/documents/document-templates.tsx
Normal file
97
client/src/components/documents/document-templates.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Truck, List, Tag, ClipboardCheck, BarChart3, Zap } from "lucide-react";
|
||||
import CreateDocumentDialog from "./create-document-dialog";
|
||||
|
||||
const templates = [
|
||||
{
|
||||
id: "delivery_note",
|
||||
title: "Delivery Note",
|
||||
description: "Create delivery documentation for outbound shipments",
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
id: "packing_list",
|
||||
title: "Packing List",
|
||||
description: "Generate detailed packing lists for shipments",
|
||||
icon: List,
|
||||
},
|
||||
{
|
||||
id: "shipping_label",
|
||||
title: "Shipping Label",
|
||||
description: "Print shipping labels with tracking information",
|
||||
icon: Tag,
|
||||
},
|
||||
{
|
||||
id: "goods_receipt",
|
||||
title: "Goods Receipt",
|
||||
description: "Document incoming inventory and deliveries",
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
{
|
||||
id: "stock_report",
|
||||
title: "Stock Report",
|
||||
description: "Generate comprehensive inventory reports",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
id: "dispatch_note",
|
||||
title: "Dispatch Note",
|
||||
description: "Create dispatch documentation for outbound goods",
|
||||
icon: Zap,
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocumentTemplates() {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const handleTemplateClick = (templateId: string) => {
|
||||
setSelectedTemplate(templateId);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold text-gray-800">
|
||||
Document Templates
|
||||
</CardTitle>
|
||||
<p className="text-gray-600">
|
||||
Generate transportation and inventory documents quickly using pre-configured templates
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{templates.map((template) => {
|
||||
const Icon = template.icon;
|
||||
return (
|
||||
<Button
|
||||
key={template.id}
|
||||
variant="outline"
|
||||
onClick={() => handleTemplateClick(template.id)}
|
||||
className="text-left p-4 h-auto hover:border-primary hover:bg-blue-50 transition-all duration-200"
|
||||
data-testid={`button-template-${template.id}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<Icon className="w-6 h-6 text-primary" />
|
||||
<h3 className="font-semibold text-gray-800">{template.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{template.description}</p>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateDocumentDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
templateType={selectedTemplate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
414
client/src/components/inventory/add-stock-dialog.tsx
Normal file
414
client/src/components/inventory/add-stock-dialog.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { insertProductSchema, insertStockMovementSchema } from "@shared/schema";
|
||||
import type { Product } from "@shared/schema";
|
||||
|
||||
const newProductSchema = insertProductSchema.extend({
|
||||
currentStock: z.number().min(0),
|
||||
});
|
||||
|
||||
const stockMovementSchema = insertStockMovementSchema.extend({
|
||||
quantity: z.number().min(1),
|
||||
});
|
||||
|
||||
interface AddStockDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedProduct?: Product | null;
|
||||
}
|
||||
|
||||
export default function AddStockDialog({ open, onOpenChange, selectedProduct }: AddStockDialogProps) {
|
||||
const [mode, setMode] = useState<"existing" | "new">("existing");
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: products } = useQuery<Product[]>({
|
||||
queryKey: ["/api/products"],
|
||||
});
|
||||
|
||||
const newProductForm = useForm({
|
||||
resolver: zodResolver(newProductSchema),
|
||||
defaultValues: {
|
||||
sku: "",
|
||||
name: "",
|
||||
description: "",
|
||||
currentStock: 0,
|
||||
minThreshold: 0,
|
||||
unit: "units",
|
||||
price: "0.00",
|
||||
},
|
||||
});
|
||||
|
||||
const stockMovementForm = useForm({
|
||||
resolver: zodResolver(stockMovementSchema),
|
||||
defaultValues: {
|
||||
productId: "",
|
||||
type: "in" as const,
|
||||
quantity: 1,
|
||||
reason: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProduct) {
|
||||
setMode("existing");
|
||||
stockMovementForm.setValue("productId", selectedProduct.id);
|
||||
} else {
|
||||
setMode("new");
|
||||
}
|
||||
}, [selectedProduct, stockMovementForm]);
|
||||
|
||||
const createProductMutation = useMutation({
|
||||
mutationFn: (data: z.infer<typeof newProductSchema>) =>
|
||||
apiRequest("POST", "/api/products", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dashboard/stats"] });
|
||||
toast({
|
||||
title: "Product Created",
|
||||
description: "New product added successfully",
|
||||
});
|
||||
newProductForm.reset();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to create product",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const stockMovementMutation = useMutation({
|
||||
mutationFn: (data: z.infer<typeof stockMovementSchema>) =>
|
||||
apiRequest("POST", "/api/stock-movements", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dashboard/stats"] });
|
||||
toast({
|
||||
title: "Stock Updated",
|
||||
description: "Stock movement recorded successfully",
|
||||
});
|
||||
stockMovementForm.reset();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update stock",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmitNewProduct = (data: z.infer<typeof newProductSchema>) => {
|
||||
createProductMutation.mutate(data);
|
||||
};
|
||||
|
||||
const onSubmitStockMovement = (data: z.infer<typeof stockMovementSchema>) => {
|
||||
stockMovementMutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle data-testid="text-dialog-title">
|
||||
{selectedProduct ? `Add Stock - ${selectedProduct.name}` : "Add Product or Stock"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!selectedProduct && (
|
||||
<div className="flex space-x-2 mb-4">
|
||||
<Button
|
||||
variant={mode === "existing" ? "default" : "outline"}
|
||||
onClick={() => setMode("existing")}
|
||||
data-testid="button-mode-existing"
|
||||
>
|
||||
Add to Existing Product
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "new" ? "default" : "outline"}
|
||||
onClick={() => setMode("new")}
|
||||
data-testid="button-mode-new"
|
||||
>
|
||||
Create New Product
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "new" && (
|
||||
<Form {...newProductForm}>
|
||||
<form onSubmit={newProductForm.handleSubmit(onSubmitNewProduct)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={newProductForm.control}
|
||||
name="sku"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SKU</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} data-testid="input-sku" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={newProductForm.control}
|
||||
name="unit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Unit</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="units, pieces, kg, etc." data-testid="input-unit" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={newProductForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Product Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} data-testid="input-product-name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={newProductForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} data-testid="input-description" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={newProductForm.control}
|
||||
name="currentStock"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Initial Stock</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
data-testid="input-initial-stock"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={newProductForm.control}
|
||||
name="minThreshold"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Min Threshold</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
data-testid="input-min-threshold"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={newProductForm.control}
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Price</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="0.00" data-testid="input-price" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="button-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createProductMutation.isPending}
|
||||
data-testid="button-create-product"
|
||||
>
|
||||
Create Product
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{mode === "existing" && (
|
||||
<Form {...stockMovementForm}>
|
||||
<form onSubmit={stockMovementForm.handleSubmit(onSubmitStockMovement)} className="space-y-4">
|
||||
{!selectedProduct && (
|
||||
<FormField
|
||||
control={stockMovementForm.control}
|
||||
name="productId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Product</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger data-testid="select-product">
|
||||
<SelectValue placeholder="Select a product" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{products?.map((product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name} ({product.sku})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={stockMovementForm.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Movement Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger data-testid="select-movement-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="in">Stock In</SelectItem>
|
||||
<SelectItem value="out">Stock Out</SelectItem>
|
||||
<SelectItem value="adjustment">Adjustment</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={stockMovementForm.control}
|
||||
name="quantity"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Quantity</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
|
||||
data-testid="input-movement-quantity"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={stockMovementForm.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} placeholder="Enter reason for stock movement" data-testid="input-reason" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="button-cancel-movement"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={stockMovementMutation.isPending}
|
||||
data-testid="button-submit-movement"
|
||||
>
|
||||
Update Stock
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
206
client/src/components/inventory/inventory-table.tsx
Normal file
206
client/src/components/inventory/inventory-table.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Edit, Plus, History, Download } from "lucide-react";
|
||||
import AddStockDialog from "@/components/inventory/add-stock-dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { Product } from "@shared/schema";
|
||||
|
||||
interface InventoryTableProps {
|
||||
searchResults?: Product[];
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
export default function InventoryTable({ searchResults, showAll = false }: InventoryTableProps) {
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [isAddStockDialogOpen, setIsAddStockDialogOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: allProducts, isLoading } = useQuery<Product[]>({
|
||||
queryKey: ["/api/products"],
|
||||
});
|
||||
|
||||
const products = searchResults || (showAll ? allProducts : allProducts?.slice(0, 5)) || [];
|
||||
|
||||
const getStockStatus = (product: Product) => {
|
||||
if (product.currentStock === 0) {
|
||||
return { label: "Out of Stock", className: "bg-red-100 text-red-800" };
|
||||
} else if (product.currentStock <= product.minThreshold) {
|
||||
return { label: "Low Stock", className: "bg-orange-100 text-orange-800" };
|
||||
} else {
|
||||
return { label: "In Stock", className: "bg-green-100 text-green-800" };
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddStock = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
setIsAddStockDialogOpen(true);
|
||||
};
|
||||
|
||||
const exportInventory = () => {
|
||||
const csvContent = [
|
||||
["SKU", "Product Name", "Current Stock", "Min Threshold", "Unit", "Status"].join(","),
|
||||
...products.map(product => [
|
||||
product.sku,
|
||||
`"${product.name}"`,
|
||||
product.currentStock,
|
||||
product.minThreshold,
|
||||
product.unit,
|
||||
getStockStatus(product).label
|
||||
].join(","))
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "inventory-export.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl font-bold text-gray-800">
|
||||
{showAll ? "All Inventory" : "Current Inventory"}
|
||||
</CardTitle>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={exportInventory}
|
||||
className="bg-secondary text-white hover:bg-green-600"
|
||||
data-testid="button-export-inventory"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left p-4 font-semibold text-gray-700">SKU</th>
|
||||
<th className="text-left p-4 font-semibold text-gray-700">Product Name</th>
|
||||
<th className="text-left p-4 font-semibold text-gray-700">Current Stock</th>
|
||||
<th className="text-left p-4 font-semibold text-gray-700">Min Threshold</th>
|
||||
<th className="text-left p-4 font-semibold text-gray-700">Status</th>
|
||||
<th className="text-left p-4 font-semibold text-gray-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.length > 0 ? (
|
||||
products.map((product) => {
|
||||
const status = getStockStatus(product);
|
||||
return (
|
||||
<tr
|
||||
key={product.id}
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
data-testid={`row-product-${product.id}`}
|
||||
>
|
||||
<td className="p-4 font-mono text-sm" data-testid={`text-sku-${product.id}`}>
|
||||
{product.sku}
|
||||
</td>
|
||||
<td className="p-4 font-medium" data-testid={`text-name-${product.id}`}>
|
||||
{product.name}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-lg font-semibold" data-testid={`text-stock-${product.id}`}>
|
||||
{product.currentStock}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 ml-1">{product.unit}</span>
|
||||
</td>
|
||||
<td className="p-4 text-gray-600">{product.minThreshold}</td>
|
||||
<td className="p-4">
|
||||
<Badge className={status.className} data-testid={`badge-status-${product.id}`}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="p-2 text-blue-600 hover:bg-blue-50"
|
||||
data-testid={`button-edit-${product.id}`}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleAddStock(product)}
|
||||
className="p-2 text-green-600 hover:bg-green-50"
|
||||
data-testid={`button-add-stock-${product.id}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="p-2 text-orange-600 hover:bg-orange-50"
|
||||
data-testid={`button-history-${product.id}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-8 text-center text-gray-500" data-testid="text-no-products">
|
||||
No products found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!showAll && products.length > 0 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600" data-testid="text-showing-count">
|
||||
Showing {products.length} of {allProducts?.length || 0} items
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AddStockDialog
|
||||
open={isAddStockDialogOpen}
|
||||
onOpenChange={setIsAddStockDialogOpen}
|
||||
selectedProduct={selectedProduct}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
127
client/src/components/inventory/stock-overview.tsx
Normal file
127
client/src/components/inventory/stock-overview.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Package, CheckCircle, AlertTriangle, XCircle, Search, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface StockOverviewProps {
|
||||
stats?: {
|
||||
totalItems: number;
|
||||
inStockItems: number;
|
||||
lowStockItems: number;
|
||||
outOfStockItems: number;
|
||||
};
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function StockOverview({ stats, isLoading }: StockOverviewProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dashboard/stats"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/products"] });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-20 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800" data-testid="text-stock-overview-title">
|
||||
Stock Overview
|
||||
</h2>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
className="bg-primary text-white hover:bg-primary-dark"
|
||||
data-testid="button-refresh-stock"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 tablet-grid">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-blue-600 text-sm font-medium uppercase tracking-wide">Total Items</p>
|
||||
<p className="text-2xl font-bold text-blue-800" data-testid="text-total-items">
|
||||
{stats?.totalItems || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Package className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-lg border border-green-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-green-600 text-sm font-medium uppercase tracking-wide">In Stock</p>
|
||||
<p className="text-2xl font-bold text-green-800" data-testid="text-in-stock">
|
||||
{stats?.inStockItems || 0}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 p-4 rounded-lg border border-orange-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-orange-600 text-sm font-medium uppercase tracking-wide">Low Stock</p>
|
||||
<p className="text-2xl font-bold text-orange-800" data-testid="text-low-stock">
|
||||
{stats?.lowStockItems || 0}
|
||||
</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 text-orange-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 p-4 rounded-lg border border-red-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-red-600 text-sm font-medium uppercase tracking-wide">Out of Stock</p>
|
||||
<p className="text-2xl font-bold text-red-800" data-testid="text-out-of-stock">
|
||||
{stats?.outOfStockItems || 0}
|
||||
</p>
|
||||
</div>
|
||||
<XCircle className="w-8 h-8 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search products by name, SKU, or barcode..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 text-lg border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
data-testid="input-search-products"
|
||||
/>
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
55
client/src/components/layout/bottom-navigation.tsx
Normal file
55
client/src/components/layout/bottom-navigation.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useLocation } from "wouter";
|
||||
import { Home, Package, QrCode, FileText, BarChart3 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", icon: Home, label: "Home" },
|
||||
{ path: "/inventory", icon: Package, label: "Inventory" },
|
||||
{ path: "/scanner", icon: QrCode, label: "Scan" },
|
||||
{ path: "/documents", icon: FileText, label: "Documents" },
|
||||
{ path: "/reports", icon: BarChart3, label: "Reports" },
|
||||
];
|
||||
|
||||
export default function BottomNavigation() {
|
||||
const [location, setLocation] = useLocation();
|
||||
|
||||
return (
|
||||
<nav className="no-print fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg md:hidden lg:flex xl:hidden safe-area safe-area-bottom">
|
||||
<div className="flex justify-around py-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.path;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => setLocation(item.path)}
|
||||
className={cn(
|
||||
"flex flex-col items-center p-3 transition-all duration-200 touch-target no-select rounded-lg mx-1",
|
||||
"active:scale-95 active:bg-blue-50",
|
||||
isActive
|
||||
? "text-primary bg-blue-50"
|
||||
: "text-gray-500 hover:text-primary hover:bg-gray-50"
|
||||
)}
|
||||
data-testid={`nav-${item.label.toLowerCase()}`}
|
||||
style={{
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
minWidth: '60px',
|
||||
minHeight: '56px'
|
||||
}}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"w-6 h-6 mb-1 transition-transform",
|
||||
isActive ? "scale-110" : ""
|
||||
)} />
|
||||
<span className={cn(
|
||||
"text-xs font-medium transition-all",
|
||||
isActive ? "font-semibold" : ""
|
||||
)}>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
29
client/src/components/layout/floating-action-button.tsx
Normal file
29
client/src/components/layout/floating-action-button.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import AddStockDialog from "@/components/inventory/add-stock-dialog";
|
||||
|
||||
export default function FloatingActionButton() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
className="no-print fixed bottom-20 right-4 w-14 h-14 bg-primary text-white rounded-full shadow-lg hover:bg-primary-dark transition-all duration-200 md:hidden active:scale-95 touch-target"
|
||||
style={{
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
marginBottom: 'env(safe-area-inset-bottom)'
|
||||
}}
|
||||
data-testid="fab-quick-add"
|
||||
>
|
||||
<Plus className="w-6 h-6 no-select" />
|
||||
</Button>
|
||||
|
||||
<AddStockDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
client/src/components/layout/header.tsx
Normal file
89
client/src/components/layout/header.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Bell, User, Warehouse } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export default function Header() {
|
||||
const { data: notificationCount } = useQuery<{ count: number }>({
|
||||
queryKey: ["/api/notifications/unread-count"],
|
||||
});
|
||||
|
||||
const unreadCount = notificationCount?.count || 0;
|
||||
|
||||
return (
|
||||
<header className="bg-primary text-white shadow-lg sticky top-0 z-50 no-print safe-area-top">
|
||||
<div className="container mx-auto px-4 py-3 safe-area">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Warehouse className="w-8 h-8 no-select" data-testid="icon-warehouse" />
|
||||
<h1 className="text-xl font-bold no-select" data-testid="text-app-title">
|
||||
WarehouseTrack Pro
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative p-2 hover:bg-primary-dark text-white"
|
||||
data-testid="button-notifications"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
className="absolute -top-1 -right-1 bg-error text-white text-xs min-w-[20px] h-5 flex items-center justify-center"
|
||||
data-testid="badge-notification-count"
|
||||
>
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuItem>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Low Stock Alert</p>
|
||||
<p className="text-gray-600">5 items need attention</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">New Order</p>
|
||||
<p className="text-gray-600">Order #1234 received</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-2 hover:bg-primary-dark text-white"
|
||||
data-testid="button-user-menu"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem>Logout</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
62
client/src/components/layout/tablet-navigation.tsx
Normal file
62
client/src/components/layout/tablet-navigation.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
Home,
|
||||
Package,
|
||||
FileText,
|
||||
QrCode,
|
||||
BarChart3,
|
||||
Warehouse
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", icon: Home, label: "Dashboard" },
|
||||
{ path: "/inventory", icon: Package, label: "Inventory" },
|
||||
{ path: "/scanner", icon: QrCode, label: "Scanner" },
|
||||
{ path: "/documents", icon: FileText, label: "Documents" },
|
||||
{ path: "/reports", icon: BarChart3, label: "Reports" },
|
||||
];
|
||||
|
||||
export default function TabletNavigation() {
|
||||
const [location, setLocation] = useLocation();
|
||||
|
||||
return (
|
||||
<nav className="hidden md:flex lg:hidden flex-col w-64 bg-white border-r border-gray-200 shadow-sm h-screen sticky top-0 safe-area-top">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-8">
|
||||
<Warehouse className="w-8 h-8 text-primary" />
|
||||
<h1 className="text-lg font-bold text-gray-900">WarehouseTrack Pro</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.path;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.path}
|
||||
onClick={() => setLocation(item.path)}
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start h-12 text-left transition-all duration-200",
|
||||
isActive
|
||||
? "bg-primary text-white shadow-md"
|
||||
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
|
||||
)}
|
||||
data-testid={`tablet-nav-${item.label.toLowerCase()}`}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"w-5 h-5 mr-3",
|
||||
isActive ? "text-white" : "text-gray-500"
|
||||
)} />
|
||||
{item.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
98
client/src/components/pwa/install-prompt.tsx
Normal file
98
client/src/components/pwa/install-prompt.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Download, X, Smartphone } from 'lucide-react';
|
||||
import { usePWAInstall } from '@/hooks/use-mobile';
|
||||
|
||||
interface InstallPromptProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InstallPrompt({ className }: InstallPromptProps) {
|
||||
const { isInstallable, installApp } = usePWAInstall();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
// Check if user has already dismissed the prompt
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem('pwa-install-dismissed');
|
||||
if (dismissed) {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Don't show if not installable, already dismissed, or already installed
|
||||
if (!isInstallable || isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleInstall = async () => {
|
||||
setIsInstalling(true);
|
||||
try {
|
||||
const success = await installApp();
|
||||
if (success) {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Installation failed:', error);
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true);
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`fixed bottom-24 left-4 right-4 z-50 p-4 bg-white shadow-lg border-2 border-primary/20 md:hidden ${className}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex-shrink-0">
|
||||
<Smartphone className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
Install WarehouseTrack Pro
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Get the full app experience with offline access and faster loading.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 ml-2"
|
||||
data-testid="button-dismiss-install"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex space-x-2">
|
||||
<Button
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling}
|
||||
className="flex-1"
|
||||
data-testid="button-install-app"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isInstalling ? 'Installing...' : 'Install App'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDismiss}
|
||||
className="px-4"
|
||||
data-testid="button-not-now"
|
||||
>
|
||||
Not Now
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstallPrompt;
|
||||
265
client/src/components/scanner/scanner-modal.tsx
Normal file
265
client/src/components/scanner/scanner-modal.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Camera, Keyboard, Search, Package, Plus, Minus, X } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { Product } from "@shared/schema";
|
||||
|
||||
interface ScannerModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ScannerModal({ open, onOpenChange }: ScannerModalProps) {
|
||||
const [scanInput, setScanInput] = useState("");
|
||||
const [scannedProduct, setScannedProduct] = useState<Product | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [isManualEntry, setIsManualEntry] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: products } = useQuery<Product[]>({
|
||||
queryKey: ["/api/products"],
|
||||
});
|
||||
|
||||
const stockMovementMutation = useMutation({
|
||||
mutationFn: (data: { productId: string; type: string; quantity: number; reason: string }) =>
|
||||
apiRequest("POST", "/api/stock-movements", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dashboard/stats"] });
|
||||
toast({
|
||||
title: "Stock Updated",
|
||||
description: "Stock movement recorded successfully",
|
||||
});
|
||||
setScannedProduct(null);
|
||||
setScanInput("");
|
||||
setQuantity(1);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update stock",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleScan = () => {
|
||||
if (!scanInput.trim()) return;
|
||||
|
||||
const product = products?.find(p =>
|
||||
p.sku.toLowerCase() === scanInput.toLowerCase() ||
|
||||
p.name.toLowerCase().includes(scanInput.toLowerCase())
|
||||
);
|
||||
|
||||
if (product) {
|
||||
setScannedProduct(product);
|
||||
toast({
|
||||
title: "Product Found",
|
||||
description: `Scanned: ${product.name}`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Product Not Found",
|
||||
description: "No product found with that SKU or name",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStockMovement = (type: 'in' | 'out') => {
|
||||
if (!scannedProduct) return;
|
||||
|
||||
stockMovementMutation.mutate({
|
||||
productId: scannedProduct.id,
|
||||
type,
|
||||
quantity,
|
||||
reason: type === 'in' ? 'Stock added via scanner' : 'Stock removed via scanner',
|
||||
});
|
||||
};
|
||||
|
||||
const getStockStatus = (product: Product) => {
|
||||
if (product.currentStock === 0) {
|
||||
return { label: "Out of Stock", className: "bg-red-100 text-red-800" };
|
||||
} else if (product.currentStock <= product.minThreshold) {
|
||||
return { label: "Low Stock", className: "bg-orange-100 text-orange-800" };
|
||||
} else {
|
||||
return { label: "In Stock", className: "bg-green-100 text-green-800" };
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setScannedProduct(null);
|
||||
setScanInput("");
|
||||
setQuantity(1);
|
||||
setIsManualEntry(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center space-x-2" data-testid="scanner-modal-title">
|
||||
{isManualEntry ? (
|
||||
<Keyboard className="w-5 h-5" />
|
||||
) : (
|
||||
<Camera className="w-5 h-5" />
|
||||
)}
|
||||
<span>{isManualEntry ? "Manual Entry" : "Scanner"}</span>
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
data-testid="button-close-scanner"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!isManualEntry ? (
|
||||
<div className="bg-gray-100 rounded-lg p-8 text-center">
|
||||
<Camera className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-2">Position barcode within frame</p>
|
||||
<p className="text-sm text-gray-500 mb-4">Camera will activate automatically</p>
|
||||
<Button
|
||||
onClick={() => setIsManualEntry(true)}
|
||||
variant="outline"
|
||||
data-testid="button-switch-manual"
|
||||
>
|
||||
<Keyboard className="w-4 h-4 mr-2" />
|
||||
Switch to Manual Entry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter SKU or product name..."
|
||||
value={scanInput}
|
||||
onChange={(e) => setScanInput(e.target.value)}
|
||||
className="pl-10"
|
||||
data-testid="input-manual-scan"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleScan()}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleScan}
|
||||
className="bg-primary hover:bg-primary-dark"
|
||||
data-testid="button-scan-manual"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsManualEntry(false)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
data-testid="button-switch-camera"
|
||||
>
|
||||
<Camera className="w-4 h-4 mr-2" />
|
||||
Switch to Camera Scanner
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scannedProduct && (
|
||||
<div className="border border-gray-200 rounded-lg p-4 bg-blue-50">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Package className="w-6 h-6 text-primary" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg" data-testid="scanned-product-name">
|
||||
{scannedProduct.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600" data-testid="scanned-product-sku">
|
||||
SKU: {scannedProduct.sku}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<span className="text-lg font-semibold" data-testid="scanned-product-stock">
|
||||
{scannedProduct.currentStock} {scannedProduct.unit}
|
||||
</span>
|
||||
<Badge className={getStockStatus(scannedProduct).className}>
|
||||
{getStockStatus(scannedProduct).label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Quantity
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
data-testid="button-decrease-quantity"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-20 text-center"
|
||||
data-testid="input-scanner-quantity"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
data-testid="button-increase-quantity"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={() => handleStockMovement('in')}
|
||||
disabled={stockMovementMutation.isPending}
|
||||
className="flex-1 bg-secondary hover:bg-green-600"
|
||||
data-testid="button-add-stock-scanner"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Stock
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleStockMovement('out')}
|
||||
disabled={stockMovementMutation.isPending || scannedProduct.currentStock < quantity}
|
||||
variant="outline"
|
||||
className="flex-1 text-red-600 border-red-300 hover:bg-red-50"
|
||||
data-testid="button-remove-stock-scanner"
|
||||
>
|
||||
<Minus className="w-4 h-4 mr-2" />
|
||||
Remove Stock
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
56
client/src/components/ui/accordion.tsx
Normal file
56
client/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
139
client/src/components/ui/alert-dialog.tsx
Normal file
139
client/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
59
client/src/components/ui/alert.tsx
Normal file
59
client/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
5
client/src/components/ui/aspect-ratio.tsx
Normal file
5
client/src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
50
client/src/components/ui/avatar.tsx
Normal file
50
client/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
36
client/src/components/ui/badge.tsx
Normal file
36
client/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-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 }
|
||||
115
client/src/components/ui/breadcrumb.tsx
Normal file
115
client/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbList.displayName = "BreadcrumbList"
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
56
client/src/components/ui/button.tsx
Normal file
56
client/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
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 }
|
||||
68
client/src/components/ui/calendar.tsx
Normal file
68
client/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
|
||||
),
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
79
client/src/components/ui/card.tsx
Normal file
79
client/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
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.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl 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, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
260
client/src/components/ui/carousel.tsx
Normal file
260
client/src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Carousel.displayName = "Carousel"
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
365
client/src/components/ui/chart.tsx
Normal file
365
client/src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
28
client/src/components/ui/checkbox.tsx
Normal file
28
client/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
11
client/src/components/ui/collapsible.tsx
Normal file
11
client/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
151
client/src/components/ui/command.tsx
Normal file
151
client/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
198
client/src/components/ui/context-menu.tsx
Normal file
198
client/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
))
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
))
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-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">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
))
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
122
client/src/components/ui/dialog.tsx
Normal file
122
client/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
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/80 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-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<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-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
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,
|
||||
}
|
||||
118
client/src/components/ui/drawer.tsx
Normal file
118
client/src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
198
client/src/components/ui/dropdown-menu.tsx
Normal file
198
client/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-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">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
178
client/src/components/ui/form.tsx
Normal file
178
client/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
29
client/src/components/ui/hover-card.tsx
Normal file
29
client/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
69
client/src/components/ui/input-otp.tsx
Normal file
69
client/src/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { Dot } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
InputOTP.displayName = "InputOTP"
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
))
|
||||
InputOTPGroup.displayName = "InputOTPGroup"
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
InputOTPSlot.displayName = "InputOTPSlot"
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Dot />
|
||||
</div>
|
||||
))
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
22
client/src/components/ui/input.tsx
Normal file
22
client/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
client/src/components/ui/label.tsx
Normal file
24
client/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
256
client/src/components/ui/menubar.tsx
Normal file
256
client/src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return <MenubarPrimitive.RadioGroup {...props} />
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
))
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
)
|
||||
)
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
))
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-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">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
))
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
MenubarShortcut.displayname = "MenubarShortcut"
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
}
|
||||
128
client/src/components/ui/navigation-menu.tsx
Normal file
128
client/src/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
))
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
|
||||
)
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
))
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
))
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
}
|
||||
117
client/src/components/ui/pagination.tsx
Normal file
117
client/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
29
client/src/components/ui/popover.tsx
Normal file
29
client/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
28
client/src/components/ui/progress.tsx
Normal file
28
client/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
42
client/src/components/ui/radio-group.tsx
Normal file
42
client/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
45
client/src/components/ui/resizable.tsx
Normal file
45
client/src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
46
client/src/components/ui/scroll-area.tsx
Normal file
46
client/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
160
client/src/components/ui/select.tsx
Normal file
160
client/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } 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-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 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 SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.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-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<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>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.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-accent focus:text-accent-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
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
29
client/src/components/ui/separator.tsx
Normal file
29
client/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
140
client/src/components/ui/sheet.tsx
Normal file
140
client/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
771
client/src/components/ui/sidebar.tsx
Normal file
771
client/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,771 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { VariantProps, cva } from "class-variance-authority"
|
||||
import { PanelLeft } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((open) => !open)
|
||||
: setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
SidebarProvider.displayName = "SidebarProvider"
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Sidebar.displayName = "Sidebar"
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
SidebarTrigger.displayName = "SidebarTrigger"
|
||||
|
||||
const SidebarRail = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarRail.displayName = "SidebarRail"
|
||||
|
||||
const SidebarInset = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"main">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarInset.displayName = "SidebarInset"
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarInput.displayName = "SidebarInput"
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarHeader.displayName = "SidebarHeader"
|
||||
|
||||
const SidebarFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarFooter.displayName = "SidebarFooter"
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarSeparator.displayName = "SidebarSeparator"
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarContent.displayName = "SidebarContent"
|
||||
|
||||
const SidebarGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroup.displayName = "SidebarGroup"
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction"
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent"
|
||||
|
||||
const SidebarMenu = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenu.displayName = "SidebarMenu"
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
)
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton"
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction"
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-[--skeleton-width] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub"
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
15
client/src/components/ui/skeleton.tsx
Normal file
15
client/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
26
client/src/components/ui/slider.tsx
Normal file
26
client/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
27
client/src/components/ui/switch.tsx
Normal file
27
client/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors 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 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
117
client/src/components/ui/table.tsx
Normal file
117
client/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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", 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 TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 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-12 px-4 text-left align-middle font-medium 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("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
53
client/src/components/ui/tabs.tsx
Normal file
53
client/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
22
client/src/components/ui/textarea.tsx
Normal file
22
client/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
127
client/src/components/ui/toast.tsx
Normal file
127
client/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
33
client/src/components/ui/toaster.tsx
Normal file
33
client/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
61
client/src/components/ui/toggle-group.tsx
Normal file
61
client/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
43
client/src/components/ui/toggle.tsx
Normal file
43
client/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3 min-w-10",
|
||||
sm: "h-9 px-2.5 min-w-9",
|
||||
lg: "h-11 px-5 min-w-11",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
30
client/src/components/ui/tooltip.tsx
Normal file
30
client/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
148
client/src/hooks/use-mobile.tsx
Normal file
148
client/src/hooks/use-mobile.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
|
||||
interface MobileDetection {
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
isDesktop: boolean;
|
||||
isAndroid: boolean;
|
||||
isIOS: boolean;
|
||||
isStandalone: boolean;
|
||||
hasNotch: boolean;
|
||||
orientation: 'portrait' | 'landscape';
|
||||
}
|
||||
|
||||
export function useMobile(): MobileDetection {
|
||||
const [detection, setDetection] = React.useState<MobileDetection>({
|
||||
isMobile: false,
|
||||
isTablet: false,
|
||||
isDesktop: false,
|
||||
isAndroid: false,
|
||||
isIOS: false,
|
||||
isStandalone: false,
|
||||
hasNotch: false,
|
||||
orientation: 'portrait',
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const detectDevice = () => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
// Device type detection
|
||||
const isMobile = width <= 768;
|
||||
const isTablet = width > 768 && width <= 1024;
|
||||
const isDesktop = width > 1024;
|
||||
|
||||
// Platform detection
|
||||
const isAndroid = /android/.test(userAgent);
|
||||
const isIOS = /iphone|ipad|ipod/.test(userAgent);
|
||||
|
||||
// PWA detection
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true ||
|
||||
document.referrer.includes('android-app://');
|
||||
|
||||
// Notch detection (approximate)
|
||||
const hasNotch = window.screen.height >= 812 &&
|
||||
(isIOS || (isAndroid && width >= 360));
|
||||
|
||||
// Orientation detection
|
||||
const orientation = height > width ? 'portrait' : 'landscape';
|
||||
|
||||
setDetection({
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
isStandalone,
|
||||
hasNotch,
|
||||
orientation,
|
||||
});
|
||||
};
|
||||
|
||||
detectDevice();
|
||||
|
||||
// Listen for resize and orientation changes
|
||||
const handleResize = () => detectDevice();
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('orientationchange', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('orientationchange', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return detection;
|
||||
}
|
||||
|
||||
// Utility hook for PWA installation
|
||||
export function usePWAInstall() {
|
||||
const [isInstallable, setIsInstallable] = React.useState(false);
|
||||
const [installPrompt, setInstallPrompt] = React.useState<any>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setInstallPrompt(e);
|
||||
setIsInstallable(true);
|
||||
};
|
||||
|
||||
const handleAppInstalled = () => {
|
||||
setIsInstallable(false);
|
||||
setInstallPrompt(null);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.addEventListener('appinstalled', handleAppInstalled);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.removeEventListener('appinstalled', handleAppInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const installApp = async () => {
|
||||
if (!installPrompt) return false;
|
||||
|
||||
try {
|
||||
const result = await installPrompt.prompt();
|
||||
const { outcome } = await result.userChoice;
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
setIsInstallable(false);
|
||||
setInstallPrompt(null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('PWA installation failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isInstallable,
|
||||
installApp,
|
||||
};
|
||||
}
|
||||
191
client/src/hooks/use-toast.ts
Normal file
191
client/src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
282
client/src/index.css
Normal file
282
client/src/index.css
Normal file
@@ -0,0 +1,282 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: hsl(0, 0%, 98%);
|
||||
--foreground: hsl(210, 25%, 7.8431%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(210, 25%, 7.8431%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-foreground: hsl(210, 25%, 7.8431%);
|
||||
--primary: hsl(207.8, 76.9%, 40.8%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--primary-dark: hsl(209.1, 77.8%, 36.9%);
|
||||
--secondary: hsl(120.5, 39.4%, 34.5%);
|
||||
--secondary-foreground: hsl(0, 0%, 100%);
|
||||
--muted: hsl(240, 1.9608%, 90%);
|
||||
--muted-foreground: hsl(210, 25%, 7.8431%);
|
||||
--accent: hsl(211.5789, 51.3514%, 92.7451%);
|
||||
--accent-foreground: hsl(203.8863, 88.2845%, 53.1373%);
|
||||
--destructive: hsl(356.3033, 90.5579%, 54.3137%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--warning: hsl(35.4, 91.7%, 48.0%);
|
||||
--warning-foreground: hsl(0, 0%, 100%);
|
||||
--error: hsl(354.3, 70.0%, 49.0%);
|
||||
--error-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(201.4286, 30.4348%, 90.9804%);
|
||||
--input: hsl(200, 23.0769%, 97.4510%);
|
||||
--ring: hsl(202.8169, 89.1213%, 53.1373%);
|
||||
--chart-1: hsl(203.8863, 88.2845%, 53.1373%);
|
||||
--chart-2: hsl(159.7826, 100%, 36.0784%);
|
||||
--chart-3: hsl(42.0290, 92.8251%, 56.2745%);
|
||||
--chart-4: hsl(147.1429, 78.5047%, 41.9608%);
|
||||
--chart-5: hsl(341.4894, 75.2000%, 50.9804%);
|
||||
--sidebar: hsl(180, 6.6667%, 97.0588%);
|
||||
--sidebar-foreground: hsl(210, 25%, 7.8431%);
|
||||
--sidebar-primary: hsl(203.8863, 88.2845%, 53.1373%);
|
||||
--sidebar-primary-foreground: hsl(0, 0%, 100%);
|
||||
--sidebar-accent: hsl(211.5789, 51.3514%, 92.7451%);
|
||||
--sidebar-accent-foreground: hsl(203.8863, 88.2845%, 53.1373%);
|
||||
--sidebar-border: hsl(205.0000, 25.0000%, 90.5882%);
|
||||
--sidebar-ring: hsl(202.8169, 89.1213%, 53.1373%);
|
||||
--font-sans: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
--radius: 0.75rem;
|
||||
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-xs: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-sm: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00), 0px 1px 2px -1px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00), 0px 1px 2px -1px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-md: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00), 0px 2px 4px -1px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-lg: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00), 0px 4px 6px -1px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-xl: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00), 0px 8px 10px -1px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--tracking-normal: 0em;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0, 0%, 6%);
|
||||
--foreground: hsl(200, 6.6667%, 91.1765%);
|
||||
--card: hsl(228, 9.8039%, 10%);
|
||||
--card-foreground: hsl(0, 0%, 85.0980%);
|
||||
--popover: hsl(0, 0%, 6%);
|
||||
--popover-foreground: hsl(200, 6.6667%, 91.1765%);
|
||||
--primary: hsl(207.8, 76.9%, 52%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--primary-dark: hsl(209.1, 77.8%, 48%);
|
||||
--secondary: hsl(120.5, 39.4%, 45%);
|
||||
--secondary-foreground: hsl(0, 0%, 100%);
|
||||
--muted: hsl(0, 0%, 9.4118%);
|
||||
--muted-foreground: hsl(210, 3.3898%, 46.2745%);
|
||||
--accent: hsl(205.7143, 70%, 7.8431%);
|
||||
--accent-foreground: hsl(203.7736, 87.6033%, 52.5490%);
|
||||
--destructive: hsl(356.3033, 90.5579%, 54.3137%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--warning: hsl(35.4, 91.7%, 58%);
|
||||
--warning-foreground: hsl(0, 0%, 100%);
|
||||
--error: hsl(354.3, 70.0%, 59%);
|
||||
--error-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(210, 5.2632%, 14.9020%);
|
||||
--input: hsl(207.6923, 27.6596%, 18.4314%);
|
||||
--ring: hsl(202.8169, 89.1213%, 53.1373%);
|
||||
--chart-1: hsl(203.8863, 88.2845%, 53.1373%);
|
||||
--chart-2: hsl(159.7826, 100%, 36.0784%);
|
||||
--chart-3: hsl(42.0290, 92.8251%, 56.2745%);
|
||||
--chart-4: hsl(147.1429, 78.5047%, 41.9608%);
|
||||
--chart-5: hsl(341.4894, 75.2000%, 50.9804%);
|
||||
--sidebar: hsl(228, 9.8039%, 10%);
|
||||
--sidebar-foreground: hsl(0, 0%, 85.0980%);
|
||||
--sidebar-primary: hsl(202.8169, 89.1213%, 53.1373%);
|
||||
--sidebar-primary-foreground: hsl(0, 0%, 100%);
|
||||
--sidebar-accent: hsl(205.7143, 70%, 7.8431%);
|
||||
--sidebar-accent-foreground: hsl(203.7736, 87.6033%, 52.5490%);
|
||||
--sidebar-border: hsl(205.7143, 15.7895%, 26.0784%);
|
||||
--sidebar-ring: hsl(202.8169, 89.1213%, 53.1373%);
|
||||
--font-sans: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
--radius: 0.75rem;
|
||||
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-xs: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-sm: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00), 0px 1px 2px -1px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00), 0px 1px 2px -1px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-md: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00), 0px 2px 4px -1px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-lg: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00), 0px 4px 6px -1px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-xl: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00), 0px 8px 10px -1px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169, 89.1213%, 53.1373%, 0.00);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-sans antialiased bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
.print-only {
|
||||
display: block !important;
|
||||
}
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Enhanced touch optimizations for mobile devices */
|
||||
@media (pointer: coarse) {
|
||||
button, [role="button"], input, select, textarea {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Larger touch targets for interactive elements */
|
||||
.touch-target {
|
||||
padding: 16px;
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
/* Prevent iOS bounce scrolling on inputs */
|
||||
input, textarea, select {
|
||||
border-radius: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS Safari specific fixes */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
/* Fix viewport height issues with iOS Safari */
|
||||
.min-h-screen {
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
/* Prevent zoom on input focus */
|
||||
input, select, textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Android Chrome specific optimizations */
|
||||
@media screen and (max-width: 768px) {
|
||||
/* Improve scrolling performance */
|
||||
* {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Better touch feedback */
|
||||
button, [role="button"] {
|
||||
-webkit-tap-highlight-color: rgba(37, 99, 235, 0.1);
|
||||
tap-highlight-color: rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
/* Prevent text selection on UI elements */
|
||||
.no-select {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet optimizations for iPad and Galaxy tablets */
|
||||
@media screen and (min-width: 769px) and (max-width: 1024px) {
|
||||
/* Two-column layout for tablets */
|
||||
.tablet-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tablet-three-col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Larger cards for better touch targets */
|
||||
.card {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Better spacing for touch */
|
||||
.tablet-spacing {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Optimize for landscape and portrait */
|
||||
@media (orientation: landscape) {
|
||||
.tablet-landscape {
|
||||
grid-template-columns: 1fr 2fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* iPad specific optimizations */
|
||||
@media screen and (device-width: 768px) and (device-height: 1024px),
|
||||
screen and (device-width: 1024px) and (device-height: 768px),
|
||||
screen and (device-width: 834px) and (device-height: 1194px),
|
||||
screen and (device-width: 1194px) and (device-height: 834px),
|
||||
screen and (device-width: 820px) and (device-height: 1180px),
|
||||
screen and (device-width: 1180px) and (device-height: 820px) {
|
||||
/* iPad Safari specific fixes */
|
||||
input, select, textarea {
|
||||
font-size: 16px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Better hover states for iPad */
|
||||
@media (hover: hover) {
|
||||
button:hover, [role="button"]:hover {
|
||||
transform: translateY(-2px);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area insets for devices with notches */
|
||||
.safe-area {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit browsers */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground));
|
||||
}
|
||||
422
client/src/lib/pdf-generator.ts
Normal file
422
client/src/lib/pdf-generator.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* PDF Generation Utility
|
||||
* In a production environment, this would use libraries like jsPDF, PDFKit, or React-PDF
|
||||
* For now, we provide a simple text-based PDF generation placeholder
|
||||
*/
|
||||
|
||||
import type { Document, Product } from "@shared/schema";
|
||||
|
||||
export interface PDFGenerationOptions {
|
||||
format?: 'A4' | 'Letter';
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
margins?: {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class PDFGenerator {
|
||||
private static formatDate(date: Date | string | null): string {
|
||||
if (!date) return 'Unknown';
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
private static formatTime(date: Date | string | null): string {
|
||||
if (!date) return 'Unknown';
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
static generateDeliveryNote(document: Document, options?: PDFGenerationOptions): string {
|
||||
const content = document.content as any;
|
||||
const createdDate = this.formatDate(document.createdAt);
|
||||
const createdTime = this.formatTime(document.createdAt);
|
||||
|
||||
return `
|
||||
==============================================
|
||||
WAREHOUSE TRACK PRO
|
||||
==============================================
|
||||
|
||||
DELIVERY NOTE: ${document.documentNumber}
|
||||
Date: ${createdDate} ${createdTime}
|
||||
Status: ${document.status.toUpperCase()}
|
||||
|
||||
----------------------------------------------
|
||||
CUSTOMER INFORMATION
|
||||
----------------------------------------------
|
||||
Name: ${content.customerName || 'N/A'}
|
||||
Address: ${content.customerAddress || 'N/A'}
|
||||
|
||||
----------------------------------------------
|
||||
ITEMS
|
||||
----------------------------------------------
|
||||
${content.items?.map((item: any, index: number) => `
|
||||
${index + 1}. ${item.productName} (${item.productSku})
|
||||
Quantity: ${item.quantity} ${item.unit}
|
||||
${item.notes ? `Notes: ${item.notes}` : ''}
|
||||
`).join('') || 'No items listed'}
|
||||
|
||||
----------------------------------------------
|
||||
SUMMARY
|
||||
----------------------------------------------
|
||||
Total Items: ${content.totalItems || 0}
|
||||
Created: ${createdDate} at ${createdTime}
|
||||
|
||||
----------------------------------------------
|
||||
SIGNATURES
|
||||
----------------------------------------------
|
||||
Driver: _________________________ Date: _______
|
||||
|
||||
Customer: _______________________ Date: _______
|
||||
|
||||
==============================================
|
||||
Generated by WarehouseTrack Pro
|
||||
==============================================
|
||||
`;
|
||||
}
|
||||
|
||||
static generatePackingList(document: Document, options?: PDFGenerationOptions): string {
|
||||
const content = document.content as any;
|
||||
const createdDate = this.formatDate(document.createdAt);
|
||||
const createdTime = this.formatTime(document.createdAt);
|
||||
|
||||
return `
|
||||
==============================================
|
||||
WAREHOUSE TRACK PRO
|
||||
==============================================
|
||||
|
||||
PACKING LIST: ${document.documentNumber}
|
||||
Date: ${createdDate} ${createdTime}
|
||||
Status: ${document.status.toUpperCase()}
|
||||
|
||||
----------------------------------------------
|
||||
CUSTOMER INFORMATION
|
||||
----------------------------------------------
|
||||
Name: ${content.customerName || 'N/A'}
|
||||
Address: ${content.customerAddress || 'N/A'}
|
||||
|
||||
----------------------------------------------
|
||||
PACKING DETAILS
|
||||
----------------------------------------------
|
||||
${content.items?.map((item: any, index: number) => `
|
||||
Item ${index + 1}:
|
||||
Product: ${item.productName}
|
||||
SKU: ${item.productSku}
|
||||
Quantity: ${item.quantity} ${item.unit}
|
||||
${item.notes ? `Special Instructions: ${item.notes}` : ''}
|
||||
|
||||
Packed: [ ] Checked: [ ] Initial: _____
|
||||
`).join('') || 'No items to pack'}
|
||||
|
||||
----------------------------------------------
|
||||
PACKING SUMMARY
|
||||
----------------------------------------------
|
||||
Total Items: ${content.totalItems || 0}
|
||||
Total Packages: _______________
|
||||
Total Weight: _________________
|
||||
|
||||
----------------------------------------------
|
||||
QUALITY CONTROL
|
||||
----------------------------------------------
|
||||
Packed By: ________________________ Date: _______
|
||||
Checked By: _______________________ Date: _______
|
||||
Approved By: ______________________ Date: _______
|
||||
|
||||
==============================================
|
||||
Generated by WarehouseTrack Pro
|
||||
==============================================
|
||||
`;
|
||||
}
|
||||
|
||||
static generateShippingLabel(document: Document, options?: PDFGenerationOptions): string {
|
||||
const content = document.content as any;
|
||||
const createdDate = this.formatDate(document.createdAt);
|
||||
|
||||
return `
|
||||
==============================================
|
||||
SHIPPING LABEL
|
||||
==============================================
|
||||
|
||||
Tracking Number: ${document.documentNumber}
|
||||
Date: ${createdDate}
|
||||
|
||||
----------------------------------------------
|
||||
FROM:
|
||||
WarehouseTrack Pro
|
||||
[Your Warehouse Address]
|
||||
[City, State ZIP]
|
||||
|
||||
----------------------------------------------
|
||||
TO:
|
||||
${content.customerName || '[Customer Name]'}
|
||||
${content.customerAddress || '[Customer Address]'}
|
||||
|
||||
----------------------------------------------
|
||||
PACKAGE DETAILS:
|
||||
----------------------------------------------
|
||||
Contents: ${content.totalItems || 0} items
|
||||
Weight: ________________
|
||||
Dimensions: ____________
|
||||
|
||||
Service Type: [ ] Standard [ ] Express [ ] Overnight
|
||||
|
||||
----------------------------------------------
|
||||
BARCODE AREA
|
||||
----------------------------------------------
|
||||
||||| ${document.documentNumber} |||||
|
||||
|
||||
==============================================
|
||||
Handle with Care
|
||||
==============================================
|
||||
`;
|
||||
}
|
||||
|
||||
static generateGoodsReceipt(document: Document, options?: PDFGenerationOptions): string {
|
||||
const content = document.content as any;
|
||||
const createdDate = this.formatDate(document.createdAt);
|
||||
const createdTime = this.formatTime(document.createdAt);
|
||||
|
||||
return `
|
||||
==============================================
|
||||
WAREHOUSE TRACK PRO
|
||||
==============================================
|
||||
|
||||
GOODS RECEIPT: ${document.documentNumber}
|
||||
Date: ${createdDate} ${createdTime}
|
||||
Status: ${document.status.toUpperCase()}
|
||||
|
||||
----------------------------------------------
|
||||
SUPPLIER INFORMATION
|
||||
----------------------------------------------
|
||||
Name: ${content.customerName || 'N/A'}
|
||||
Address: ${content.customerAddress || 'N/A'}
|
||||
|
||||
----------------------------------------------
|
||||
RECEIVED ITEMS
|
||||
----------------------------------------------
|
||||
${content.items?.map((item: any, index: number) => `
|
||||
${index + 1}. ${item.productName} (${item.productSku})
|
||||
Expected: ${item.quantity} ${item.unit}
|
||||
Received: _______ ${item.unit}
|
||||
Condition: [ ] Good [ ] Damaged [ ] Defective
|
||||
${item.notes ? `Notes: ${item.notes}` : ''}
|
||||
`).join('') || 'No items listed'}
|
||||
|
||||
----------------------------------------------
|
||||
RECEIVING SUMMARY
|
||||
----------------------------------------------
|
||||
Total Items Expected: ${content.totalItems || 0}
|
||||
Total Items Received: ________________
|
||||
Discrepancies: _______________________
|
||||
|
||||
----------------------------------------------
|
||||
AUTHORIZATION
|
||||
----------------------------------------------
|
||||
Received By: ______________________ Date: _______
|
||||
Signature: ________________________
|
||||
|
||||
Checked By: _______________________ Date: _______
|
||||
Signature: ________________________
|
||||
|
||||
==============================================
|
||||
Generated by WarehouseTrack Pro
|
||||
==============================================
|
||||
`;
|
||||
}
|
||||
|
||||
static generateStockReport(document: Document, products?: Product[], options?: PDFGenerationOptions): string {
|
||||
const createdDate = this.formatDate(document.createdAt);
|
||||
const createdTime = this.formatTime(document.createdAt);
|
||||
|
||||
const totalValue = products?.reduce((sum, product) => {
|
||||
const price = parseFloat(product.price || "0");
|
||||
return sum + (price * product.currentStock);
|
||||
}, 0) || 0;
|
||||
|
||||
const inStockCount = products?.filter(p => p.currentStock > p.minThreshold).length || 0;
|
||||
const lowStockCount = products?.filter(p => p.currentStock > 0 && p.currentStock <= p.minThreshold).length || 0;
|
||||
const outOfStockCount = products?.filter(p => p.currentStock === 0).length || 0;
|
||||
|
||||
return `
|
||||
==============================================
|
||||
WAREHOUSE TRACK PRO
|
||||
==============================================
|
||||
|
||||
STOCK REPORT: ${document.documentNumber}
|
||||
Generated: ${createdDate} ${createdTime}
|
||||
Report Period: Current Inventory Status
|
||||
|
||||
----------------------------------------------
|
||||
INVENTORY SUMMARY
|
||||
----------------------------------------------
|
||||
Total Products: ${products?.length || 0}
|
||||
Total Value: $${totalValue.toFixed(2)}
|
||||
|
||||
Stock Status:
|
||||
In Stock: ${inStockCount}
|
||||
Low Stock: ${lowStockCount}
|
||||
Out of Stock: ${outOfStockCount}
|
||||
|
||||
----------------------------------------------
|
||||
DETAILED INVENTORY
|
||||
----------------------------------------------
|
||||
${products?.map((product, index) => {
|
||||
const status = product.currentStock === 0 ? 'OUT OF STOCK' :
|
||||
product.currentStock <= product.minThreshold ? 'LOW STOCK' : 'IN STOCK';
|
||||
const value = (parseFloat(product.price || "0") * product.currentStock).toFixed(2);
|
||||
|
||||
return `
|
||||
${index + 1}. ${product.name}
|
||||
SKU: ${product.sku}
|
||||
Current Stock: ${product.currentStock} ${product.unit}
|
||||
Min Threshold: ${product.minThreshold} ${product.unit}
|
||||
Unit Price: $${product.price || '0.00'}
|
||||
Total Value: $${value}
|
||||
Status: ${status}
|
||||
`;
|
||||
}).join('') || 'No products in inventory'}
|
||||
|
||||
----------------------------------------------
|
||||
LOW STOCK ALERTS
|
||||
----------------------------------------------
|
||||
${products?.filter(p => p.currentStock > 0 && p.currentStock <= p.minThreshold).map(product => `
|
||||
⚠️ ${product.name} (${product.sku})
|
||||
Current: ${product.currentStock} ${product.unit}
|
||||
Minimum: ${product.minThreshold} ${product.unit}
|
||||
`).join('') || 'No low stock items'}
|
||||
|
||||
----------------------------------------------
|
||||
OUT OF STOCK ITEMS
|
||||
----------------------------------------------
|
||||
${products?.filter(p => p.currentStock === 0).map(product => `
|
||||
❌ ${product.name} (${product.sku})
|
||||
Minimum Required: ${product.minThreshold} ${product.unit}
|
||||
`).join('') || 'No out of stock items'}
|
||||
|
||||
==============================================
|
||||
Generated by WarehouseTrack Pro
|
||||
==============================================
|
||||
`;
|
||||
}
|
||||
|
||||
static generateDispatchNote(document: Document, options?: PDFGenerationOptions): string {
|
||||
const content = document.content as any;
|
||||
const createdDate = this.formatDate(document.createdAt);
|
||||
const createdTime = this.formatTime(document.createdAt);
|
||||
|
||||
return `
|
||||
==============================================
|
||||
WAREHOUSE TRACK PRO
|
||||
==============================================
|
||||
|
||||
DISPATCH NOTE: ${document.documentNumber}
|
||||
Date: ${createdDate} ${createdTime}
|
||||
Status: ${document.status.toUpperCase()}
|
||||
|
||||
----------------------------------------------
|
||||
DISPATCH INFORMATION
|
||||
----------------------------------------------
|
||||
Customer: ${content.customerName || 'N/A'}
|
||||
Delivery Address: ${content.customerAddress || 'N/A'}
|
||||
|
||||
----------------------------------------------
|
||||
DISPATCHED ITEMS
|
||||
----------------------------------------------
|
||||
${content.items?.map((item: any, index: number) => `
|
||||
${index + 1}. ${item.productName} (${item.productSku})
|
||||
Quantity Dispatched: ${item.quantity} ${item.unit}
|
||||
${item.notes ? `Instructions: ${item.notes}` : ''}
|
||||
Condition: [ ] Good [ ] Fragile [ ] Hazardous
|
||||
`).join('') || 'No items dispatched'}
|
||||
|
||||
----------------------------------------------
|
||||
DISPATCH SUMMARY
|
||||
----------------------------------------------
|
||||
Total Items: ${content.totalItems || 0}
|
||||
Dispatch Time: ${createdTime}
|
||||
Expected Delivery: ____________________
|
||||
|
||||
----------------------------------------------
|
||||
TRANSPORTATION DETAILS
|
||||
----------------------------------------------
|
||||
Vehicle: _____________________________
|
||||
Driver: ______________________________
|
||||
Contact: _____________________________
|
||||
|
||||
----------------------------------------------
|
||||
AUTHORIZATION
|
||||
----------------------------------------------
|
||||
Dispatched By: ____________________ Date: _______
|
||||
Signature: ________________________
|
||||
|
||||
Authorized By: ____________________ Date: _______
|
||||
Signature: ________________________
|
||||
|
||||
==============================================
|
||||
Generated by WarehouseTrack Pro
|
||||
==============================================
|
||||
`;
|
||||
}
|
||||
|
||||
static generateDocument(document: Document, products?: Product[], options?: PDFGenerationOptions): string {
|
||||
switch (document.type) {
|
||||
case 'delivery_note':
|
||||
return this.generateDeliveryNote(document, options);
|
||||
case 'packing_list':
|
||||
return this.generatePackingList(document, options);
|
||||
case 'shipping_label':
|
||||
return this.generateShippingLabel(document, options);
|
||||
case 'goods_receipt':
|
||||
return this.generateGoodsReceipt(document, options);
|
||||
case 'stock_report':
|
||||
return this.generateStockReport(document, products, options);
|
||||
case 'dispatch_note':
|
||||
return this.generateDispatchNote(document, options);
|
||||
default:
|
||||
return `
|
||||
==============================================
|
||||
WAREHOUSE TRACK PRO
|
||||
==============================================
|
||||
|
||||
DOCUMENT: ${document.documentNumber}
|
||||
Type: ${document.type}
|
||||
Title: ${document.title}
|
||||
Date: ${this.formatDate(document.createdAt)}
|
||||
Status: ${document.status.toUpperCase()}
|
||||
|
||||
Content:
|
||||
${JSON.stringify(document.content, null, 2)}
|
||||
|
||||
==============================================
|
||||
Generated by WarehouseTrack Pro
|
||||
==============================================
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
static async downloadPDF(doc: Document, products?: Product[], options?: PDFGenerationOptions): Promise<void> {
|
||||
const pdfContent = this.generateDocument(doc, products, options);
|
||||
|
||||
const blob = new Blob([pdfContent], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = window.document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${doc.documentNumber}.pdf`;
|
||||
window.document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
window.document.body.removeChild(a);
|
||||
}
|
||||
}
|
||||
|
||||
export default PDFGenerator;
|
||||
57
client/src/lib/queryClient.ts
Normal file
57
client/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { QueryClient, QueryFunction } from "@tanstack/react-query";
|
||||
|
||||
async function throwIfResNotOk(res: Response) {
|
||||
if (!res.ok) {
|
||||
const text = (await res.text()) || res.statusText;
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
data?: unknown | undefined,
|
||||
): Promise<Response> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: data ? { "Content-Type": "application/json" } : {},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
type UnauthorizedBehavior = "returnNull" | "throw";
|
||||
export const getQueryFn: <T>(options: {
|
||||
on401: UnauthorizedBehavior;
|
||||
}) => QueryFunction<T> =
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey.join("/") as string, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
queryFn: getQueryFn({ on401: "throw" }),
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
6
client/src/lib/utils.ts
Normal file
6
client/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
18
client/src/main.tsx
Normal file
18
client/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
// Register service worker for PWA functionality
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('SW registered: ', registration);
|
||||
})
|
||||
.catch((registrationError) => {
|
||||
console.log('SW registration failed: ', registrationError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
47
client/src/pages/dashboard.tsx
Normal file
47
client/src/pages/dashboard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import AlertBanner from "@/components/common/alert-banner";
|
||||
import QuickActions from "@/components/common/quick-actions";
|
||||
import StockOverview from "@/components/inventory/stock-overview";
|
||||
import RecentActivity from "@/components/common/recent-activity";
|
||||
import DocumentTemplates from "@/components/documents/document-templates";
|
||||
import InventoryTable from "@/components/inventory/inventory-table";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: stats, isLoading: statsLoading } = useQuery<{
|
||||
totalItems: number;
|
||||
inStockItems: number;
|
||||
lowStockItems: number;
|
||||
outOfStockItems: number;
|
||||
}>({
|
||||
queryKey: ["/api/dashboard/stats"],
|
||||
});
|
||||
|
||||
const { data: lowStockProducts } = useQuery<Product[]>({
|
||||
queryKey: ["/api/products/low-stock"],
|
||||
});
|
||||
|
||||
const showLowStockAlert = lowStockProducts && lowStockProducts.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{showLowStockAlert && (
|
||||
<AlertBanner
|
||||
type="warning"
|
||||
title="Low Stock Alert"
|
||||
message={`${lowStockProducts.length} items are running low on inventory`}
|
||||
data-testid="alert-low-stock"
|
||||
/>
|
||||
)}
|
||||
|
||||
<QuickActions />
|
||||
|
||||
<StockOverview stats={stats} isLoading={statsLoading} />
|
||||
|
||||
<RecentActivity />
|
||||
|
||||
<DocumentTemplates />
|
||||
|
||||
<InventoryTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
client/src/pages/documents.tsx
Normal file
144
client/src/pages/documents.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, FileText, Download, Edit, Trash2 } from "lucide-react";
|
||||
import DocumentTemplates from "@/components/documents/document-templates";
|
||||
import CreateDocumentDialog from "@/components/documents/create-document-dialog";
|
||||
import type { Document } from "@shared/schema";
|
||||
|
||||
export default function Documents() {
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const { data: documents, isLoading } = useQuery<Document[]>({
|
||||
queryKey: ["/api/documents"],
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "finalized":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "printed":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPdf = async (documentId: string, documentNumber: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${documentId}/pdf`);
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${documentNumber}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download PDF:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-gray-800" data-testid="text-documents-title">
|
||||
Documents
|
||||
</h1>
|
||||
<Button
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
className="bg-primary hover:bg-primary-dark"
|
||||
data-testid="button-create-document"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Document
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DocumentTemplates />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
<span>Recent Documents</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8" data-testid="text-loading">
|
||||
Loading documents...
|
||||
</div>
|
||||
) : documents && documents.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{documents.map((document) => (
|
||||
<div
|
||||
key={document.id}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
data-testid={`card-document-${document.id}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="font-semibold text-gray-800">{document.title}</h3>
|
||||
<Badge className={getStatusColor(document.status)}>
|
||||
{document.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{document.documentNumber} • {document.type.replace('_', ' ')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Created: {document.createdAt ? new Date(document.createdAt).toLocaleDateString() : 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadPdf(document.id, document.documentNumber)}
|
||||
data-testid={`button-download-${document.id}`}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid={`button-edit-${document.id}`}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
data-testid={`button-delete-${document.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500" data-testid="text-no-documents">
|
||||
No documents found. Create your first document using the templates above.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateDocumentDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
client/src/pages/inventory.tsx
Normal file
70
client/src/pages/inventory.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import InventoryTable from "@/components/inventory/inventory-table";
|
||||
import StockOverview from "@/components/inventory/stock-overview";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, Plus } from "lucide-react";
|
||||
import AddStockDialog from "@/components/inventory/add-stock-dialog";
|
||||
|
||||
export default function Inventory() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery<{
|
||||
totalItems: number;
|
||||
inStockItems: number;
|
||||
lowStockItems: number;
|
||||
outOfStockItems: number;
|
||||
}>({
|
||||
queryKey: ["/api/dashboard/stats"],
|
||||
});
|
||||
|
||||
const { data: searchResults } = useQuery<Product[]>({
|
||||
queryKey: ["/api/products/search", { q: searchQuery }],
|
||||
enabled: searchQuery.length > 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-gray-800" data-testid="text-inventory-title">
|
||||
Inventory Management
|
||||
</h1>
|
||||
<Button
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
className="bg-primary hover:bg-primary-dark"
|
||||
data-testid="button-add-product"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Product
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<StockOverview stats={stats} isLoading={statsLoading} />
|
||||
|
||||
<div className="bg-white rounded-xl shadow-md p-6">
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search products by name, SKU, or barcode..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 py-4 text-lg"
|
||||
data-testid="input-search-products"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InventoryTable searchResults={searchResults} showAll={true} />
|
||||
</div>
|
||||
|
||||
<AddStockDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
client/src/pages/not-found.tsx
Normal file
21
client/src/pages/not-found.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex mb-4 gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Did you forget to add the page to the router?
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
client/src/pages/reports.tsx
Normal file
217
client/src/pages/reports.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BarChart3, Download, TrendingUp, Package, AlertTriangle, FileText } from "lucide-react";
|
||||
import type { Product, StockMovement } from "@shared/schema";
|
||||
|
||||
export default function Reports() {
|
||||
const { data: products } = useQuery<Product[]>({
|
||||
queryKey: ["/api/products"],
|
||||
});
|
||||
|
||||
const { data: stockMovements } = useQuery<StockMovement[]>({
|
||||
queryKey: ["/api/stock-movements"],
|
||||
});
|
||||
|
||||
const { data: stats } = useQuery<{
|
||||
totalItems: number;
|
||||
inStockItems: number;
|
||||
lowStockItems: number;
|
||||
outOfStockItems: number;
|
||||
}>({
|
||||
queryKey: ["/api/dashboard/stats"],
|
||||
});
|
||||
|
||||
const totalValue = products?.reduce((sum, product) => {
|
||||
const price = parseFloat(product.price || "0");
|
||||
return sum + (price * product.currentStock);
|
||||
}, 0) || 0;
|
||||
|
||||
const recentMovements = stockMovements?.slice(0, 10) || [];
|
||||
|
||||
const generateReport = (type: string) => {
|
||||
// In a real app, this would generate and download a proper report
|
||||
console.log(`Generating ${type} report...`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-gray-800" data-testid="text-reports-title">
|
||||
Reports & Analytics
|
||||
</h1>
|
||||
<Button
|
||||
onClick={() => generateReport('comprehensive')}
|
||||
className="bg-primary hover:bg-primary-dark"
|
||||
data-testid="button-generate-report"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Generate Report
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600 uppercase tracking-wide">Total Items</p>
|
||||
<p className="text-3xl font-bold text-blue-800" data-testid="text-total-items">
|
||||
{stats?.totalItems || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Package className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-600 uppercase tracking-wide">Total Value</p>
|
||||
<p className="text-3xl font-bold text-green-800" data-testid="text-total-value">
|
||||
${totalValue.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="w-8 h-8 text-green-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-orange-600 uppercase tracking-wide">Low Stock</p>
|
||||
<p className="text-3xl font-bold text-orange-800" data-testid="text-low-stock-count">
|
||||
{stats?.lowStockItems || 0}
|
||||
</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 text-orange-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600 uppercase tracking-wide">Out of Stock</p>
|
||||
<p className="text-3xl font-bold text-red-800" data-testid="text-out-of-stock-count">
|
||||
{stats?.outOfStockItems || 0}
|
||||
</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 text-red-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Report Types */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
<span>Available Reports</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="border border-gray-200 rounded-lg p-4 hover:border-primary hover:bg-blue-50 transition-all cursor-pointer">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<BarChart3 className="w-6 h-6 text-primary" />
|
||||
<h3 className="font-semibold">Inventory Summary</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Complete overview of all products, stock levels, and values
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => generateReport('inventory')}
|
||||
data-testid="button-generate-inventory-report"
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg p-4 hover:border-primary hover:bg-blue-50 transition-all cursor-pointer">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<TrendingUp className="w-6 h-6 text-primary" />
|
||||
<h3 className="font-semibold">Stock Movements</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Detailed log of all stock ins and outs over time
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => generateReport('movements')}
|
||||
data-testid="button-generate-movements-report"
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg p-4 hover:border-primary hover:bg-blue-50 transition-all cursor-pointer">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<AlertTriangle className="w-6 h-6 text-primary" />
|
||||
<h3 className="font-semibold">Stock Alerts</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Products that need attention due to low or zero stock
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => generateReport('alerts')}
|
||||
data-testid="button-generate-alerts-report"
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Stock Movements</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentMovements.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{recentMovements.map((movement) => {
|
||||
const product = products?.find(p => p.id === movement.productId);
|
||||
return (
|
||||
<div key={movement.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium" data-testid={`text-movement-product-${movement.id}`}>
|
||||
{product?.name || 'Unknown Product'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{movement.type === 'in' ? 'Added' : movement.type === 'out' ? 'Removed' : 'Adjusted'} {movement.quantity} units
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">
|
||||
{movement.createdAt ? new Date(movement.createdAt).toLocaleDateString() : 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-gray-500 py-8" data-testid="text-no-movements">
|
||||
No recent stock movements
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
client/src/pages/scanner.tsx
Normal file
251
client/src/pages/scanner.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Camera, Keyboard, Search, Package, Plus, Minus } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { Product } from "@shared/schema";
|
||||
|
||||
export default function Scanner() {
|
||||
const [scanInput, setScanInput] = useState("");
|
||||
const [scannedProduct, setScannedProduct] = useState<Product | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [isManualEntry, setIsManualEntry] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: products } = useQuery<Product[]>({
|
||||
queryKey: ["/api/products"],
|
||||
});
|
||||
|
||||
const stockMovementMutation = useMutation({
|
||||
mutationFn: (data: { productId: string; type: string; quantity: number; reason: string }) =>
|
||||
apiRequest("POST", "/api/stock-movements", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dashboard/stats"] });
|
||||
toast({
|
||||
title: "Stock Updated",
|
||||
description: "Stock movement recorded successfully",
|
||||
});
|
||||
setScannedProduct(null);
|
||||
setScanInput("");
|
||||
setQuantity(1);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update stock",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleScan = () => {
|
||||
if (!scanInput.trim()) return;
|
||||
|
||||
const product = products?.find(p =>
|
||||
p.sku.toLowerCase() === scanInput.toLowerCase() ||
|
||||
p.name.toLowerCase().includes(scanInput.toLowerCase())
|
||||
);
|
||||
|
||||
if (product) {
|
||||
setScannedProduct(product);
|
||||
toast({
|
||||
title: "Product Found",
|
||||
description: `Scanned: ${product.name}`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Product Not Found",
|
||||
description: "No product found with that SKU or name",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStockMovement = (type: 'in' | 'out') => {
|
||||
if (!scannedProduct) return;
|
||||
|
||||
stockMovementMutation.mutate({
|
||||
productId: scannedProduct.id,
|
||||
type,
|
||||
quantity,
|
||||
reason: type === 'in' ? 'Stock added via scanner' : 'Stock removed via scanner',
|
||||
});
|
||||
};
|
||||
|
||||
const getStockStatus = (product: Product) => {
|
||||
if (product.currentStock === 0) {
|
||||
return { label: "Out of Stock", className: "bg-red-100 text-red-800" };
|
||||
} else if (product.currentStock <= product.minThreshold) {
|
||||
return { label: "Low Stock", className: "bg-orange-100 text-orange-800" };
|
||||
} else {
|
||||
return { label: "In Stock", className: "bg-green-100 text-green-800" };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2" data-testid="text-scanner-title">
|
||||
Product Scanner
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Scan or search for products to manage inventory
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
{isManualEntry ? (
|
||||
<Keyboard className="w-5 h-5" />
|
||||
) : (
|
||||
<Camera className="w-5 h-5" />
|
||||
)}
|
||||
<span>{isManualEntry ? "Manual Entry" : "Scanner"}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isManualEntry ? (
|
||||
<div className="bg-gray-100 rounded-lg p-8 text-center">
|
||||
<Camera className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-2">Position barcode within frame</p>
|
||||
<p className="text-sm text-gray-500 mb-4">Camera scanner would activate here</p>
|
||||
<Button
|
||||
onClick={() => setIsManualEntry(true)}
|
||||
variant="outline"
|
||||
data-testid="button-manual-entry"
|
||||
>
|
||||
<Keyboard className="w-4 h-4 mr-2" />
|
||||
Switch to Manual Entry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter SKU or product name..."
|
||||
value={scanInput}
|
||||
onChange={(e) => setScanInput(e.target.value)}
|
||||
className="pl-10"
|
||||
data-testid="input-scan-manual"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleScan}
|
||||
className="bg-primary hover:bg-primary-dark"
|
||||
data-testid="button-search-product"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsManualEntry(false)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
data-testid="button-camera-mode"
|
||||
>
|
||||
<Camera className="w-4 h-4 mr-2" />
|
||||
Switch to Camera Scanner
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{scannedProduct && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Package className="w-5 h-5" />
|
||||
<span>Scanned Product</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg" data-testid="text-product-name">
|
||||
{scannedProduct.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600" data-testid="text-product-sku">
|
||||
SKU: {scannedProduct.sku}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<span className="text-lg font-semibold" data-testid="text-current-stock">
|
||||
{scannedProduct.currentStock} {scannedProduct.unit}
|
||||
</span>
|
||||
<Badge className={getStockStatus(scannedProduct).className}>
|
||||
{getStockStatus(scannedProduct).label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Quantity
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
data-testid="button-decrease-quantity"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-20 text-center"
|
||||
data-testid="input-quantity"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
data-testid="button-increase-quantity"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={() => handleStockMovement('in')}
|
||||
disabled={stockMovementMutation.isPending}
|
||||
className="flex-1 bg-secondary hover:bg-green-600"
|
||||
data-testid="button-add-stock"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Stock
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleStockMovement('out')}
|
||||
disabled={stockMovementMutation.isPending || scannedProduct.currentStock < quantity}
|
||||
variant="outline"
|
||||
className="flex-1 text-red-600 border-red-300 hover:bg-red-50"
|
||||
data-testid="button-remove-stock"
|
||||
>
|
||||
<Minus className="w-4 h-4 mr-2" />
|
||||
Remove Stock
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
components.json
Normal file
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "client/src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
14
drizzle.config.ts
Normal file
14
drizzle.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL, ensure the database is provisioned");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
out: "./migrations",
|
||||
schema: "./shared/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
9115
package-lock.json
generated
Normal file
9115
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
105
package.json
Normal file
105
package.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "rest-express",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development tsx server/index.ts",
|
||||
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"check": "tsc",
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@neondatabase/serverless": "^0.10.4",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.5",
|
||||
"@radix-ui/react-collapsible": "^1.1.4",
|
||||
"@radix-ui/react-context-menu": "^2.2.7",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-hover-card": "^1.1.7",
|
||||
"@radix-ui/react-label": "^2.1.3",
|
||||
"@radix-ui/react-menubar": "^1.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.6",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.3",
|
||||
"@radix-ui/react-radio-group": "^1.2.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slider": "^1.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.4",
|
||||
"@radix-ui/react-tabs": "^1.1.4",
|
||||
"@radix-ui/react-toast": "^1.2.7",
|
||||
"@radix-ui/react-toggle": "^1.1.3",
|
||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"drizzle-orm": "^0.39.1",
|
||||
"drizzle-zod": "^0.7.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"framer-motion": "^11.13.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"memorystore": "^1.6.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.15.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vaul": "^1.1.2",
|
||||
"wouter": "^3.3.5",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@replit/vite-plugin-cartographer": "^0.3.0",
|
||||
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"esbuild": "^0.25.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "^5.4.19"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
97
replit.md
Normal file
97
replit.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Replit.md
|
||||
|
||||
## Overview
|
||||
|
||||
WarehouseTrack Pro is a comprehensive warehouse inventory management system built as a full-stack web application. The system provides real-time inventory tracking, stock movement management, document generation, barcode scanning capabilities, and reporting features. It's designed to streamline warehouse operations with an intuitive interface optimized for both desktop and mobile use.
|
||||
|
||||
## User Preferences
|
||||
|
||||
Preferred communication style: Simple, everyday language.
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Frontend Architecture
|
||||
- **Framework**: React 18 with TypeScript and Vite for build tooling
|
||||
- **UI Library**: shadcn/ui components built on Radix UI primitives for accessibility
|
||||
- **Styling**: Tailwind CSS with CSS custom properties for theming
|
||||
- **Routing**: Wouter for lightweight client-side routing
|
||||
- **State Management**: TanStack Query (React Query) for server state management
|
||||
- **Form Handling**: React Hook Form with Zod validation
|
||||
- **Mobile-First Design**: Responsive layout with bottom navigation for mobile, traditional header for desktop
|
||||
|
||||
### Backend Architecture
|
||||
- **Runtime**: Node.js with Express.js framework
|
||||
- **Language**: TypeScript with ES modules
|
||||
- **Database ORM**: Drizzle ORM for type-safe database operations
|
||||
- **Schema Validation**: Zod schemas shared between client and server
|
||||
- **Development Server**: Vite middleware integration for hot module replacement
|
||||
- **API Design**: RESTful endpoints with JSON responses
|
||||
|
||||
### Data Storage Architecture
|
||||
- **Database**: PostgreSQL with Neon serverless hosting
|
||||
- **ORM**: Drizzle ORM with PostgreSQL dialect
|
||||
- **Schema Management**: Code-first approach with migrations in `./migrations`
|
||||
- **Data Models**: Products, stock movements, documents, orders, and notifications
|
||||
- **Relationships**: Foreign key constraints between products and stock movements
|
||||
|
||||
### Core Data Models
|
||||
- **Products**: SKU-based inventory items with stock levels and thresholds
|
||||
- **Stock Movements**: Audit trail for all inventory changes (in/out/adjustments)
|
||||
- **Documents**: Generated PDFs for delivery notes, packing lists, shipping labels
|
||||
- **Orders**: Customer order management with item tracking
|
||||
- **Notifications**: System alerts for low stock and other events
|
||||
|
||||
### Development and Build System
|
||||
- **Build Tool**: Vite for frontend bundling and development server
|
||||
- **Backend Build**: esbuild for Node.js server bundling
|
||||
- **TypeScript**: Strict type checking across client, server, and shared modules
|
||||
- **Path Aliases**: Clean imports using @ for client code and @shared for common types
|
||||
- **Hot Reload**: Development mode with automatic server and client refresh
|
||||
|
||||
### Component Architecture
|
||||
- **Design System**: Consistent UI components using shadcn/ui
|
||||
- **Layout Components**: Header, bottom navigation, and floating action button
|
||||
- **Page Components**: Dashboard, inventory, documents, scanner, and reports
|
||||
- **Feature Components**: Modular components for inventory management, document creation, and scanning
|
||||
- **Shared Components**: Reusable UI elements like alerts, dialogs, and forms
|
||||
|
||||
### Document Generation System
|
||||
- **PDF Creation**: Server-side PDF generation for warehouse documents
|
||||
- **Templates**: Predefined templates for delivery notes, packing lists, shipping labels
|
||||
- **Dynamic Content**: Documents populated with real inventory and order data
|
||||
- **Download System**: Client-side PDF download functionality
|
||||
|
||||
### Mobile Optimization
|
||||
- **Responsive Design**: Mobile-first approach with tablet and desktop breakpoints
|
||||
- **Touch Interface**: Large touch targets and mobile-friendly navigation
|
||||
- **Barcode Scanner**: Camera-based scanning with manual entry fallback
|
||||
- **Offline Considerations**: Local state management for poor connectivity scenarios
|
||||
|
||||
## External Dependencies
|
||||
|
||||
### Database and Hosting
|
||||
- **Neon Database**: Serverless PostgreSQL hosting with connection pooling
|
||||
- **Drizzle Kit**: Database migration and schema management tools
|
||||
|
||||
### UI and Styling
|
||||
- **Radix UI**: Accessible component primitives for complex UI elements
|
||||
- **Tailwind CSS**: Utility-first CSS framework with custom design tokens
|
||||
- **Lucide React**: Icon library for consistent iconography
|
||||
- **Google Fonts**: Roboto font family for typography
|
||||
|
||||
### Development Tools
|
||||
- **Vite**: Frontend build tool and development server
|
||||
- **TypeScript**: Static type checking and enhanced developer experience
|
||||
- **React Query**: Server state management and caching
|
||||
- **React Hook Form**: Form state management and validation
|
||||
- **Zod**: Runtime schema validation for type safety
|
||||
|
||||
### Replit Integration
|
||||
- **Replit Vite Plugins**: Development environment integration and error handling
|
||||
- **Runtime Error Modal**: Development-time error overlay for debugging
|
||||
|
||||
### Node.js Backend Dependencies
|
||||
- **Express.js**: Web application framework for API routes
|
||||
- **Connect PG Simple**: PostgreSQL session store for Express
|
||||
- **Date-fns**: Date manipulation and formatting utilities
|
||||
- **UUID Generation**: Unique identifier creation for database records
|
||||
71
server/index.ts
Normal file
71
server/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import express, { type Request, Response, NextFunction } from "express";
|
||||
import { registerRoutes } from "./routes";
|
||||
import { setupVite, serveStatic, log } from "./vite";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
const path = req.path;
|
||||
let capturedJsonResponse: Record<string, any> | undefined = undefined;
|
||||
|
||||
const originalResJson = res.json;
|
||||
res.json = function (bodyJson, ...args) {
|
||||
capturedJsonResponse = bodyJson;
|
||||
return originalResJson.apply(res, [bodyJson, ...args]);
|
||||
};
|
||||
|
||||
res.on("finish", () => {
|
||||
const duration = Date.now() - start;
|
||||
if (path.startsWith("/api")) {
|
||||
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
|
||||
if (capturedJsonResponse) {
|
||||
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
|
||||
}
|
||||
|
||||
if (logLine.length > 80) {
|
||||
logLine = logLine.slice(0, 79) + "…";
|
||||
}
|
||||
|
||||
log(logLine);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const server = await registerRoutes(app);
|
||||
|
||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||
const status = err.status || err.statusCode || 500;
|
||||
const message = err.message || "Internal Server Error";
|
||||
|
||||
res.status(status).json({ message });
|
||||
throw err;
|
||||
});
|
||||
|
||||
// importantly only setup vite in development and after
|
||||
// setting up all the other routes so the catch-all route
|
||||
// doesn't interfere with the other routes
|
||||
if (app.get("env") === "development") {
|
||||
await setupVite(app, server);
|
||||
} else {
|
||||
serveStatic(app);
|
||||
}
|
||||
|
||||
// ALWAYS serve the app on the port specified in the environment variable PORT
|
||||
// Other ports are firewalled. Default to 5000 if not specified.
|
||||
// this serves both the API and the client.
|
||||
// It is the only port that is not firewalled.
|
||||
const port = parseInt(process.env.PORT || '5000', 10);
|
||||
server.listen({
|
||||
port,
|
||||
host: "0.0.0.0",
|
||||
reusePort: true,
|
||||
}, () => {
|
||||
log(`serving on port ${port}`);
|
||||
});
|
||||
})();
|
||||
258
server/routes.ts
Normal file
258
server/routes.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { Express } from "express";
|
||||
import { createServer, type Server } from "http";
|
||||
import { storage } from "./storage";
|
||||
import { insertProductSchema, insertStockMovementSchema, insertDocumentSchema } from "@shared/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function registerRoutes(app: Express): Promise<Server> {
|
||||
// Products
|
||||
app.get("/api/products", async (req, res) => {
|
||||
try {
|
||||
const products = await storage.getProducts();
|
||||
res.json(products);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to fetch products" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/products/search", async (req, res) => {
|
||||
try {
|
||||
const query = req.query.q as string;
|
||||
if (!query) {
|
||||
return res.status(400).json({ message: "Search query is required" });
|
||||
}
|
||||
const products = await storage.searchProducts(query);
|
||||
res.json(products);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to search products" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/products/low-stock", async (req, res) => {
|
||||
try {
|
||||
const products = await storage.getLowStockProducts();
|
||||
res.json(products);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to fetch low stock products" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/products/out-of-stock", async (req, res) => {
|
||||
try {
|
||||
const products = await storage.getOutOfStockProducts();
|
||||
res.json(products);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to fetch out of stock products" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/products/:id", async (req, res) => {
|
||||
try {
|
||||
const product = await storage.getProduct(req.params.id);
|
||||
if (!product) {
|
||||
return res.status(404).json({ message: "Product not found" });
|
||||
}
|
||||
res.json(product);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to fetch product" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/products", async (req, res) => {
|
||||
try {
|
||||
const productData = insertProductSchema.parse(req.body);
|
||||
const product = await storage.createProduct(productData);
|
||||
res.status(201).json(product);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: "Invalid product data", errors: error.errors });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to create product" });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/products/:id", async (req, res) => {
|
||||
try {
|
||||
const updates = insertProductSchema.partial().parse(req.body);
|
||||
const product = await storage.updateProduct(req.params.id, updates);
|
||||
if (!product) {
|
||||
return res.status(404).json({ message: "Product not found" });
|
||||
}
|
||||
res.json(product);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: "Invalid product data", errors: error.errors });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to update product" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/products/:id", async (req, res) => {
|
||||
try {
|
||||
const deleted = await storage.deleteProduct(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ message: "Product not found" });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to delete product" });
|
||||
}
|
||||
});
|
||||
|
||||
// Stock movements
|
||||
app.get("/api/stock-movements", async (req, res) => {
|
||||
try {
|
||||
const productId = req.query.productId as string;
|
||||
const movements = await storage.getStockMovements(productId);
|
||||
res.json(movements);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to fetch stock movements" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/stock-movements", async (req, res) => {
|
||||
try {
|
||||
const movementData = insertStockMovementSchema.parse(req.body);
|
||||
const movement = await storage.createStockMovement(movementData);
|
||||
res.status(201).json(movement);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: "Invalid stock movement data", errors: error.errors });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to create stock movement" });
|
||||
}
|
||||
});
|
||||
|
||||
// Documents
|
||||
app.get("/api/documents", async (req, res) => {
|
||||
try {
|
||||
const documents = await storage.getDocuments();
|
||||
res.json(documents);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to fetch documents" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/documents/:id", async (req, res) => {
|
||||
try {
|
||||
const document = await storage.getDocument(req.params.id);
|
||||
if (!document) {
|
||||
return res.status(404).json({ message: "Document not found" });
|
||||
}
|
||||
res.json(document);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to fetch document" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/documents", async (req, res) => {
|
||||
try {
|
||||
const documentData = insertDocumentSchema.parse(req.body);
|
||||
const document = await storage.createDocument(documentData);
|
||||
res.status(201).json(document);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: "Invalid document data", errors: error.errors });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to create document" });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/documents/:id", async (req, res) => {
|
||||
try {
|
||||
const updates = insertDocumentSchema.partial().parse(req.body);
|
||||
const document = await storage.updateDocument(req.params.id, updates);
|
||||
if (!document) {
|
||||
return res.status(404).json({ message: "Document not found" });
|
||||
}
|
||||
res.json(document);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: "Invalid document data", errors: error.errors });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to update document" });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate PDF document
|
||||
app.get("/api/documents/:id/pdf", async (req, res) => {
|
||||
try {
|
||||
const document = await storage.getDocument(req.params.id);
|
||||
if (!document) {
|
||||
return res.status(404).json({ message: "Document not found" });
|
||||
}
|
||||
|
||||
// Simple PDF generation for now - in production would use proper PDF library
|
||||
const pdfContent = `
|
||||
Document: ${document.title}
|
||||
Type: ${document.type}
|
||||
Number: ${document.documentNumber}
|
||||
Status: ${document.status}
|
||||
Created: ${document.createdAt}
|
||||
|
||||
Content:
|
||||
${JSON.stringify(document.content, null, 2)}
|
||||
`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${document.documentNumber}.pdf"`);
|
||||
res.send(pdfContent);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to generate PDF" });
|
||||
}
|
||||
});
|
||||
|
||||
// Notifications
|
||||
app.get("/api/notifications", async (req, res) => {
|
||||
try {
|
||||
const notifications = await storage.getNotifications();
|
||||
res.json(notifications);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to fetch notifications" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/notifications/unread-count", async (req, res) => {
|
||||
try {
|
||||
const count = await storage.getUnreadNotificationCount();
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to fetch notification count" });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/notifications/:id/read", async (req, res) => {
|
||||
try {
|
||||
const success = await storage.markNotificationAsRead(req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ message: "Notification not found" });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to mark notification as read" });
|
||||
}
|
||||
});
|
||||
|
||||
// Dashboard stats
|
||||
app.get("/api/dashboard/stats", async (req, res) => {
|
||||
try {
|
||||
const products = await storage.getProducts();
|
||||
const lowStockProducts = await storage.getLowStockProducts();
|
||||
const outOfStockProducts = await storage.getOutOfStockProducts();
|
||||
|
||||
const stats = {
|
||||
totalItems: products.length,
|
||||
inStockItems: products.filter(p => p.currentStock > p.minThreshold).length,
|
||||
lowStockItems: lowStockProducts.length,
|
||||
outOfStockItems: outOfStockProducts.length,
|
||||
};
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to fetch dashboard stats" });
|
||||
}
|
||||
});
|
||||
|
||||
const httpServer = createServer(app);
|
||||
return httpServer;
|
||||
}
|
||||
349
server/storage.ts
Normal file
349
server/storage.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
type Product,
|
||||
type InsertProduct,
|
||||
type StockMovement,
|
||||
type InsertStockMovement,
|
||||
type Document,
|
||||
type InsertDocument,
|
||||
type Order,
|
||||
type InsertOrder,
|
||||
type Notification,
|
||||
type InsertNotification
|
||||
} from "@shared/schema";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export interface IStorage {
|
||||
// Products
|
||||
getProducts(): Promise<Product[]>;
|
||||
getProduct(id: string): Promise<Product | undefined>;
|
||||
getProductBySku(sku: string): Promise<Product | undefined>;
|
||||
createProduct(product: InsertProduct): Promise<Product>;
|
||||
updateProduct(id: string, product: Partial<InsertProduct>): Promise<Product | undefined>;
|
||||
deleteProduct(id: string): Promise<boolean>;
|
||||
searchProducts(query: string): Promise<Product[]>;
|
||||
getLowStockProducts(): Promise<Product[]>;
|
||||
getOutOfStockProducts(): Promise<Product[]>;
|
||||
|
||||
// Stock movements
|
||||
getStockMovements(productId?: string): Promise<StockMovement[]>;
|
||||
createStockMovement(movement: InsertStockMovement): Promise<StockMovement>;
|
||||
|
||||
// Documents
|
||||
getDocuments(): Promise<Document[]>;
|
||||
getDocument(id: string): Promise<Document | undefined>;
|
||||
createDocument(document: InsertDocument): Promise<Document>;
|
||||
updateDocument(id: string, document: Partial<InsertDocument>): Promise<Document | undefined>;
|
||||
deleteDocument(id: string): Promise<boolean>;
|
||||
|
||||
// Orders
|
||||
getOrders(): Promise<Order[]>;
|
||||
getOrder(id: string): Promise<Order | undefined>;
|
||||
createOrder(order: InsertOrder): Promise<Order>;
|
||||
updateOrder(id: string, order: Partial<InsertOrder>): Promise<Order | undefined>;
|
||||
|
||||
// Notifications
|
||||
getNotifications(): Promise<Notification[]>;
|
||||
createNotification(notification: InsertNotification): Promise<Notification>;
|
||||
markNotificationAsRead(id: string): Promise<boolean>;
|
||||
getUnreadNotificationCount(): Promise<number>;
|
||||
}
|
||||
|
||||
export class MemStorage implements IStorage {
|
||||
private products: Map<string, Product>;
|
||||
private stockMovements: Map<string, StockMovement>;
|
||||
private documents: Map<string, Document>;
|
||||
private orders: Map<string, Order>;
|
||||
private notifications: Map<string, Notification>;
|
||||
|
||||
constructor() {
|
||||
this.products = new Map();
|
||||
this.stockMovements = new Map();
|
||||
this.documents = new Map();
|
||||
this.orders = new Map();
|
||||
this.notifications = new Map();
|
||||
|
||||
// Initialize with sample data
|
||||
this.initializeSampleData();
|
||||
}
|
||||
|
||||
private initializeSampleData() {
|
||||
const sampleProducts: Product[] = [
|
||||
{
|
||||
id: "1",
|
||||
sku: "SH-001",
|
||||
name: "Safety Helmets - White",
|
||||
description: "High-quality safety helmets for construction work",
|
||||
currentStock: 125,
|
||||
minThreshold: 20,
|
||||
unit: "units",
|
||||
price: "29.99",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
sku: "WG-003",
|
||||
name: "Work Gloves - Medium",
|
||||
description: "Durable work gloves for general construction",
|
||||
currentStock: 8,
|
||||
minThreshold: 15,
|
||||
unit: "pairs",
|
||||
price: "12.50",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
sku: "HV-005",
|
||||
name: "Hi-Vis Vests - Large",
|
||||
description: "High-visibility safety vests",
|
||||
currentStock: 0,
|
||||
minThreshold: 10,
|
||||
unit: "units",
|
||||
price: "15.75",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
sampleProducts.forEach(product => {
|
||||
this.products.set(product.id, product);
|
||||
});
|
||||
|
||||
// Create notifications for low/out of stock items
|
||||
const lowStockNotification: Notification = {
|
||||
id: "notif-1",
|
||||
type: "low_stock",
|
||||
title: "Low Stock Alert",
|
||||
message: "Work Gloves - Medium is below minimum threshold",
|
||||
isRead: false,
|
||||
relatedId: "2",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const outOfStockNotification: Notification = {
|
||||
id: "notif-2",
|
||||
type: "out_of_stock",
|
||||
title: "Out of Stock Alert",
|
||||
message: "Hi-Vis Vests - Large is out of stock",
|
||||
isRead: false,
|
||||
relatedId: "3",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.notifications.set("notif-1", lowStockNotification);
|
||||
this.notifications.set("notif-2", outOfStockNotification);
|
||||
}
|
||||
|
||||
// Products
|
||||
async getProducts(): Promise<Product[]> {
|
||||
return Array.from(this.products.values());
|
||||
}
|
||||
|
||||
async getProduct(id: string): Promise<Product | undefined> {
|
||||
return this.products.get(id);
|
||||
}
|
||||
|
||||
async getProductBySku(sku: string): Promise<Product | undefined> {
|
||||
return Array.from(this.products.values()).find(product => product.sku === sku);
|
||||
}
|
||||
|
||||
async createProduct(insertProduct: InsertProduct): Promise<Product> {
|
||||
const id = randomUUID();
|
||||
const product: Product = {
|
||||
...insertProduct,
|
||||
id,
|
||||
description: insertProduct.description ?? null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.products.set(id, product);
|
||||
return product;
|
||||
}
|
||||
|
||||
async updateProduct(id: string, updates: Partial<InsertProduct>): Promise<Product | undefined> {
|
||||
const product = this.products.get(id);
|
||||
if (!product) return undefined;
|
||||
|
||||
const updatedProduct: Product = {
|
||||
...product,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.products.set(id, updatedProduct);
|
||||
return updatedProduct;
|
||||
}
|
||||
|
||||
async deleteProduct(id: string): Promise<boolean> {
|
||||
return this.products.delete(id);
|
||||
}
|
||||
|
||||
async searchProducts(query: string): Promise<Product[]> {
|
||||
const products = Array.from(this.products.values());
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return products.filter(product =>
|
||||
product.name.toLowerCase().includes(lowercaseQuery) ||
|
||||
product.sku.toLowerCase().includes(lowercaseQuery) ||
|
||||
product.description?.toLowerCase().includes(lowercaseQuery)
|
||||
);
|
||||
}
|
||||
|
||||
async getLowStockProducts(): Promise<Product[]> {
|
||||
return Array.from(this.products.values()).filter(
|
||||
product => product.currentStock > 0 && product.currentStock <= product.minThreshold
|
||||
);
|
||||
}
|
||||
|
||||
async getOutOfStockProducts(): Promise<Product[]> {
|
||||
return Array.from(this.products.values()).filter(
|
||||
product => product.currentStock === 0
|
||||
);
|
||||
}
|
||||
|
||||
// Stock movements
|
||||
async getStockMovements(productId?: string): Promise<StockMovement[]> {
|
||||
const movements = Array.from(this.stockMovements.values());
|
||||
if (productId) {
|
||||
return movements.filter(movement => movement.productId === productId);
|
||||
}
|
||||
return movements;
|
||||
}
|
||||
|
||||
async createStockMovement(insertMovement: InsertStockMovement): Promise<StockMovement> {
|
||||
const id = randomUUID();
|
||||
const movement: StockMovement = {
|
||||
...insertMovement,
|
||||
id,
|
||||
reason: insertMovement.reason ?? null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
this.stockMovements.set(id, movement);
|
||||
|
||||
// Update product stock
|
||||
const product = this.products.get(insertMovement.productId);
|
||||
if (product) {
|
||||
let newStock = product.currentStock;
|
||||
if (insertMovement.type === 'in') {
|
||||
newStock += insertMovement.quantity;
|
||||
} else if (insertMovement.type === 'out') {
|
||||
newStock -= insertMovement.quantity;
|
||||
} else if (insertMovement.type === 'adjustment') {
|
||||
newStock = insertMovement.quantity;
|
||||
}
|
||||
|
||||
await this.updateProduct(insertMovement.productId, { currentStock: Math.max(0, newStock) });
|
||||
}
|
||||
|
||||
return movement;
|
||||
}
|
||||
|
||||
// Documents
|
||||
async getDocuments(): Promise<Document[]> {
|
||||
return Array.from(this.documents.values());
|
||||
}
|
||||
|
||||
async getDocument(id: string): Promise<Document | undefined> {
|
||||
return this.documents.get(id);
|
||||
}
|
||||
|
||||
async createDocument(insertDocument: InsertDocument): Promise<Document> {
|
||||
const id = randomUUID();
|
||||
const document: Document = {
|
||||
...insertDocument,
|
||||
id,
|
||||
content: insertDocument.content ?? {},
|
||||
status: insertDocument.status ?? "draft",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.documents.set(id, document);
|
||||
return document;
|
||||
}
|
||||
|
||||
async updateDocument(id: string, updates: Partial<InsertDocument>): Promise<Document | undefined> {
|
||||
const document = this.documents.get(id);
|
||||
if (!document) return undefined;
|
||||
|
||||
const updatedDocument: Document = {
|
||||
...document,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.documents.set(id, updatedDocument);
|
||||
return updatedDocument;
|
||||
}
|
||||
|
||||
async deleteDocument(id: string): Promise<boolean> {
|
||||
return this.documents.delete(id);
|
||||
}
|
||||
|
||||
// Orders
|
||||
async getOrders(): Promise<Order[]> {
|
||||
return Array.from(this.orders.values());
|
||||
}
|
||||
|
||||
async getOrder(id: string): Promise<Order | undefined> {
|
||||
return this.orders.get(id);
|
||||
}
|
||||
|
||||
async createOrder(insertOrder: InsertOrder): Promise<Order> {
|
||||
const id = randomUUID();
|
||||
const order: Order = {
|
||||
...insertOrder,
|
||||
id,
|
||||
status: insertOrder.status ?? "pending",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.orders.set(id, order);
|
||||
return order;
|
||||
}
|
||||
|
||||
async updateOrder(id: string, updates: Partial<InsertOrder>): Promise<Order | undefined> {
|
||||
const order = this.orders.get(id);
|
||||
if (!order) return undefined;
|
||||
|
||||
const updatedOrder: Order = {
|
||||
...order,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.orders.set(id, updatedOrder);
|
||||
return updatedOrder;
|
||||
}
|
||||
|
||||
// Notifications
|
||||
async getNotifications(): Promise<Notification[]> {
|
||||
return Array.from(this.notifications.values()).sort(
|
||||
(a, b) => (b.createdAt?.getTime() || 0) - (a.createdAt?.getTime() || 0)
|
||||
);
|
||||
}
|
||||
|
||||
async createNotification(insertNotification: InsertNotification): Promise<Notification> {
|
||||
const id = randomUUID();
|
||||
const notification: Notification = {
|
||||
...insertNotification,
|
||||
id,
|
||||
isRead: insertNotification.isRead ?? false,
|
||||
relatedId: insertNotification.relatedId ?? null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
this.notifications.set(id, notification);
|
||||
return notification;
|
||||
}
|
||||
|
||||
async markNotificationAsRead(id: string): Promise<boolean> {
|
||||
const notification = this.notifications.get(id);
|
||||
if (!notification) return false;
|
||||
|
||||
notification.isRead = true;
|
||||
this.notifications.set(id, notification);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getUnreadNotificationCount(): Promise<number> {
|
||||
return Array.from(this.notifications.values()).filter(n => !n.isRead).length;
|
||||
}
|
||||
}
|
||||
|
||||
export const storage = new MemStorage();
|
||||
85
server/vite.ts
Normal file
85
server/vite.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import express, { type Express } from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { createServer as createViteServer, createLogger } from "vite";
|
||||
import { type Server } from "http";
|
||||
import viteConfig from "../vite.config";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const viteLogger = createLogger();
|
||||
|
||||
export function log(message: string, source = "express") {
|
||||
const formattedTime = new Date().toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
console.log(`${formattedTime} [${source}] ${message}`);
|
||||
}
|
||||
|
||||
export async function setupVite(app: Express, server: Server) {
|
||||
const serverOptions = {
|
||||
middlewareMode: true,
|
||||
hmr: { server },
|
||||
allowedHosts: true as const,
|
||||
};
|
||||
|
||||
const vite = await createViteServer({
|
||||
...viteConfig,
|
||||
configFile: false,
|
||||
customLogger: {
|
||||
...viteLogger,
|
||||
error: (msg, options) => {
|
||||
viteLogger.error(msg, options);
|
||||
process.exit(1);
|
||||
},
|
||||
},
|
||||
server: serverOptions,
|
||||
appType: "custom",
|
||||
});
|
||||
|
||||
app.use(vite.middlewares);
|
||||
app.use("*", async (req, res, next) => {
|
||||
const url = req.originalUrl;
|
||||
|
||||
try {
|
||||
const clientTemplate = path.resolve(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"client",
|
||||
"index.html",
|
||||
);
|
||||
|
||||
// always reload the index.html file from disk incase it changes
|
||||
let template = await fs.promises.readFile(clientTemplate, "utf-8");
|
||||
template = template.replace(
|
||||
`src="/src/main.tsx"`,
|
||||
`src="/src/main.tsx?v=${nanoid()}"`,
|
||||
);
|
||||
const page = await vite.transformIndexHtml(url, template);
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(page);
|
||||
} catch (e) {
|
||||
vite.ssrFixStacktrace(e as Error);
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function serveStatic(app: Express) {
|
||||
const distPath = path.resolve(import.meta.dirname, "public");
|
||||
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Could not find the build directory: ${distPath}, make sure to build the client first`,
|
||||
);
|
||||
}
|
||||
|
||||
app.use(express.static(distPath));
|
||||
|
||||
// fall through to index.html if the file doesn't exist
|
||||
app.use("*", (_req, res) => {
|
||||
res.sendFile(path.resolve(distPath, "index.html"));
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user