Удалены неиспользуемые импорты и функции из компонентов, улучшены стили и функциональность. Обновлены компоненты для работы с изображениями, добавлены новые интерфейсы и типы данных для сотрудников. Реализована логика загрузки расписания сотрудников через GraphQL, улучшен интерфейс взаимодействия с пользователем.

This commit is contained in:
Bivekich
2025-07-18 11:00:51 +03:00
parent d361364716
commit 7e7e4a9b4a
19 changed files with 507 additions and 248 deletions

View File

@ -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) => {

View File

@ -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) {
</div>
<div className="space-y-2">
{Object.values(sellerGroups).map((group, index) => (
{Object.values(sellerGroups).map((group) => (
<div
key={group.organization.id}
className="bg-white/5 p-3 rounded-lg text-xs"

View File

@ -18,22 +18,7 @@ export function DashboardHome() {
return 'Вашей организации'
}
const getCabinetType = () => {
if (!user?.organization?.type) return 'кабинета'
switch (user.organization.type) {
case 'FULFILLMENT':
return 'фулфилмент кабинета'
case 'SELLER':
return 'селлер кабинета'
case 'LOGIST':
return 'логистического кабинета'
case 'WHOLESALE':
return 'оптового кабинета'
default:
return 'кабинета'
}
}
return (
<div className="min-h-screen bg-gradient-smooth flex">

View File

@ -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 =
<div className="space-y-3">
{formData.passportPhoto ? (
<div className="relative">
<img
<Image
src={formData.passportPhoto}
alt="Паспорт"
width={400}
height={300}
className="w-full h-auto max-h-48 object-contain rounded-lg border border-white/20 bg-white/5 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowPassportPreview(true)}
/>
@ -546,9 +549,11 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
<DialogTitle className="text-white">Фото паспорта</DialogTitle>
</DialogHeader>
<div className="flex justify-center">
<img
src={formData.passportPhoto}
alt="Паспорт"
<Image
src={formData.passportPhoto}
alt="Паспорт"
width={600}
height={800}
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
</div>

View File

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

View File

@ -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
<div className="space-y-3">
{formData.passportPhoto ? (
<div className="relative">
<img
<Image
src={formData.passportPhoto}
alt="Паспорт"
alt="Паспорт"
width={400}
height={300}
className="w-full h-auto max-h-48 object-contain rounded-lg border border-white/20 bg-white/5 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowPassportPreview(true)}
/>
@ -508,9 +511,11 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
<DialogTitle className="text-white">Фото паспорта</DialogTitle>
</DialogHeader>
<div className="flex justify-center">
<img
src={formData.passportPhoto}
alt="Паспорт"
<Image
src={formData.passportPhoto}
alt="Паспорт"
width={600}
height={800}
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
</div>

View File

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

View File

@ -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<Employee | null>(null)
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(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) =>
<TabsContent value="combined">
<Card className="glass-card p-6">
{(() => {
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) =>
{/* Легенда точно как в гите */}
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-green-500/20 text-green-300 border-green-500/30">
<div className="w-4 h-4 rounded border bg-emerald-500/20 text-emerald-300/70 border-emerald-400/80 flex items-center justify-center">
<CheckCircle className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Рабочий день</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-gray-500/20 text-gray-300 border-gray-500/30">
<div className="w-4 h-4 rounded border bg-purple-500/20 text-purple-300/70 border-purple-400/80 flex items-center justify-center">
<Clock className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Выходной</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-blue-500/20 text-blue-300 border-blue-500/30">
<div className="w-4 h-4 rounded border bg-blue-500/20 text-blue-300/70 border-blue-400/80 flex items-center justify-center">
<Plane className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Отпуск</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-yellow-500/20 text-yellow-300 border-yellow-500/30">
<div className="w-4 h-4 rounded border bg-yellow-500/20 text-yellow-300/70 border-yellow-400/80 flex items-center justify-center">
<Activity className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Больничный</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-red-500/20 text-red-300 border-red-500/30">
<div className="w-4 h-4 rounded border bg-red-500/20 text-red-300/70 border-red-400/80 flex items-center justify-center">
<XCircle className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Прогул</span>
@ -408,7 +524,7 @@ ${employees.map((emp: Employee) =>
</div>
{/* Объединенный список сотрудников с табелем */}
{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 (
<Card key={employee.id} className="glass-card p-6">
<Card key={employee.id} className="glass-card p-6">
<div className="flex flex-col lg:flex-row gap-6">
{/* Информация о сотруднике */}
<div className="lg:w-80 flex-shrink-0">
@ -489,34 +605,34 @@ ${employees.map((emp: Employee) =>
/>
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold text-lg">
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<h3 className="text-white font-semibold text-lg truncate">
{employee.firstName} {employee.lastName}
</h3>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
onClick={() => handleEditEmployee(employee)}
>
<Edit className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<h3 className="text-white font-semibold text-lg truncate">
{employee.firstName} {employee.lastName}
</h3>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
onClick={() => handleEditEmployee(employee)}
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<UserX className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<UserX className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="glass-card border-white/10">
<AlertDialogHeader>
@ -540,52 +656,90 @@ ${employees.map((emp: Employee) =>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<p className="text-purple-300 font-medium mb-1">{employee.position}</p>
{employee.department && (
<p className="text-white/60 text-sm mb-3">{employee.department}</p>
)}
<div className="space-y-2">
<div className="flex items-center text-white/70 text-sm">
<Phone className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">{employee.phone}</span>
</div>
{employee.email && (
<div className="flex items-center text-white/70 text-sm">
<Mail className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">{employee.email}</span>
</div>
)}
</div>
</div>
</div>
{/* Статистика за месяц */}
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-green-400 font-semibold text-lg">{stats.workDays}</p>
<p className="text-white/60 text-xs">Рабочих дней</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-blue-400 font-semibold text-lg">{stats.vacationDays}</p>
<p className="text-white/60 text-xs">Отпуск</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-yellow-400 font-semibold text-lg">{stats.sickDays}</p>
<p className="text-white/60 text-xs">Больничный</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-white font-semibold text-lg">{stats.totalHours}ч</p>
<p className="text-white/60 text-xs">Всего часов</p>
</div>
</div>
</div>
<div className="mb-3">
<h4 className="text-white font-semibold text-base">
{employee.firstName} {employee.middleName} {employee.lastName}
</h4>
<p className="text-purple-300 font-medium">{employee.position}</p>
</div>
<div className="space-y-2 text-sm">
{/* Основные контакты */}
<div className="flex items-center text-white/70">
<Phone className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">{employee.phone}</span>
</div>
{employee.email && (
<div className="flex items-center text-white/70">
<Mail className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">{employee.email}</span>
</div>
)}
{/* Дата рождения */}
{employee.birthDate && (
<div className="flex items-center text-white/70">
<Calendar className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">
Родился: {new Date(employee.birthDate).toLocaleDateString('ru-RU')}
</span>
</div>
)}
{/* Дата приема на работу */}
<div className="flex items-center text-white/70">
<Briefcase className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">
Принят: {new Date(employee.hireDate).toLocaleDateString('ru-RU')}
</span>
</div>
{/* Адрес */}
{employee.address && (
<div className="flex items-center text-white/70">
<MapPin className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">{employee.address}</span>
</div>
)}
{/* Экстренный контакт */}
{employee.emergencyContact && (
<div className="flex items-center text-white/70">
<AlertCircle className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">
Экстр. контакт: {employee.emergencyContact}
{employee.emergencyPhone && ` (${employee.emergencyPhone})`}
</span>
</div>
)}
{/* Мессенджеры */}
<div className="flex gap-2">
{employee.telegram && (
<div className="flex items-center text-blue-400">
<MessageCircle className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate text-xs">@{employee.telegram}</span>
</div>
)}
{employee.whatsapp && (
<div className="flex items-center text-green-400">
<Phone className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate text-xs">{employee.whatsapp}</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Табель работы 1 в 1 как в гите */}
<div className="flex-1">
</div>
{/* Табель работы и статистика */}
<div className="flex-1 space-y-4">
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
<Calendar className="h-4 w-4" />
Табель работы за {new Date().toLocaleDateString('ru-RU', { month: 'long' })}
@ -597,7 +751,7 @@ ${employees.map((emp: Employee) =>
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
{day}
</div>
</div>
))}
{/* Дни месяца */}
@ -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)
}}
>
<div className="flex flex-col items-center justify-center h-full">
@ -698,8 +859,8 @@ ${employees.map((emp: Employee) =>
{hours > 0 && (
<span className="text-xs opacity-80">{hours}ч</span>
)}
</div>
</div>
{isToday && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-white rounded-full"></div>
)}
@ -708,15 +869,35 @@ ${employees.map((emp: Employee) =>
})
})()}
</div>
</div>
</div>
{/* Статистика за месяц */}
<div className="grid grid-cols-4 gap-3 mt-4">
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-emerald-200 font-semibold text-lg">{stats.workDays}</p>
<p className="text-white/60 text-xs">Рабочих дней</p>
</div>
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-sky-200 font-semibold text-lg">{stats.vacationDays}</p>
<p className="text-white/60 text-xs">Отпуск</p>
</div>
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-amber-200 font-semibold text-lg">{stats.sickDays}</p>
<p className="text-white/60 text-xs">Больничный</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-white font-semibold text-lg">{stats.totalHours}ч</p>
<p className="text-white/60 text-xs">Всего часов</p>
</div>
</div>
</div>
</div>
</Card>
)
})}
</div>
)
})}
</div>
)
})()}
</Card>
</Card>
</TabsContent>
<TabsContent value="reports">
@ -783,7 +964,7 @@ ${employees.map((emp: Employee) =>
<div>
<p className="text-white/70 text-sm">Отделов</p>
<p className="text-2xl font-bold text-orange-400">
{new Set(employees.map((e: Employee) => e.department).filter(Boolean)).size}
{new Set(employees.map((e: Employee) => e.position).filter(Boolean)).size}
</p>
</div>
<Calendar className="h-8 w-8 text-orange-400" />
@ -833,16 +1014,16 @@ ${employees.map((emp: Employee) =>
<Card className="glass-card p-6">
<h3 className="text-white font-medium mb-4">Распределение по отделам</h3>
<div className="space-y-3">
{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<string>).map((position: string) => {
const positionEmployees = employees.filter((e: Employee) => e.position === position)
const percentage = Math.round((positionEmployees.length / employees.length) * 100)
return (
<div key={dept} className="flex items-center justify-between">
<div key={position} className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-white/80 text-sm">{dept}</span>
<span className="text-white/60 text-xs">{deptEmployees.length} чел. ({percentage}%)</span>
<span className="text-white/80 text-sm">{position}</span>
<span className="text-white/60 text-xs">{positionEmployees.length} чел. ({percentage}%)</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div

View File

@ -5,7 +5,7 @@ import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@ -20,7 +20,7 @@ import {
Briefcase,
Save,
X,
Trash2,
UserX
} from 'lucide-react'
@ -40,61 +40,7 @@ interface Employee {
address: string
}
// Моковые данные сотрудников
const mockEmployees: Employee[] = [
{
id: '1',
firstName: 'Александр',
lastName: 'Петров',
position: 'Менеджер склада',
department: 'Логистика',
phone: '+7 (999) 123-45-67',
email: 'a.petrov@company.com',
hireDate: '2023-01-15',
status: 'active',
salary: 80000,
address: 'Москва, ул. Ленина, 10'
},
{
id: '2',
firstName: 'Мария',
lastName: 'Иванова',
position: 'Кладовщик',
department: 'Логистика',
phone: '+7 (999) 234-56-78',
email: 'm.ivanova@company.com',
hireDate: '2023-03-20',
status: 'active',
salary: 60000,
address: 'Москва, ул. Советская, 25'
},
{
id: '3',
firstName: 'Дмитрий',
lastName: 'Сидоров',
position: 'Водитель',
department: 'Доставка',
phone: '+7 (999) 345-67-89',
email: 'd.sidorov@company.com',
hireDate: '2022-11-10',
status: 'vacation',
salary: 70000,
address: 'Москва, ул. Мира, 15'
},
{
id: '4',
firstName: 'Анна',
lastName: 'Козлова',
position: 'HR-специалист',
department: 'Кадры',
phone: '+7 (999) 456-78-90',
email: 'a.kozlova@company.com',
hireDate: '2023-02-05',
status: 'active',
salary: 75000,
address: 'Москва, пр. Победы, 8'
}
]
interface EmployeesListProps {
searchQuery: string

View File

@ -50,14 +50,14 @@ export function MarketProducts({ selectedCategoryId, selectedCategoryName, onBac
const [selectedCategory, setSelectedCategory] = useState<string>('')
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(() => {

View File

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

View File

@ -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 */}
<Image className="h-4 w-4" />
</Button>

View File

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

View File

@ -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) {
{/* Изображение товара */}
<div className="relative h-48 bg-white/5 overflow-hidden">
{product.mainImage || product.images[0] ? (
<img
<Image
src={product.mainImage || product.images[0]}
alt={product.name}
width={300}
height={200}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
) : (

View File

@ -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<Set<number>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
@ -420,9 +421,11 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
</div>
) : (
<>
<img
<Image
src={imageUrl}
alt={`Товар ${index + 1}`}
width={200}
height={150}
className="w-full aspect-square object-cover rounded-lg"
/>

View File

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

View File

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

View File

@ -47,6 +47,9 @@ export const typeDefs = gql`
# Сотрудники организации
myEmployees: [Employee!]!
employee(id: ID!): Employee
# Табель сотрудника за месяц
employeeSchedule(employeeId: ID!, year: Int!, month: Int!): [EmployeeSchedule!]!
}
type Mutation {

View File

@ -19,7 +19,6 @@ export class S3Service {
private static async createSignedUrl(fileName: string, fileType: string): Promise<string> {
// Для простоты пока используем прямую загрузку через fetch
// В продакшене лучше генерировать signed URLs на backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// fileType используется для будущей логики разделения по типам файлов
const timestamp = Date.now()
const key = `avatars/${timestamp}-${fileName}`