First template code generated from replit.com

This commit is contained in:
2025-08-28 19:26:17 +05:30
commit ba255ebd90
104 changed files with 20669 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1 @@
{"latest": "main"}

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

42
.replit Normal file
View 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
View 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
View 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
View 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
View 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;

View 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>
);
}

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
</>
);
}

View 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>
);
}

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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 }

View 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,
}

View 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 }

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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>
)
}

View 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 }

View 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 }

View 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 }

View 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,
};
}

View 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
View 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));
}

View 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;

View 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
View 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
View 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 />);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

105
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

97
replit.md Normal file
View 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
View 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
View 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
View 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
View 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