+
setOpen(false)}
+ />
+
+ {/* Sidebar */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Spacer */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/client/src/components/layout/notifications.tsx b/client/src/components/layout/notifications.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7392145adbbaf8fdd3f65ecaa9a0d66470d24a2d
--- /dev/null
+++ b/client/src/components/layout/notifications.tsx
@@ -0,0 +1,295 @@
+import React, { useState, useEffect } from 'react';
+import { Bell, Calendar, Wrench, AlertTriangle, FileText, CheckSquare } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+ DropdownMenuLabel,
+} from '@/components/ui/dropdown-menu';
+import { useEquipmentData } from '@/hooks/use-equipment-data';
+import { useRemarksData } from '@/hooks/use-remarks-data';
+import { useMaintenanceData } from '@/hooks/use-maintenance-data';
+import { useQuery } from '@tanstack/react-query';
+import { Link } from 'wouter';
+
+interface Notification {
+ id: string;
+ type: 'maintenance' | 'remark' | 'task' | 'warning' | 'info';
+ title: string;
+ description: string;
+ link?: string;
+ equipmentId?: string;
+ priority: 'high' | 'medium' | 'low';
+ createdAt: Date;
+}
+
+export function NotificationsDropdown() {
+ const { equipment } = useEquipmentData();
+ const { remarks } = useRemarksData();
+ const { maintenanceRecords, refreshData } = useMaintenanceData();
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ // Загрузка задач
+ const { data: tasks = [] } = useQuery({
+ queryKey: ['/api/tasks']
+ });
+
+ // Слушаем события изменения данных ТО и замечаний
+ useEffect(() => {
+ const handleDataChange = () => {
+ refreshData();
+ setRefreshKey(prev => prev + 1);
+ };
+
+ window.addEventListener('maintenanceDataChanged', handleDataChange);
+ window.addEventListener('remarksUpdated', handleDataChange);
+ window.addEventListener('remarkStatusChanged', handleDataChange);
+
+ return () => {
+ window.removeEventListener('maintenanceDataChanged', handleDataChange);
+ window.removeEventListener('remarksUpdated', handleDataChange);
+ window.removeEventListener('remarkStatusChanged', handleDataChange);
+ };
+ }, [refreshData]);
+
+ const generateNotifications = (): Notification[] => {
+ const notifications: Notification[] = [];
+ const today = new Date();
+ const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000);
+
+ // Уведомления о запланированных ТО из записей обслуживания
+ maintenanceRecords.forEach(record => {
+ if (record.status !== 'scheduled') return;
+
+ const scheduledDate = new Date(record.scheduledDate);
+ const daysUntil = Math.ceil((scheduledDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+
+ // Проверяем, что оборудование существует в системе
+ const equipmentItem = equipment.find(eq => eq.id === record.equipmentId);
+ if (!equipmentItem) return; // Пропускаем записи для несуществующего оборудования
+
+ const equipmentName = equipmentItem.name;
+
+ if (scheduledDate <= nextWeek && scheduledDate >= today) {
+ notifications.push({
+ id: `maintenance-${record.id}`,
+ type: 'maintenance',
+ title: 'Требуется ТО',
+ description: `${equipmentName} - ${record.maintenanceType} через ${daysUntil} дн.`,
+ link: '/maintenance',
+ equipmentId: record.equipmentId,
+ priority: daysUntil <= 3 ? 'high' : 'medium',
+ createdAt: today
+ });
+ } else if (scheduledDate < today) {
+ notifications.push({
+ id: `maintenance-overdue-${record.id}`,
+ type: 'warning',
+ title: 'Просрочено ТО',
+ description: `${equipmentName} - ${record.maintenanceType} просрочено на ${Math.abs(daysUntil)} дн.`,
+ link: '/maintenance',
+ equipmentId: record.equipmentId,
+ priority: 'high',
+ createdAt: today
+ });
+ }
+ });
+
+ // Уведомления о замечаниях (только открытые и в работе)
+ remarks.forEach(remark => {
+ if (remark.status === 'open' || remark.status === 'in_progress') {
+ const priority = remark.priority === 'critical' ? 'high' :
+ remark.priority === 'high' ? 'medium' : 'low';
+
+ notifications.push({
+ id: `remark-${remark.id}`,
+ type: 'remark',
+ title: remark.status === 'in_progress' ? 'Замечание в работе' : 'Открытое замечание',
+ description: `${remark.equipmentName} - ${remark.description.substring(0, 50)}...`,
+ link: '/tasks',
+ equipmentId: remark.equipmentId,
+ priority,
+ createdAt: new Date(remark.createdAt)
+ });
+ }
+ });
+
+ // Уведомления о задачах (только ожидающие и в работе)
+ tasks.forEach((task: any) => {
+ if (task.status === 'pending' || task.status === 'in_progress') {
+ const priority = task.priority === 'critical' ? 'high' :
+ task.priority === 'high' ? 'medium' : 'low';
+
+ const dueDate = task.dueDate ? new Date(task.dueDate) : null;
+ const isOverdue = dueDate && dueDate < today;
+ const daysDue = dueDate ? Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)) : null;
+
+ notifications.push({
+ id: `task-${task.id}`,
+ type: 'task',
+ title: task.status === 'in_progress' ? 'Задача в работе' : isOverdue ? 'Просроченная задача' : 'Новая задача',
+ description: daysDue !== null ?
+ (isOverdue ? `${task.title} - просрочена на ${Math.abs(daysDue)} дн.` :
+ daysDue <= 3 ? `${task.title} - до ${daysDue} дн.` : task.title) :
+ task.title,
+ link: '/tasks',
+ equipmentId: task.equipmentId,
+ priority: isOverdue ? 'high' : priority,
+ createdAt: new Date(task.createdAt)
+ });
+ }
+ });
+
+ // Оборудование требующее внимания (статус maintenance)
+ equipment.forEach(item => {
+ if (item.status === 'maintenance') {
+ notifications.push({
+ id: `equipment-maintenance-${item.id}`,
+ type: 'warning',
+ title: 'Оборудование на ТО',
+ description: `${item.name} - находится на техобслуживании`,
+ link: '/equipment',
+ equipmentId: item.id,
+ priority: 'medium',
+ createdAt: today
+ });
+ } else if (item.status === 'inactive') {
+ notifications.push({
+ id: `equipment-inactive-${item.id}`,
+ type: 'warning',
+ title: 'Оборудование не активно',
+ description: `${item.name} - требует проверки`,
+ link: '/equipment',
+ equipmentId: item.id,
+ priority: 'high',
+ createdAt: today
+ });
+ }
+ });
+
+ // Сортировка по приоритету и дате
+ return notifications.sort((a, b) => {
+ const priorityOrder = { high: 3, medium: 2, low: 1 };
+ if (priorityOrder[a.priority] !== priorityOrder[b.priority]) {
+ return priorityOrder[b.priority] - priorityOrder[a.priority];
+ }
+ return b.createdAt.getTime() - a.createdAt.getTime();
+ });
+ };
+
+ const notifications = generateNotifications();
+ const highPriorityCount = notifications.filter(n => n.priority === 'high').length;
+
+ const getNotificationIcon = (type: string) => {
+ switch (type) {
+ case 'maintenance':
+ return
;
+ case 'remark':
+ return
;
+ case 'task':
+ return
;
+ case 'warning':
+ return
;
+ default:
+ return
;
+ }
+ };
+
+ const getPriorityColor = (priority: string) => {
+ switch (priority) {
+ case 'high':
+ return 'text-red-600 dark:text-red-400';
+ case 'medium':
+ return 'text-yellow-600 dark:text-yellow-400';
+ default:
+ return 'text-gray-600 dark:text-gray-400';
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Уведомления
+ {notifications.length > 0 && (
+
+ {notifications.length}
+
+ )}
+
+
+
+ {notifications.length === 0 ? (
+
+ ) : (
+ notifications.map((notification) => (
+
+
+
+ {getNotificationIcon(notification.type)}
+
+
+
+
+ {notification.title}
+
+
+ {notification.priority === 'high' ? 'Срочно' :
+ notification.priority === 'medium' ? 'Важно' : 'Обычное'}
+
+
+
+ {notification.description}
+
+ {notification.equipmentId && (
+
+ ID: {notification.equipmentId}
+
+ )}
+
+
+
+ ))
+ )}
+
+ {notifications.length > 0 && (
+ <>
+
+
+
+ Все уведомления →
+
+
+ >
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/client/src/components/layout/sidebar.tsx b/client/src/components/layout/sidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7712582da9898752dab879505988e22001e7178c
--- /dev/null
+++ b/client/src/components/layout/sidebar.tsx
@@ -0,0 +1,236 @@
+import { Link, useLocation } from "wouter";
+import { useAuth } from "@/hooks/use-auth";
+import { useUserStatus } from "@/hooks/use-user-status";
+import { useSidebarState } from "@/hooks/use-sidebar-state";
+import { UserStatusSelector, getStatusBadge } from "@/components/layout/user-status";
+import { Button } from "@/components/ui/button";
+import { BarChart2, Users, Calendar, Wrench, FileText, Settings, CheckSquare, Clipboard, ChartBar, ClipboardCheck, CheckCircle } from "lucide-react";
+
+export function Sidebar() {
+ const [location] = useLocation();
+ const { user } = useAuth();
+ const { users, getCurrentUserStatus, setCurrentUserStatus } = useUserStatus();
+ const { isCollapsed, toggleCollapsed } = useSidebarState();
+
+
+
+ const navigation = [
+ {
+ section: "Основное",
+ items: [
+ {
+ name: "Панель управления",
+ href: "/dashboard",
+ icon:
,
+ active: location === "/dashboard" || location === "/",
+ },
+ {
+ name: "График ТО",
+ href: "/schedule",
+ icon:
,
+ active: location === "/schedule",
+ },
+ {
+ name: "Оборудование",
+ href: "/equipment",
+ icon:
,
+ active: location === "/equipment",
+ },
+ {
+ name: "Ежедневные осмотры",
+ href: "/daily-inspection",
+ icon:
,
+ active: location === "/daily-inspection",
+ },
+ {
+ name: "Техническое обслуживание",
+ href: "/maintenance",
+ icon:
,
+ active: location === "/maintenance",
+ },
+ {
+ name: "Задачи",
+ href: "/tasks",
+ icon:
,
+ active: location === "/tasks",
+ }
+ ]
+ },
+ {
+ section: "Администрирование",
+ items: [
+ {
+ name: "Пользователи",
+ href: "/users",
+ icon:
,
+ active: location === "/users",
+ },
+ {
+ name: "Отчеты",
+ href: "/reports",
+ icon:
,
+ active: location === "/reports",
+ }
+ ]
+ }
+ ];
+
+ // Получаем только пользователей со статусом "на работе" для отображения в боковой панели
+ const workingUsers = users.filter(user => user.status === 'working');
+ const displayUsers = workingUsers.slice(0, 5);
+
+ return (
+
+ );
+}
diff --git a/client/src/components/layout/user-status.tsx b/client/src/components/layout/user-status.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9e43a87ef48722788bad080c9442c776788a3c35
--- /dev/null
+++ b/client/src/components/layout/user-status.tsx
@@ -0,0 +1,163 @@
+import React, { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { User, Clock, Coffee, Plane, UserX, Wifi } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+
+export interface UserStatus {
+ id: string;
+ name: string;
+ icon: React.ReactNode;
+ color: string;
+ bgColor: string;
+}
+
+const userStatuses: UserStatus[] = [
+ {
+ id: "online",
+ name: "Онлайн",
+ icon:
,
+ color: "text-green-600",
+ bgColor: "bg-green-100 dark:bg-green-900/20"
+ },
+ {
+ id: "working",
+ name: "На работе",
+ icon:
,
+ color: "text-blue-600",
+ bgColor: "bg-blue-100 dark:bg-blue-900/20"
+ },
+ {
+ id: "break",
+ name: "На перерыве",
+ icon:
,
+ color: "text-yellow-600",
+ bgColor: "bg-yellow-100 dark:bg-yellow-900/20"
+ },
+ {
+ id: "vacation",
+ name: "В отпуске",
+ icon:
,
+ color: "text-purple-600",
+ bgColor: "bg-purple-100 dark:bg-purple-900/20"
+ },
+ {
+ id: "absent",
+ name: "Отсутствует",
+ icon:
,
+ color: "text-red-600",
+ bgColor: "bg-red-100 dark:bg-red-900/20"
+ },
+ {
+ id: "busy",
+ name: "Занят",
+ icon:
,
+ color: "text-orange-600",
+ bgColor: "bg-orange-100 dark:bg-orange-900/20"
+ }
+];
+
+interface UserStatusSelectorProps {
+ currentStatus: string;
+ onStatusChange: (statusId: string) => void;
+ userName: string;
+}
+
+export function UserStatusSelector({ currentStatus, onStatusChange, userName }: UserStatusSelectorProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const { toast } = useToast();
+
+ const handleStatusChange = (statusId: string) => {
+ onStatusChange(statusId);
+ setIsOpen(false);
+
+ const status = userStatuses.find(s => s.id === statusId);
+ toast({
+ title: "Статус обновлен",
+ description: `Ваш статус изменен на "${status?.name}"`,
+ });
+ };
+
+ const currentStatusObj = userStatuses.find(s => s.id === currentStatus) || userStatuses[0];
+
+ return (
+
+ );
+}
+
+export function getStatusBadge(statusId: string, darkMode: boolean = false) {
+ const status = userStatuses.find(s => s.id === statusId) || userStatuses[0];
+
+ if (darkMode) {
+ // Цвета для темного фона боковой панели
+ const darkColors = {
+ 'online': 'bg-green-500/20 text-green-300',
+ 'working': 'bg-blue-500/20 text-blue-300',
+ 'break': 'bg-yellow-500/20 text-yellow-300',
+ 'vacation': 'bg-purple-500/20 text-purple-300',
+ 'absent': 'bg-red-500/20 text-red-300',
+ 'busy': 'bg-orange-500/20 text-orange-300'
+ };
+
+ const colorClass = darkColors[status.id as keyof typeof darkColors] || darkColors.online;
+
+ return (
+
+ {status.icon}
+ {status.name}
+
+ );
+ }
+
+ return (
+
+ {status.icon}
+ {status.name}
+
+ );
+}
+
+export { userStatuses };
\ No newline at end of file
diff --git a/client/src/components/providers.tsx b/client/src/components/providers.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5c98efa3e3673a40ce2794ba7cf05efa71ffc30e
--- /dev/null
+++ b/client/src/components/providers.tsx
@@ -0,0 +1,50 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "@/lib/queryClient";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { Toaster } from "@/components/ui/toaster";
+import { useState, useEffect, createContext } from "react";
+
+type Theme = "light" | "dark";
+
+interface ThemeContextType {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+}
+
+export const ThemeContext = createContext
({
+ theme: "light",
+ setTheme: () => {},
+});
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ const [theme, setTheme] = useState("light");
+
+ useEffect(() => {
+ const storedTheme = localStorage.getItem("theme") as Theme | null;
+ if (storedTheme) {
+ setTheme(storedTheme);
+ } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
+ setTheme("dark");
+ }
+ }, []);
+
+ useEffect(() => {
+ localStorage.setItem("theme", theme);
+ if (theme === "dark") {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+ }, [theme]);
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/client/src/components/ui/accordion.tsx b/client/src/components/ui/accordion.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e6a723d06574ee5cec8b00759b98f3fbe1ac7cc9
--- /dev/null
+++ b/client/src/components/ui/accordion.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8722561cf6bda62d62f9a0c67730aefda971873a
--- /dev/null
+++ b/client/src/components/ui/alert-dialog.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/client/src/components/ui/alert.tsx b/client/src/components/ui/alert.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..41fa7e0561a3fdb5f986c1213a35e563de740e96
--- /dev/null
+++ b/client/src/components/ui/alert.tsx
@@ -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 & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/client/src/components/ui/aspect-ratio.tsx b/client/src/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c4abbf37f217c715a0eaade7f45ac78600df419f
--- /dev/null
+++ b/client/src/components/ui/aspect-ratio.tsx
@@ -0,0 +1,5 @@
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+const AspectRatio = AspectRatioPrimitive.Root
+
+export { AspectRatio }
diff --git a/client/src/components/ui/avatar.tsx b/client/src/components/ui/avatar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..51e507ba9d08bcdbb1fb630498f1cbdf2bf50093
--- /dev/null
+++ b/client/src/components/ui/avatar.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f000e3ef5176395b067dfc3f3e1256a80c450015
--- /dev/null
+++ b/client/src/components/ui/badge.tsx
@@ -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,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/client/src/components/ui/breadcrumb.tsx b/client/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..60e6c96f72f0350d08b47e4730cab8f3975dc853
--- /dev/null
+++ b/client/src/components/ui/breadcrumb.tsx
@@ -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) => )
+Breadcrumb.displayName = "Breadcrumb"
+
+const BreadcrumbList = React.forwardRef<
+ HTMLOListElement,
+ React.ComponentPropsWithoutRef<"ol">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbList.displayName = "BreadcrumbList"
+
+const BreadcrumbItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentPropsWithoutRef<"li">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbItem.displayName = "BreadcrumbItem"
+
+const BreadcrumbLink = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean
+ }
+>(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+
+ )
+})
+BreadcrumbLink.displayName = "BreadcrumbLink"
+
+const BreadcrumbPage = React.forwardRef<
+ HTMLSpanElement,
+ React.ComponentPropsWithoutRef<"span">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbPage.displayName = "BreadcrumbPage"
+
+const BreadcrumbSeparator = ({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) => (
+ svg]:w-3.5 [&>svg]:h-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+)
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
+
+const BreadcrumbEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More
+
+)
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..36496a28727a3643b4212a14225d4f6cbd50bda5
--- /dev/null
+++ b/client/src/components/ui/button.tsx
@@ -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,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/client/src/components/ui/calendar.tsx b/client/src/components/ui/calendar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2174f7101ba18ec1385eed03f69dbd619feb98aa
--- /dev/null
+++ b/client/src/components/ui/calendar.tsx
@@ -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
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ (
+
+ ),
+ IconRight: ({ className, ...props }) => (
+
+ ),
+ }}
+ {...props}
+ />
+ )
+}
+Calendar.displayName = "Calendar"
+
+export { Calendar }
diff --git a/client/src/components/ui/card.tsx b/client/src/components/ui/card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f62edea578d50058bef5e6bcc178b88d145564e9
--- /dev/null
+++ b/client/src/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/client/src/components/ui/carousel.tsx b/client/src/components/ui/carousel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9c2b9bf3705d8421bef00704c0c52e83d371ca11
--- /dev/null
+++ b/client/src/components/ui/carousel.tsx
@@ -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
+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[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+
+ return context
+}
+
+const Carousel = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & 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) => {
+ 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 (
+
+
+ {children}
+
+
+ )
+ }
+)
+Carousel.displayName = "Carousel"
+
+const CarouselContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselContent.displayName = "CarouselContent"
+
+const CarouselItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselItem.displayName = "CarouselItem"
+
+const CarouselPrevious = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselPrevious.displayName = "CarouselPrevious"
+
+const CarouselNext = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselNext.displayName = "CarouselNext"
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/client/src/components/ui/chart.tsx b/client/src/components/ui/chart.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..39fba6d6fc8509b968824b5076a8791c46650294
--- /dev/null
+++ b/client/src/components/ui/chart.tsx
@@ -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 }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ 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 (
+
+
+
+
+ {children}
+
+
+
+ )
+})
+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 (
+