diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..16c1d89c64f112a8a71fa0b71b50ff18aded3b35 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +generated-icon.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..f9ba7f8b4901d059a522d76272612aa2a8321bc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.DS_Store +server/public +vite.config.ts.* +*.tar.gz \ No newline at end of file diff --git a/.replit b/.replit new file mode 100644 index 0000000000000000000000000000000000000000..51f344ffac11f3c19cd01f940721e073a6ab4c1d --- /dev/null +++ b/.replit @@ -0,0 +1,36 @@ +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"] + +[[ports]] +localPort = 5000 +externalPort = 80 + +[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 diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000000000000000000000000000000000000..3d29af77ae4b565ca7ce494911a22b667ee0d7ba --- /dev/null +++ b/client/index.html @@ -0,0 +1,16 @@ + + + + + + MarketMetrics - Team Performance Dashboard + + + + +
+ + + + + diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..22e1819e729616799d9c4d1d3cd3da3ac884c548 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,126 @@ +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 Login from "@/pages/login"; +import Register from "@/pages/register"; +import Dashboard from "@/pages/dashboard"; +import Schedule from "@/pages/schedule"; +import Equipment from "@/pages/equipment"; +import Users from "@/pages/users"; +import Maintenance from "@/pages/maintenance"; +import Reports from "@/pages/reports"; +import DailyInspection from "@/pages/daily-inspection-new"; +import Remarks from "@/pages/remarks"; +import Profile from "@/pages/profile"; +import Tasks from "@/pages/tasks"; +import { MobileSidebarProvider } from "@/hooks/use-mobile-sidebar"; +import { AuthProvider } from "@/hooks/use-auth"; +import { UserStatusProvider } from "@/hooks/use-user-status"; +import { EquipmentProvider } from "@/hooks/use-equipment-data"; +import { RemarksProvider } from "@/hooks/use-remarks-data"; +import { MaintenanceProvider } from "@/hooks/use-maintenance-data"; +import { InspectionChecklistProvider } from "@/hooks/use-inspection-checklists"; +import { SidebarProvider } from "@/hooks/use-sidebar-state"; +import { ProtectedRoute } from "@/components/auth/protected-route"; + +function Router() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function App() { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/client/src/components/auth/login-form.tsx b/client/src/components/auth/login-form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..123f0ecd06b18d86df98b8d49ea6924fe0d5b583 --- /dev/null +++ b/client/src/components/auth/login-form.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { login } from "@/lib/auth"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/hooks/use-auth"; +import { Link, useLocation } from "wouter"; +import { Eye, EyeOff, LogIn } from "lucide-react"; + +const loginSchema = z.object({ + email: z.string().email("Пожалуйста, введите корректный email"), + password: z.string().min(6, "Пароль должен содержать минимум 6 символов"), +}); + +type LoginFormValues = z.infer; + +export function LoginForm() { + const [isLoading, setIsLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const { toast } = useToast(); + const { refreshAuth } = useAuth(); + const [_, setLocation] = useLocation(); + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + const onSubmit = async (data: LoginFormValues) => { + setIsLoading(true); + try { + await login(data.email, data.password); + // Принудительно обновляем состояние аутентификации + refreshAuth(); + toast({ + title: "Вход выполнен", + description: "Вы успешно вошли в систему", + }); + // Небольшая задержка для обновления состояния + setTimeout(() => { + setLocation("/dashboard"); + }, 100); + } catch (error: any) { + console.error("Ошибка входа:", error); + toast({ + title: "Ошибка входа", + description: error.message || "Пожалуйста, проверьте ваши данные и попробуйте снова", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Вход в систему + + Введите ваши данные для доступа к аккаунту + + + +
+ + ( + + Email + + + + + + )} + /> + + ( + + Пароль + +
+ + +
+
+ +
+ )} + /> + + + + +
+ +

+ Нет аккаунта?{" "} + + Зарегистрироваться + +

+
+
+ ); +} diff --git a/client/src/components/auth/protected-route.tsx b/client/src/components/auth/protected-route.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0cf21c1e8ce19442850fc4d98cdc6b3fe232d296 --- /dev/null +++ b/client/src/components/auth/protected-route.tsx @@ -0,0 +1,41 @@ +import { useAuth } from "@/hooks/use-auth"; +import { useLocation } from "wouter"; +import { useEffect } from "react"; +import { getToken } from "@/lib/auth"; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { user, isLoading } = useAuth(); + const [, setLocation] = useLocation(); + const token = getToken(); + + useEffect(() => { + // Простая проверка - есть токен или нет + if (!token) { + setLocation("/login"); + } + }, [token, setLocation]); + + // Если нет токена, перенаправляем + if (!token) { + return null; + } + + // Показываем загрузку только при первоначальной загрузке пользователя + if (isLoading && !user) { + return ( +
+
+
+

Загрузка...

+
+
+ ); + } + + // Рендерим контент если есть токен + return <>{children}; +} \ No newline at end of file diff --git a/client/src/components/auth/register-form.tsx b/client/src/components/auth/register-form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..050a8a19bba8fbce85b4cb2d9c133ae948a54839 --- /dev/null +++ b/client/src/components/auth/register-form.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { register } from "@/lib/auth"; +import { useToast } from "@/hooks/use-toast"; +import { Link, useLocation } from "wouter"; +import { Eye, EyeOff, UserPlus } from "lucide-react"; + +const registerSchema = z.object({ + name: z.string().min(2, "Имя должно содержать минимум 2 символа"), + email: z.string().email("Пожалуйста, введите корректный email"), + password: z.string().min(6, "Пароль должен содержать минимум 6 символов"), + confirmPassword: z.string(), +}).refine(data => data.password === data.confirmPassword, { + message: "Пароли не совпадают", + path: ["confirmPassword"], +}); + +type RegisterFormValues = z.infer; + +export function RegisterForm() { + const [isLoading, setIsLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const { toast } = useToast(); + const [_, setLocation] = useLocation(); + + const form = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + }, + }); + + const onSubmit = async (data: RegisterFormValues) => { + setIsLoading(true); + try { + await register(data.name, data.email, data.password); + toast({ + title: "Регистрация успешна", + description: "Ваш аккаунт был создан", + }); + setLocation("/dashboard"); + } catch (error: any) { + console.error("Ошибка регистрации:", error); + toast({ + title: "Ошибка регистрации", + description: error.message || "Пожалуйста, проверьте введенные данные и попробуйте снова", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Создание аккаунта + + Введите ваши данные для создания аккаунта + + + +
+ + ( + + Имя + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Пароль + +
+ + +
+
+ +
+ )} + /> + + ( + + Подтверждение пароля + + + + + + )} + /> + + + + +
+ +

+ Уже есть аккаунт?{" "} + + Войти + +

+
+
+ ); +} diff --git a/client/src/components/auth/role-guard.tsx b/client/src/components/auth/role-guard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..056ef1cf4c191d8567f2a62af2f2dea7621d4f58 --- /dev/null +++ b/client/src/components/auth/role-guard.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Shield, Lock } from "lucide-react"; + +interface RoleGuardProps { + allowedRoles: string[]; + children: React.ReactNode; + fallback?: React.ReactNode; + showMessage?: boolean; +} + +export function RoleGuard({ allowedRoles, children, fallback, showMessage = true }: RoleGuardProps) { + const { user } = useAuth(); + + if (!user) { + return fallback || (showMessage ? ( + + + + Необходимо войти в систему для доступа к этому разделу. + + + ) : null); + } + + if (!allowedRoles.includes(user.role)) { + return fallback || (showMessage ? ( + + + + У вас недостаточно прав для доступа к этому разделу. + Текущая роль: {getRoleDisplayName(user.role)}. + Обратитесь к администратору для получения дополнительных прав. + + + ) : null); + } + + return <>{children}; +} + +// Компонент для скрытия элементов интерфейса на основе ролей +interface RoleBasedProps { + allowedRoles: string[]; + children: React.ReactNode; +} + +export function RoleBased({ allowedRoles, children }: RoleBasedProps) { + const { user } = useAuth(); + + if (!user || !allowedRoles.includes(user.role)) { + return null; + } + + return <>{children}; +} + +// Хук для проверки прав доступа +export function usePermissions() { + const { user } = useAuth(); + + const hasRole = (role: string) => { + return user?.role === role; + }; + + const hasAnyRole = (roles: string[]) => { + return user && roles.includes(user.role); + }; + + const canView = () => { + return hasAnyRole(['admin', 'operator', 'engineer', 'viewer']); + }; + + const canEdit = () => { + return hasAnyRole(['admin', 'operator', 'engineer']); + }; + + const canCreate = () => { + return hasAnyRole(['admin', 'operator', 'engineer']); + }; + + const canDelete = () => { + return hasAnyRole(['admin', 'operator']); + }; + + const canManageUsers = () => { + return hasRole('admin'); + }; + + const canManageSystem = () => { + return hasRole('admin'); + }; + + return { + hasRole, + hasAnyRole, + canView, + canEdit, + canCreate, + canDelete, + canManageUsers, + canManageSystem, + currentRole: user?.role || 'guest' + }; +} + +export function getRoleDisplayName(role: string): string { + switch (role) { + case 'admin': + return 'Администратор'; + case 'operator': + return 'Оператор'; + case 'engineer': + return 'Инженер'; + case 'viewer': + return 'Просмотр'; + default: + return 'Неизвестная роль'; + } +} + +export function getRoleDescription(role: string): string { + switch (role) { + case 'admin': + return 'Полный доступ ко всем функциям системы'; + case 'operator': + return 'Может выполнять осмотры, создавать отчеты, управлять оборудованием'; + case 'engineer': + return 'Может просматривать данные, выполнять ежедневные осмотры'; + case 'viewer': + return 'Только просмотр данных без возможности изменений'; + default: + return 'Описание роли недоступно'; + } +} \ No newline at end of file diff --git a/client/src/components/dashboard/campaign-performance.tsx b/client/src/components/dashboard/campaign-performance.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b4610131b362be8475fca3c64715ea1f90a0dd5d --- /dev/null +++ b/client/src/components/dashboard/campaign-performance.tsx @@ -0,0 +1,67 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { useQuery } from "@tanstack/react-query"; + +interface Campaign { + id: number; + name: string; + progress: number; +} + +export function CampaignPerformance() { + const { data: campaigns } = useQuery({ + queryKey: ["/api/campaigns"], + }); + + const getCampaignColorClass = (progress: number) => { + if (progress >= 85) return "bg-success-500 dark:bg-success-600"; + if (progress >= 60) return "bg-primary-500 dark:bg-primary-600"; + return "bg-warning-500 dark:bg-warning-600"; + }; + + return ( + + + Эффективность кампаний + + +
+ {campaigns ? ( + campaigns.map((campaign) => ( +
+
+ {campaign.name} + {campaign.progress}% +
+
+
+
+
+ )) + ) : ( + Array(4) + .fill(0) + .map((_, i) => ( +
+
+
+
+
+
+
+ )) + )} +
+ +
+ +
+
+
+ ); +} diff --git a/client/src/components/dashboard/metric-card.tsx b/client/src/components/dashboard/metric-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1c921cb1ac966682183aca14567424906a53dca4 --- /dev/null +++ b/client/src/components/dashboard/metric-card.tsx @@ -0,0 +1,74 @@ +import { BarChart2, Briefcase, CheckCircle, Wrench, AlertTriangle, Settings } from "lucide-react"; + +interface MetricCardProps { + title: string; + value: string | number; + change: number; + icon: string; +} + +export function MetricCard({ title, value, change, icon }: MetricCardProps) { + const isPositive = change >= 0; + const iconClasses = { + equipment: { + bg: "bg-blue-100", + text: "text-blue-600", + icon: , + progress: 100, + }, + completed: { + bg: "bg-green-100", + text: "text-green-600", + icon: , + progress: 67, + }, + overdue: { + bg: "bg-yellow-100", + text: "text-yellow-600", + icon: , + progress: 7, + }, + repairs: { + bg: "bg-red-100", + text: "text-red-600", + icon: , + progress: 12, + }, + }; + + const iconType = icon in iconClasses ? icon : "equipment"; + const { bg, text, icon: iconComponent, progress } = iconClasses[iconType as keyof typeof iconClasses]; + + return ( +
+
+
+

{title}

+
+

{value}

+

+ + {Math.abs(change)}% +

+
+

по сравнению с прошлым месяцем

+
+
+ {iconComponent} +
+
+
+
+
Прогресс
+
{progress}%
+
+
+
+
+
+
+ ); +} diff --git a/client/src/components/dashboard/role-access-control.tsx b/client/src/components/dashboard/role-access-control.tsx new file mode 100644 index 0000000000000000000000000000000000000000..81de78cacfd3e3daaff9760eadb8d093f788b2ec --- /dev/null +++ b/client/src/components/dashboard/role-access-control.tsx @@ -0,0 +1,111 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { useQuery } from "@tanstack/react-query"; +import { CheckCircle, XCircle, Edit, PlusCircle } from "lucide-react"; + +interface Role { + id: number; + name: string; + description: string; + permissions: string[]; +} + +export function RoleAccessControl() { + const { data: roles } = useQuery({ + queryKey: ["/api/roles"], + }); + + // Mock roles if API data not available yet + const mockRoles = [ + { + id: 1, + name: "Администратор", + description: "Полный доступ ко всем функциям", + permissions: ["dashboard", "tasks", "team", "reports", "settings"], + }, + { + id: 2, + name: "Маркетинг-менеджер", + description: "Может управлять кампаниями и просматривать отчеты", + permissions: ["dashboard", "tasks", "team", "reports"], + }, + { + id: 3, + name: "Контент-создатель", + description: "Создает и редактирует контент для кампаний", + permissions: ["dashboard", "tasks"], + }, + ]; + + const displayRoles = roles || mockRoles; + const allPermissions = ["dashboard", "tasks", "team", "reports", "settings"]; + + return ( + + +
+ Управление доступом на основе ролей +
+ + +
+
+
+ +
+ + + + + + {allPermissions.map((permission) => ( + + ))} + + + + + {displayRoles.map((role) => ( + + + + {allPermissions.map((permission) => ( + + ))} + + + ))} + +
РольОписание + {permission === "dashboard" ? "Панель" : + permission === "tasks" ? "Задачи" : + permission === "team" ? "Команда" : + permission === "reports" ? "Отчеты" : + permission === "settings" ? "Настройки" : + permission} +
+
{role.name}
+
{role.description} + {role.permissions.includes(permission) ? ( + + ) : ( + + )} + + +
+
+
+
+ ); +} diff --git a/client/src/components/dashboard/team-activity.tsx b/client/src/components/dashboard/team-activity.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c736e1d1ce167c7adce24598aafc5b254f72433d --- /dev/null +++ b/client/src/components/dashboard/team-activity.tsx @@ -0,0 +1,122 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { useQuery } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; + +interface TeamMember { + id: number; + name: string; + avatar: string; +} + +interface Activity { + id: number; + userId: number; + action: string; + timestamp: string; + user?: TeamMember; +} + +export function TeamActivity() { + const { data: activities } = useQuery({ + queryKey: ["/api/activities"], + }); + + // Generate initial for avatar display + const getInitials = (name: string) => { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase(); + }; + + // Get background color based on initials + const getAvatarColor = (initials: string) => { + const colors = { + JD: "bg-primary-100 text-primary-600 dark:bg-primary-900 dark:text-primary-300", + MS: "bg-secondary-100 text-secondary-500 dark:bg-secondary-900 dark:text-secondary-300", + AR: "bg-yellow-100 text-yellow-600 dark:bg-yellow-900 dark:text-yellow-300", + AM: "bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-300", + }; + return initials in colors + ? colors[initials as keyof typeof colors] + : "bg-primary-100 text-primary-600 dark:bg-primary-900 dark:text-primary-300"; + }; + + // Mock data in case API isn't available yet + const mockActivities = [ + { + id: 1, + user: { id: 1, name: "Елена Иванова", avatar: "ЕИ" }, + action: "Завершила настройку кампании \"Летняя акция\"", + timestamp: "2023-08-30T12:30:00Z", + }, + { + id: 2, + user: { id: 2, name: "Максим Петров", avatar: "МП" }, + action: "Добавил 3 новые задачи в кампанию \"Квартальная рассылка\"", + timestamp: "2023-08-30T10:15:00Z", + }, + { + id: 3, + user: { id: 3, name: "Анна Смирнова", avatar: "АС" }, + action: "Обновила стратегию социальных медиа", + timestamp: "2023-08-29T15:45:00Z", + }, + { + id: 4, + user: { id: 4, name: "Алексей Морозов", avatar: "АМ" }, + action: "Создал новый отчет о производительности за 3 квартал", + timestamp: "2023-08-29T09:12:00Z", + }, + { + id: 5, + user: { id: 1, name: "Елена Иванова", avatar: "ЕИ" }, + action: "Завершила 12 задач в кампании \"Запуск продукта\"", + timestamp: "2023-08-28T14:20:00Z", + }, + ]; + + const displayActivities = activities || mockActivities; + + return ( + + + Активность команды + + +
+
    + {displayActivities.map((activity) => ( +
  • +
    +
    +
    + {activity.user?.avatar || getInitials(activity.user?.name || "")} +
    +
    +
    +
    {activity.user?.name}
    +
    {activity.action}
    +
    + {formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true })} +
    +
    +
    +
  • + ))} +
+
+ +
+
+
+ ); +} diff --git a/client/src/components/dashboard/team-member-performance.tsx b/client/src/components/dashboard/team-member-performance.tsx new file mode 100644 index 0000000000000000000000000000000000000000..02d37984f9551230f58e0bc9716e4cad64e800e4 --- /dev/null +++ b/client/src/components/dashboard/team-member-performance.tsx @@ -0,0 +1,167 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "wouter"; +import { Download, Filter } from "lucide-react"; + +interface TeamMember { + id: number; + name: string; + email: string; + role: string; + avatar: string; + metrics: { + tasksCompleted: number; + tasksTotal: number; + onTimeRate: number; + productivityScore: number; + }; +} + +export function TeamMemberPerformance() { + const { data: teamMembers } = useQuery({ + queryKey: ["/api/users"], + }); + + const getRatingLabel = (score: number) => { + if (score >= 85) return { label: "Высокий", className: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300" }; + if (score >= 70) return { label: "Средний", className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300" }; + return { label: "Низкий", className: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300" }; + }; + + const getProgressColor = (score: number) => { + if (score >= 85) return "bg-success-500 dark:bg-success-600"; + if (score >= 70) return "bg-yellow-500 dark:bg-yellow-600"; + return "bg-red-500 dark:bg-red-600"; + }; + + // Use mock data if API data not available yet + const mockTeamMembers = [ + { + id: 1, + name: "Jane Doe", + email: "jane.doe@example.com", + role: "Marketing Manager", + avatar: "JD", + metrics: { + tasksCompleted: 24, + tasksTotal: 30, + onTimeRate: 92, + productivityScore: 90, + }, + }, + { + id: 2, + name: "Mike Smith", + email: "mike.smith@example.com", + role: "Content Creator", + avatar: "MS", + metrics: { + tasksCompleted: 18, + tasksTotal: 25, + onTimeRate: 78, + productivityScore: 72, + }, + }, + { + id: 3, + name: "Anna Roberts", + email: "anna.roberts@example.com", + role: "Social Media Specialist", + avatar: "AR", + metrics: { + tasksCompleted: 32, + tasksTotal: 35, + onTimeRate: 94, + productivityScore: 95, + }, + }, + ]; + + const displayTeamMembers = teamMembers || mockTeamMembers; + + return ( + + + Эффективность сотрудников +
+ + +
+
+
+ + + + + + + + + + + + + {displayTeamMembers.map((member) => { + const rating = getRatingLabel(member.metrics.onTimeRate); + const progressColor = getProgressColor(member.metrics.productivityScore); + + return ( + + + + + + + + + ); + })} + +
СотрудникРольВыполнено задачСвоевременностьПродуктивность
+
+
+ {member.avatar} +
+
+
{member.name}
+
{member.email}
+
+
+
+
{member.role}
+
+
{member.metrics.tasksCompleted}/{member.metrics.tasksTotal}
+
+
+
{member.metrics.onTimeRate}%
+ + {rating.label} + +
+
+
+
+
+
{member.metrics.productivityScore}/100
+
+ + View + +
+
+
+ +
+
+ ); +} diff --git a/client/src/components/dashboard/team-performance-chart.tsx b/client/src/components/dashboard/team-performance-chart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c29b6dae8b32bad89451bf0cd952e6a5c2fc2ff1 --- /dev/null +++ b/client/src/components/dashboard/team-performance-chart.tsx @@ -0,0 +1,62 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export function TeamPerformanceChart() { + const months = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн"]; + const heights = [40, 65, 50, 75, 60, 80]; + const colors = [ + "bg-primary-100 dark:bg-primary-900/30", + "bg-primary-200 dark:bg-primary-800/40", + "bg-primary-300 dark:bg-primary-700/50", + "bg-primary-400 dark:bg-primary-600/60", + "bg-primary-500 dark:bg-primary-500/70", + "bg-primary-600 dark:bg-primary-400/80", + ]; + + return ( + + + Динамика эффективности команды +
+ + + +
+
+ +
+ {months.map((month, index) => ( +
+
+ {month} +
+ ))} +
+
+
Среднее: 75%
+
Цель: 90%
+
+
+
+ ); +} diff --git a/client/src/components/equipment-form.tsx b/client/src/components/equipment-form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ce70ebf30d35ec3d578c4099476bdb5f7f520f2b --- /dev/null +++ b/client/src/components/equipment-form.tsx @@ -0,0 +1,254 @@ +import { useState, useCallback, useEffect } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; + +interface EquipmentFormProps { + initialData?: any; + onSave: (data: any) => void; + onCancel: () => void; + isEdit?: boolean; +} + +const equipmentTypes = [ + "Фрезерный станок", + "Токарный станок", + "Шлифовальный станок", + "Сверлильный станок", + "Гравировальный станок", + "Плазменная резка", + "Лазерная резка", + "Гибочный станок", + "Прессовое оборудование", + "Сварочное оборудование", + "Другое" +]; + +const responsibleOptions = [ + "Иванов И.И.", + "Петров П.П.", + "Сидоров С.С.", + "Калюжный Никита" +]; + +export default function EquipmentForm({ initialData, onSave, onCancel, isEdit = false }: EquipmentFormProps) { + const [formData, setFormData] = useState({ + id: "", + name: "", + type: "", + description: "", + status: "active", + lastMaintenance: "", + nextMaintenance: "", + responsible: "", + maintenancePeriods: [] as string[] + }); + + const [customType, setCustomType] = useState(""); + const [isCustomType, setIsCustomType] = useState(false); + + useEffect(() => { + if (initialData) { + setFormData({ + id: initialData.id || "", + name: initialData.name || "", + type: initialData.type || "", + description: initialData.description || "", + status: initialData.status || "active", + lastMaintenance: initialData.lastMaintenance || "", + nextMaintenance: initialData.nextMaintenance || "", + responsible: initialData.responsible || "", + maintenancePeriods: initialData.maintenancePeriods || [] + }); + + const isCustom = !equipmentTypes.includes(initialData.type || ""); + setIsCustomType(isCustom); + if (isCustom) { + setCustomType(initialData.type || ""); + } + } + }, [initialData]); + + const handleFieldChange = useCallback((field: string, value: string) => { + setFormData(prev => ({ + ...prev, + [field]: value + })); + }, []); + + const handleNameChange = useCallback((e: React.ChangeEvent) => { + setFormData(prev => ({ ...prev, name: e.target.value })); + }, []); + + const handleDescriptionChange = useCallback((e: React.ChangeEvent) => { + setFormData(prev => ({ ...prev, description: e.target.value })); + }, []); + + const handleCustomTypeChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + setCustomType(value); + setFormData(prev => ({ ...prev, type: value })); + }, []); + + const handleMaintenancePeriodChange = useCallback((period: string, checked: boolean) => { + setFormData(prev => ({ + ...prev, + maintenancePeriods: checked + ? [...prev.maintenancePeriods, period] + : prev.maintenancePeriods.filter(p => p !== period) + })); + }, []); + + const handleSave = () => { + onSave(formData); + }; + + const maintenancePeriods = ["1М - ТО", "3М - ТО", "6М - ТО", "1Г - ТО"]; + + return ( +
+
+
+ + +
+
+ +
+
+ { + setIsCustomType(e.target.checked); + if (!e.target.checked) { + setCustomType(""); + handleFieldChange('type', ''); + } + }} + className="h-4 w-4 text-blue-600 border-gray-300 rounded" + /> + +
+ + {isCustomType ? ( + + ) : ( + + )} +
+
+
+ +
+ +