From 7e7e4a9b4a93b40d73a37ecc8f9baf2ff9743929 Mon Sep 17 00:00:00 2001 From: Bivekich Date: Fri, 18 Jul 2025 11:00:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D1=83?= =?UTF-8?q?=D0=B5=D0=BC=D1=8B=D0=B5=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B8=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2,=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20=D0=B8=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C.=20=D0=9E=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC=D0=B8,?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D1=84=D0=B5=D0=B9=D1=81=D1=8B=20=D0=B8=20=D1=82=D0=B8=D0=BF?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2.=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=81?= =?UTF-8?q?=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20GraphQL,=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BD=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=20=D0=B2=D0=B7=D0=B0=D0=B8=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=8F=20=D1=81=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/cart/cart-items.tsx | 1 - src/components/cart/cart-summary.tsx | 4 +- src/components/dashboard/dashboard-home.tsx | 17 +- .../employees/employee-edit-inline-form.tsx | 15 +- src/components/employees/employee-form.tsx | 2 +- .../employees/employee-inline-form.tsx | 19 +- .../employees/employee-schedule.tsx | 40 +- .../employees/employees-dashboard.tsx | 431 +++++++++++++----- src/components/employees/employees-list.tsx | 60 +-- src/components/market/market-products.tsx | 4 +- src/components/messenger/messenger-chat.tsx | 2 +- src/components/ui/file-uploader.tsx | 1 + src/components/ui/voice-player.tsx | 3 +- src/components/warehouse/product-card.tsx | 7 +- src/components/warehouse/product-form.tsx | 9 +- src/graphql/queries.ts | 15 + src/graphql/resolvers.ts | 121 ++++- src/graphql/typedefs.ts | 3 + src/services/s3-service.ts | 1 - 19 files changed, 507 insertions(+), 248 deletions(-) diff --git a/src/components/cart/cart-items.tsx b/src/components/cart/cart-items.tsx index cf61dd3..eee6181 100644 --- a/src/components/cart/cart-items.tsx +++ b/src/components/cart/cart-items.tsx @@ -155,7 +155,6 @@ export function CartItems({ cart }: CartItemsProps) { } const unavailableItems = cart.items.filter(item => !item.isAvailable) - const availableItems = cart.items.filter(item => item.isAvailable) // Группировка товаров по поставщикам const groupedItems = cart.items.reduce((groups, item) => { diff --git a/src/components/cart/cart-summary.tsx b/src/components/cart/cart-summary.tsx index e73a5bf..6338ed8 100644 --- a/src/components/cart/cart-summary.tsx +++ b/src/components/cart/cart-summary.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' + import { Separator } from '@/components/ui/separator' import { ShoppingCart, @@ -131,7 +131,7 @@ export function CartSummary({ cart }: CartSummaryProps) {
- {Object.values(sellerGroups).map((group, index) => ( + {Object.values(sellerGroups).map((group) => (
{ - if (!user?.organization?.type) return 'кабинета' - - switch (user.organization.type) { - case 'FULFILLMENT': - return 'фулфилмент кабинета' - case 'SELLER': - return 'селлер кабинета' - case 'LOGIST': - return 'логистического кабинета' - case 'WHOLESALE': - return 'оптового кабинета' - default: - return 'кабинета' - } - } + return (
diff --git a/src/components/employees/employee-edit-inline-form.tsx b/src/components/employees/employee-edit-inline-form.tsx index 170cb5d..73551b6 100644 --- a/src/components/employees/employee-edit-inline-form.tsx +++ b/src/components/employees/employee-edit-inline-form.tsx @@ -1,6 +1,7 @@ "use client" -import { useState, useRef, useEffect } from 'react' +import { useState, useRef } from 'react' +import Image from 'next/image' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -289,9 +290,11 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
{formData.passportPhoto ? (
- Паспорт setShowPassportPreview(true)} /> @@ -546,9 +549,11 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = Фото паспорта
- Паспорт
diff --git a/src/components/employees/employee-form.tsx b/src/components/employees/employee-form.tsx index 7f7d467..83c7d97 100644 --- a/src/components/employees/employee-form.tsx +++ b/src/components/employees/employee-form.tsx @@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Card } from '@/components/ui/card' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' -import { Upload, X, User, Camera } from 'lucide-react' +import { User, Camera } from 'lucide-react' import { toast } from 'sonner' interface Employee { diff --git a/src/components/employees/employee-inline-form.tsx b/src/components/employees/employee-inline-form.tsx index 5d6ca77..75b8dca 100644 --- a/src/components/employees/employee-inline-form.tsx +++ b/src/components/employees/employee-inline-form.tsx @@ -1,12 +1,13 @@ "use client" import { useState, useRef } from 'react' +import Image from 'next/image' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' -import { Badge } from '@/components/ui/badge' + import { Separator } from '@/components/ui/separator' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { @@ -19,7 +20,7 @@ import { Mail, Briefcase, DollarSign, - Calendar, + FileText, MessageCircle } from 'lucide-react' @@ -251,9 +252,11 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
{formData.passportPhoto ? (
- Паспорт setShowPassportPreview(true)} /> @@ -508,9 +511,11 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl Фото паспорта
- Паспорт
diff --git a/src/components/employees/employee-schedule.tsx b/src/components/employees/employee-schedule.tsx index 102886f..c6a5b56 100644 --- a/src/components/employees/employee-schedule.tsx +++ b/src/components/employees/employee-schedule.tsx @@ -4,13 +4,13 @@ import { useState } from 'react' import { Card } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Badge } from '@/components/ui/badge' + import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { Calendar, ChevronLeft, ChevronRight, - User, + CheckCircle, XCircle, Plane, @@ -164,17 +164,17 @@ export function EmployeeSchedule({ employees }: EmployeeScheduleProps) { const getCellStyle = (status: WorkDayStatus) => { switch (status) { case 'work': - return 'bg-green-500/20 text-green-300 border-green-500/30' + return 'bg-emerald-500/20 text-emerald-300/70 border-emerald-400/80' case 'weekend': - return 'bg-gray-500/20 text-gray-300 border-gray-500/30' + return 'bg-purple-500/20 text-purple-300/70 border-purple-400/80' case 'vacation': - return 'bg-blue-500/20 text-blue-300 border-blue-500/30' + return 'bg-blue-500/20 text-blue-300/70 border-blue-400/80' case 'sick': - return 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' + return 'bg-yellow-500/20 text-yellow-300/70 border-yellow-400/80' case 'absent': - return 'bg-red-500/20 text-red-300 border-red-500/30' + return 'bg-red-500/20 text-red-300/70 border-red-400/80' default: - return 'bg-white/10 text-white/70 border-white/20' + return 'bg-white/10 text-white/50 border-white/20' } } @@ -197,16 +197,16 @@ export function EmployeeSchedule({ employees }: EmployeeScheduleProps) { } // Получить название статуса - const getStatusName = (status: WorkDayStatus) => { - switch (status) { - case 'work': return 'Рабочий' - case 'weekend': return 'Выходной' - case 'vacation': return 'Отпуск' - case 'sick': return 'Больничный' - case 'absent': return 'Прогул' - default: return 'Неизвестно' - } - } + // const getStatusName = (status: WorkDayStatus) => { + // switch (status) { + // case 'work': return 'Рабочий' + // case 'weekend': return 'Выходной' + // case 'vacation': return 'Отпуск' + // case 'sick': return 'Больничный' + // case 'absent': return 'Прогул' + // default: return 'Неизвестно' + // } + // } // Создаем массив дней для отображения const calendarDays: (number | null)[] = [] @@ -224,8 +224,8 @@ export function EmployeeSchedule({ employees }: EmployeeScheduleProps) { // Получить статистику для сотрудника за месяц const getMonthStats = (employeeId: string) => { - const monthKey = `${currentYear}-${currentMonth}` - const schedule = schedules[`${employeeId}-${monthKey}`] + // const monthKey = `${currentYear}-${currentMonth}` + // const schedule = schedules[`${employeeId}-${monthKey}`] let workDays = 0 let vacationDays = 0 diff --git a/src/components/employees/employees-dashboard.tsx b/src/components/employees/employees-dashboard.tsx index 1cfc027..ad3722f 100644 --- a/src/components/employees/employees-dashboard.tsx +++ b/src/components/employees/employees-dashboard.tsx @@ -1,20 +1,20 @@ "use client" -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useQuery, useMutation } from '@apollo/client' +import { apolloClient } from '@/lib/apollo-client' import { Sidebar } from '@/components/dashboard/sidebar' import { Card } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Input } from '@/components/ui/input' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { EmployeeForm } from './employee-form' +// import { EmployeeForm } from './employee-form' import { EmployeeInlineForm } from './employee-inline-form' import { EmployeeEditInlineForm } from './employee-edit-inline-form' import { toast } from 'sonner' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' -import { GET_MY_EMPLOYEES } from '@/graphql/queries' +import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries' import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations' import { Users, @@ -32,7 +32,11 @@ import { XCircle, Plane, Activity, - Clock + Clock, + Briefcase, + MapPin, + AlertCircle, + MessageCircle } from 'lucide-react' // Интерфейс сотрудника @@ -70,16 +74,63 @@ export function EmployeesDashboard() { const [createLoading, setCreateLoading] = useState(false) const [editingEmployee, setEditingEmployee] = useState(null) const [deletingEmployeeId, setDeletingEmployeeId] = useState(null) + const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({}) + const [currentYear] = useState(new Date().getFullYear()) + const [currentMonth] = useState(new Date().getMonth()) + + interface ScheduleRecord { + id: string + date: string + status: string + hoursWorked?: number + employee: { + id: string + } + } // GraphQL запросы и мутации const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES) const [createEmployee] = useMutation(CREATE_EMPLOYEE) const [updateEmployee] = useMutation(UPDATE_EMPLOYEE) const [deleteEmployee] = useMutation(DELETE_EMPLOYEE) + const [updateEmployeeSchedule] = useMutation(UPDATE_EMPLOYEE_SCHEDULE) - const employees = data?.myEmployees || [] + const employees = useMemo(() => data?.myEmployees || [], [data?.myEmployees]) + // Загружаем данные табеля для всех сотрудников + useEffect(() => { + const loadScheduleData = async () => { + if (employees.length > 0) { + const schedulePromises = employees.map(async (employee: Employee) => { + try { + const { data } = await apolloClient.query({ + query: GET_EMPLOYEE_SCHEDULE, + variables: { + employeeId: employee.id, + year: currentYear, + month: currentMonth + } + }) + return { employeeId: employee.id, scheduleData: data?.employeeSchedule || [] } + } catch (error) { + console.error(`Error loading schedule for ${employee.id}:`, error) + return { employeeId: employee.id, scheduleData: [] } + } + }) + const results = await Promise.all(schedulePromises) + const scheduleMap: {[key: string]: ScheduleRecord[]} = {} + results.forEach((result: { employeeId: string; scheduleData: ScheduleRecord[] }) => { + if (result && result.scheduleData) { + scheduleMap[result.employeeId] = result.scheduleData + } + }) + setEmployeeSchedules(scheduleMap) + } + } + + loadScheduleData() + }, [employees, currentYear, currentMonth]) const handleEditEmployee = (employee: Employee) => { setEditingEmployee(employee) @@ -156,6 +207,72 @@ export function EmployeesDashboard() { } } + // Функция для изменения статуса дня в табеле + const changeDayStatus = async (employeeId: string, day: number, currentStatus: string) => { + try { + // Циклично переключаем статусы + const statuses = ['WORK', 'WEEKEND', 'VACATION', 'SICK', 'ABSENT'] + const currentIndex = statuses.indexOf(currentStatus.toUpperCase()) + const nextStatus = statuses[(currentIndex + 1) % statuses.length] + + // Формируем дату + const date = new Date(currentYear, currentMonth, day) + const hours = nextStatus === 'WORK' ? 8 : 0 + + // Отправляем мутацию + await updateEmployeeSchedule({ + variables: { + input: { + employeeId: employeeId, + date: date.toISOString().split('T')[0], // YYYY-MM-DD формат + status: nextStatus, + hoursWorked: hours + } + } + }) + + // Обновляем локальное состояние + const updatedDate = new Date(currentYear, currentMonth, day) + const dateStr = updatedDate.toISOString().split('T')[0] + + setEmployeeSchedules(prev => { + const currentSchedule = prev[employeeId] || [] + const existingRecordIndex = currentSchedule.findIndex(record => + record.date.split('T')[0] === dateStr + ) + + const newRecord = { + id: Date.now().toString(), // временный ID + date: updatedDate.toISOString(), + status: nextStatus, + hoursWorked: hours, + employee: { id: employeeId } + } + + let updatedSchedule + if (existingRecordIndex >= 0) { + // Обновляем существующую запись + updatedSchedule = [...currentSchedule] + updatedSchedule[existingRecordIndex] = { ...updatedSchedule[existingRecordIndex], ...newRecord } + } else { + // Добавляем новую запись + updatedSchedule = [...currentSchedule, newRecord] + } + + return { + ...prev, + [employeeId]: updatedSchedule + } + }) + + toast.success('Статус дня обновлен') + + } catch (error) { + console.error('Error updating day status:', error) + toast.error('Ошибка при обновлении статуса дня') + } + } + const exportToCSV = () => { const csvContent = [ ['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'], @@ -319,10 +436,9 @@ ${employees.map((emp: Employee) => {(() => { - const filteredEmployees = employees.filter(employee => + const filteredEmployees = employees.filter((employee: Employee) => `${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) || - employee.position.toLowerCase().includes(searchQuery.toLowerCase()) || - (employee.department && employee.department.toLowerCase().includes(searchQuery.toLowerCase())) + employee.position.toLowerCase().includes(searchQuery.toLowerCase()) ) if (filteredEmployees.length === 0) { @@ -376,31 +492,31 @@ ${employees.map((emp: Employee) => {/* Легенда точно как в гите */}
-
+
Рабочий день
-
+
Выходной
-
+
Отпуск
-
+
Больничный
-
+
Прогул @@ -408,7 +524,7 @@ ${employees.map((emp: Employee) =>
{/* Объединенный список сотрудников с табелем */} - {filteredEmployees.map((employee) => { + {filteredEmployees.map((employee: Employee) => { // Генерируем календарные дни для текущего месяца const currentDate = new Date() const currentMonth = currentDate.getMonth() @@ -431,16 +547,16 @@ ${employees.map((emp: Employee) => return 'work' } - const getDayClass = (status: string) => { - switch (status) { - case 'work': return 'bg-green-500/30 border-green-500/50' - case 'weekend': return 'bg-gray-500/30 border-gray-500/50' - case 'vacation': return 'bg-blue-500/30 border-blue-500/50' - case 'sick': return 'bg-yellow-500/30 border-yellow-500/50' - case 'absent': return 'bg-red-500/30 border-red-500/50' - default: return 'bg-white/10 border-white/20' - } - } + // const getDayClass = (status: string) => { + // switch (status) { + // case 'work': return 'bg-green-500/30 border-green-500/50' + // case 'weekend': return 'bg-gray-500/30 border-gray-500/50' + // case 'vacation': return 'bg-blue-500/30 border-blue-500/50' + // case 'sick': return 'bg-yellow-500/30 border-yellow-500/50' + // case 'absent': return 'bg-red-500/30 border-red-500/50' + // default: return 'bg-white/10 border-white/20' + // } + // } // Подсчитываем статистику const stats = { @@ -471,7 +587,7 @@ ${employees.map((emp: Employee) => } return ( - +
{/* Информация о сотруднике */}
@@ -489,34 +605,34 @@ ${employees.map((emp: Employee) => /> ) : null} - {employee.firstName.charAt(0)}{employee.lastName.charAt(0)} + {employee.firstName.charAt(0)}{employee.lastName.charAt(0)} + +
+
+

+ {employee.firstName} {employee.lastName} +

+
+ -
-
-

- {employee.firstName} {employee.lastName} -

-
- - - + @@ -540,52 +656,90 @@ ${employees.map((emp: Employee) => -
-
- -

{employee.position}

- {employee.department && ( -

{employee.department}

- )} - -
-
- - {employee.phone} -
- {employee.email && ( -
- - {employee.email} -
- )} -
-
-
- - {/* Статистика за месяц */} -
-
-

{stats.workDays}

-

Рабочих дней

-
-
-

{stats.vacationDays}

-

Отпуск

-
-
-

{stats.sickDays}

-

Больничный

-
-
-

{stats.totalHours}ч

-

Всего часов

-
+ +
+

+ {employee.firstName} {employee.middleName} {employee.lastName} +

+

{employee.position}

+ +
+ +
+ {/* Основные контакты */} +
+ + {employee.phone} +
+ {employee.email && ( +
+ + {employee.email} +
+ )} + + {/* Дата рождения */} + {employee.birthDate && ( +
+ + + Родился: {new Date(employee.birthDate).toLocaleDateString('ru-RU')} + +
+ )} + + {/* Дата приема на работу */} +
+ + + Принят: {new Date(employee.hireDate).toLocaleDateString('ru-RU')} + +
+ + {/* Адрес */} + {employee.address && ( +
+ + {employee.address} +
+ )} + + {/* Экстренный контакт */} + {employee.emergencyContact && ( +
+ + + Экстр. контакт: {employee.emergencyContact} + {employee.emergencyPhone && ` (${employee.emergencyPhone})`} + +
+ )} + + {/* Мессенджеры */} +
+ {employee.telegram && ( +
+ + @{employee.telegram} +
+ )} + {employee.whatsapp && ( +
+ + {employee.whatsapp} +
+ )} +
+
+
+
- {/* Табель работы 1 в 1 как в гите */} -
+
+ + {/* Табель работы и статистика */} +

Табель работы за {new Date().toLocaleDateString('ru-RU', { month: 'long' })} @@ -597,7 +751,7 @@ ${employees.map((emp: Employee) => {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
{day} -
+

))} {/* Дни месяца */} @@ -617,31 +771,41 @@ ${employees.map((emp: Employee) => calendarDays.push(day) } - // Функции статуса и стилей точно как в гите + // Функция для получения статуса дня из данных или дефолта const getDayStatus = (day: number) => { const date = new Date(currentYear, currentMonth, day) + const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD + + // Ищем запись в табеле для этого дня + const scheduleData = employeeSchedules[employee.id] || [] + const dayRecord = scheduleData.find(record => + record.date.split('T')[0] === dateStr + ) + + if (dayRecord) { + return dayRecord.status.toLowerCase() + } + + // Если записи нет, устанавливаем дефолтный статус const dayOfWeek = date.getDay() if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend' - if ([15, 16].includes(day)) return 'vacation' - if ([10].includes(day)) return 'sick' - if ([22].includes(day)) return 'absent' - return 'work' + return 'work' // По умолчанию рабочий день для новых сотрудников } const getCellStyle = (status: string) => { switch (status) { case 'work': - return 'bg-green-500/20 text-green-300 border-green-500/30' + return 'bg-emerald-500/20 text-emerald-300/70 border-emerald-400/80' case 'weekend': - return 'bg-gray-500/20 text-gray-300 border-gray-500/30' + return 'bg-purple-500/20 text-purple-300/70 border-purple-400/80' case 'vacation': - return 'bg-blue-500/20 text-blue-300 border-blue-500/30' + return 'bg-blue-500/20 text-blue-300/70 border-blue-400/80' case 'sick': - return 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' + return 'bg-yellow-500/20 text-yellow-300/70 border-yellow-400/80' case 'absent': - return 'bg-red-500/20 text-red-300 border-red-500/30' + return 'bg-red-500/20 text-red-300/70 border-red-400/80' default: - return 'bg-white/10 text-white/70 border-white/20' + return 'bg-white/10 text-white/50 border-white/20' } } @@ -678,16 +842,13 @@ ${employees.map((emp: Employee) => key={`${employee.id}-${day}`} className={` relative p-2 min-h-[60px] border rounded-lg cursor-pointer - transition-all duration-200 hover:scale-105 + transition-transform duration-150 hover:scale-105 active:scale-95 ${getCellStyle(status)} ${isToday ? 'ring-2 ring-white/50' : ''} `} onClick={() => { - // Циклично переключаем статусы - точно как в гите - // const statuses = ['work', 'weekend', 'vacation', 'sick', 'absent'] - // const currentIndex = statuses.indexOf(status) - // const nextStatus = statuses[(currentIndex + 1) % statuses.length] - // changeDayStatus(employee.id, day, nextStatus) + // Циклично переключаем статусы + changeDayStatus(employee.id, day, status) }} >
@@ -698,8 +859,8 @@ ${employees.map((emp: Employee) => {hours > 0 && ( {hours}ч )} -
- +
+ {isToday && (
)} @@ -708,15 +869,35 @@ ${employees.map((emp: Employee) => }) })()}
-
-
+ + {/* Статистика за месяц */} +
+
+

{stats.workDays}

+

Рабочих дней

+
+
+

{stats.vacationDays}

+

Отпуск

+
+
+

{stats.sickDays}

+

Больничный

+
+
+

{stats.totalHours}ч

+

Всего часов

+
+
+
+
- ) - })} -
+ ) + })} +
) })()} - + @@ -783,7 +964,7 @@ ${employees.map((emp: Employee) =>

Отделов

- {new Set(employees.map((e: Employee) => e.department).filter(Boolean)).size} + {new Set(employees.map((e: Employee) => e.position).filter(Boolean)).size}

@@ -833,16 +1014,16 @@ ${employees.map((emp: Employee) =>

Распределение по отделам

- {Array.from(new Set(employees.map(e => e.department).filter(Boolean))).map(dept => { - const deptEmployees = employees.filter(e => e.department === dept) - const percentage = Math.round((deptEmployees.length / employees.length) * 100) + {Array.from(new Set(employees.map((e: Employee) => e.position).filter(Boolean)) as Set).map((position: string) => { + const positionEmployees = employees.filter((e: Employee) => e.position === position) + const percentage = Math.round((positionEmployees.length / employees.length) * 100) return ( -
+
- {dept} - {deptEmployees.length} чел. ({percentage}%) + {position} + {positionEmployees.length} чел. ({percentage}%)
('') const [localSearch, setLocalSearch] = useState('') - const { data, loading, refetch } = useQuery(GET_ALL_PRODUCTS, { + const { data, loading } = useQuery(GET_ALL_PRODUCTS, { variables: { search: searchTerm || null, category: selectedCategoryId || selectedCategory || null } }) - const products: Product[] = data?.allProducts || [] + const products: Product[] = useMemo(() => data?.allProducts || [], [data?.allProducts]) // Получаем уникальные категории из товаров const categories = useMemo(() => { diff --git a/src/components/messenger/messenger-chat.tsx b/src/components/messenger/messenger-chat.tsx index d1fd178..e312a27 100644 --- a/src/components/messenger/messenger-chat.tsx +++ b/src/components/messenger/messenger-chat.tsx @@ -5,7 +5,7 @@ import { useMutation, useQuery } from '@apollo/client' import { GET_MESSAGES } from '@/graphql/queries' import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE } from '@/graphql/mutations' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' + import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' import { EmojiPickerComponent } from '@/components/ui/emoji-picker' diff --git a/src/components/ui/file-uploader.tsx b/src/components/ui/file-uploader.tsx index 503bc0b..f1cc588 100644 --- a/src/components/ui/file-uploader.tsx +++ b/src/components/ui/file-uploader.tsx @@ -176,6 +176,7 @@ export function FileUploader({ onSendFile }: FileUploaderProps) { disabled={isUploading} className="text-white/60 hover:text-white hover:bg-white/10 h-10 w-10 p-0" > + {/* eslint-disable-next-line jsx-a11y/alt-text */} diff --git a/src/components/ui/voice-player.tsx b/src/components/ui/voice-player.tsx index 388f915..01c7fa1 100644 --- a/src/components/ui/voice-player.tsx +++ b/src/components/ui/voice-player.tsx @@ -23,7 +23,6 @@ export function VoicePlayer({ audioUrl, duration = 0, isCurrentUser = false }: V if (duration > 0 && (!audioDuration || audioDuration === 0)) { setAudioDuration(duration) } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [duration, audioDuration]) useEffect(() => { @@ -85,7 +84,7 @@ export function VoicePlayer({ audioUrl, duration = 0, isCurrentUser = false }: V audio.pause() } } - }, [audioUrl]) + }, [audioUrl, duration]) const togglePlayPause = () => { const audio = audioRef.current diff --git a/src/components/warehouse/product-card.tsx b/src/components/warehouse/product-card.tsx index 7dbc54e..f27d5a4 100644 --- a/src/components/warehouse/product-card.tsx +++ b/src/components/warehouse/product-card.tsx @@ -1,6 +1,7 @@ "use client" -import { useState } from 'react' +import Image from 'next/image' + import { useMutation } from '@apollo/client' import { Card } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -80,9 +81,11 @@ export function ProductCard({ product, onEdit, onDeleted }: ProductCardProps) { {/* Изображение товара */}
{product.mainImage || product.images[0] ? ( - {product.name} ) : ( diff --git a/src/components/warehouse/product-form.tsx b/src/components/warehouse/product-form.tsx index 9e33a95..66168ef 100644 --- a/src/components/warehouse/product-form.tsx +++ b/src/components/warehouse/product-form.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useRef } from 'react' +import Image from 'next/image' import { useMutation, useQuery } from '@apollo/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -9,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Card } from '@/components/ui/card' import { CREATE_PRODUCT, UPDATE_PRODUCT } from '@/graphql/mutations' import { GET_CATEGORIES } from '@/graphql/queries' -import { Upload, X, Star, Plus, Image as ImageIcon } from 'lucide-react' +import { X, Star, Upload } from 'lucide-react' import { toast } from 'sonner' interface Product { @@ -56,7 +57,7 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) { isActive: product?.isActive ?? true }) - const [isUploading, setIsUploading] = useState(false) + const [isUploading] = useState(false) const [uploadingImages, setUploadingImages] = useState>(new Set()) const fileInputRef = useRef(null) @@ -420,9 +421,11 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
) : ( <> - {`Товар diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index ed22412..9458fb0 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -538,4 +538,19 @@ export const GET_EMPLOYEE = gql` updatedAt } } +` + +export const GET_EMPLOYEE_SCHEDULE = gql` + query GetEmployeeSchedule($employeeId: ID!, $year: Int!, $month: Int!) { + employeeSchedule(employeeId: $employeeId, year: $year, month: $month) { + id + date + status + hoursWorked + notes + employee { + id + } + } + } ` \ No newline at end of file diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 33a4913..eb73697 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -20,6 +20,63 @@ interface Context { } } +interface CreateEmployeeInput { + firstName: string + lastName: string + middleName?: string + birthDate?: string + avatar?: string + passportPhoto?: string + passportSeries?: string + passportNumber?: string + passportIssued?: string + passportDate?: string + address?: string + position: string + department?: string + hireDate: string + salary?: number + phone: string + email?: string + telegram?: string + whatsapp?: string + emergencyContact?: string + emergencyPhone?: string +} + +interface UpdateEmployeeInput { + firstName?: string + lastName?: string + middleName?: string + birthDate?: string + avatar?: string + passportPhoto?: string + passportSeries?: string + passportNumber?: string + passportIssued?: string + passportDate?: string + address?: string + position?: string + department?: string + hireDate?: string + salary?: number + status?: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED' + phone?: string + email?: string + telegram?: string + whatsapp?: string + emergencyContact?: string + emergencyPhone?: string +} + +interface UpdateScheduleInput { + employeeId: string + date: string + status: 'WORK' | 'WEEKEND' | 'VACATION' | 'SICK' | 'ABSENT' + hoursWorked?: number + notes?: string +} + interface AuthTokenPayload { userId: string phone: string @@ -742,6 +799,59 @@ export const resolvers = { }) return employee + }, + + // Получить табель сотрудника за месяц + employeeSchedule: async (_: unknown, args: { employeeId: string; year: number; month: number }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' } + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true } + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + if (currentUser.organization.type !== 'FULFILLMENT') { + throw new GraphQLError('Доступно только для фулфилмент центров') + } + + // Проверяем что сотрудник принадлежит организации + const employee = await prisma.employee.findFirst({ + where: { + id: args.employeeId, + organizationId: currentUser.organization.id + } + }) + + if (!employee) { + throw new GraphQLError('Сотрудник не найден') + } + + // Получаем записи табеля за указанный месяц + const startDate = new Date(args.year, args.month, 1) + const endDate = new Date(args.year, args.month + 1, 0) + + const scheduleRecords = await prisma.employeeSchedule.findMany({ + where: { + employeeId: args.employeeId, + date: { + gte: startDate, + lte: endDate + } + }, + orderBy: { + date: 'asc' + } + }) + + return scheduleRecords } }, @@ -3110,7 +3220,7 @@ export const resolvers = { }, // Создать сотрудника - createEmployee: async (_: unknown, args: { input: any }, context: Context) => { + createEmployee: async (_: unknown, args: { input: CreateEmployeeInput }, context: Context) => { if (!context.user) { throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' } @@ -3159,7 +3269,7 @@ export const resolvers = { }, // Обновить сотрудника - updateEmployee: async (_: unknown, args: { id: string; input: any }, context: Context) => { + updateEmployee: async (_: unknown, args: { id: string; input: UpdateEmployeeInput }, context: Context) => { if (!context.user) { throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' } @@ -3247,7 +3357,7 @@ export const resolvers = { }, // Обновить табель сотрудника - updateEmployeeSchedule: async (_: unknown, args: { input: any }, context: Context) => { + updateEmployeeSchedule: async (_: unknown, args: { input: UpdateScheduleInput }, context: Context) => { if (!context.user) { throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' } @@ -3441,6 +3551,11 @@ export const resolvers = { return parent.updatedAt.toISOString() } return parent.updatedAt + }, + employee: async (parent: { employeeId: string }) => { + return await prisma.employee.findUnique({ + where: { id: parent.employeeId } + }) } } } \ No newline at end of file diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index ab926dd..1f4acbe 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -47,6 +47,9 @@ export const typeDefs = gql` # Сотрудники организации myEmployees: [Employee!]! employee(id: ID!): Employee + + # Табель сотрудника за месяц + employeeSchedule(employeeId: ID!, year: Int!, month: Int!): [EmployeeSchedule!]! } type Mutation { diff --git a/src/services/s3-service.ts b/src/services/s3-service.ts index e5cabf6..26f8ef3 100644 --- a/src/services/s3-service.ts +++ b/src/services/s3-service.ts @@ -19,7 +19,6 @@ export class S3Service { private static async createSignedUrl(fileName: string, fileType: string): Promise { // Для простоты пока используем прямую загрузку через fetch // В продакшене лучше генерировать signed URLs на backend - // eslint-disable-next-line @typescript-eslint/no-unused-vars // fileType используется для будущей логики разделения по типам файлов const timestamp = Date.now() const key = `avatars/${timestamp}-${fileName}`