518 lines
24 KiB
TypeScript
518 lines
24 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData';
|
||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||
import {
|
||
Clock,
|
||
Star,
|
||
Phone,
|
||
Plus,
|
||
Calendar,
|
||
Search,
|
||
AlertTriangle,
|
||
} from 'lucide-react';
|
||
|
||
interface ShiftPlanItem {
|
||
employeeId: string;
|
||
day: string; // 'Пн' | 'Вт' | 'Ср' | 'Чт' | 'Пт' | 'Сб' | 'Вс'
|
||
shiftText: string;
|
||
hours: number;
|
||
hasConflict?: boolean;
|
||
conflictReason?: string;
|
||
}
|
||
|
||
const DAYS_OF_WEEK = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||
|
||
const INITIAL_SHIFTS: ShiftPlanItem[] = [
|
||
{ employeeId: 'emp-1', day: 'Пн', shiftText: '10:00 - 22:00', hours: 12 },
|
||
{ employeeId: 'emp-1', day: 'Вт', shiftText: '10:00 - 22:00', hours: 12 },
|
||
{ employeeId: 'emp-1', day: 'Ср', shiftText: '10:00 - 22:00', hours: 12 },
|
||
{ employeeId: 'emp-1', day: 'Чт', shiftText: '10:00 - 22:00', hours: 12 },
|
||
{ employeeId: 'emp-1', day: 'Пт', shiftText: '09:00 - 23:00', hours: 14, hasConflict: true, conflictReason: 'Смена превышает 12 ч (ТК РФ)' },
|
||
{ employeeId: 'emp-2', day: 'Вт', shiftText: '11:00 - 23:00', hours: 12 },
|
||
{ employeeId: 'emp-2', day: 'Ср', shiftText: '11:00 - 23:00', hours: 12 },
|
||
{ employeeId: 'emp-2', day: 'Чт', shiftText: '11:00 - 23:00', hours: 12 },
|
||
{ employeeId: 'emp-2', day: 'Сб', shiftText: '11:00 - 23:00', hours: 12 },
|
||
{ employeeId: 'emp-2', day: 'Вс', shiftText: '11:00 - 23:00', hours: 12 },
|
||
{ employeeId: 'emp-3', day: 'Пн', shiftText: '10:00 - 22:00', hours: 12 },
|
||
{ employeeId: 'emp-3', day: 'Чт', shiftText: '10:00 - 22:00', hours: 12 },
|
||
{ employeeId: 'emp-3', day: 'Пт', shiftText: '10:00 - 22:00', hours: 12 },
|
||
{ employeeId: 'emp-3', day: 'Сб', shiftText: '10:00 - 22:00', hours: 12 },
|
||
{ employeeId: 'emp-4', day: 'Пн', shiftText: '12:00 - 00:00', hours: 12 },
|
||
{ employeeId: 'emp-4', day: 'Вт', shiftText: '12:00 - 00:00', hours: 12 },
|
||
{ employeeId: 'emp-4', day: 'Пт', shiftText: '12:00 - 00:00', hours: 12 },
|
||
{ employeeId: 'emp-4', day: 'Сб', shiftText: '12:00 - 00:00', hours: 12 },
|
||
];
|
||
|
||
export const EmployeesModule: React.FC = () => {
|
||
const { showToast } = useToast();
|
||
|
||
const [activeTab, setActiveTab] = useState<'employees' | 'schedule'>('employees');
|
||
const [employees, setEmployees] = useState<EmployeeEntity[]>(INITIAL_EMPLOYEES);
|
||
const [shifts, setShifts] = useState<ShiftPlanItem[]>(INITIAL_SHIFTS);
|
||
const [search, setSearch] = useState('');
|
||
|
||
// New Employee Modal
|
||
const [isNewEmpModalOpen, setIsNewEmpModalOpen] = useState(false);
|
||
const [newEmpName, setNewEmpName] = useState('');
|
||
const [newEmpRole, setNewEmpRole] = useState('Официант');
|
||
const [newEmpPhone, setNewEmpPhone] = useState('+7 (913) ');
|
||
const [newEmpRate, setNewEmpRate] = useState(250);
|
||
|
||
// Edit Shift Modal
|
||
const [editingShift, setEditingShift] = useState<{ empId: string; day: string; shiftText: string; hours: number } | null>(null);
|
||
|
||
const filteredEmployees = employees.filter((e) => {
|
||
const matchSearch = e.name.toLowerCase().includes(search.toLowerCase()) || e.role.toLowerCase().includes(search.toLowerCase());
|
||
return matchSearch;
|
||
});
|
||
|
||
const handleCreateEmployee = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newEmpName.trim()) return;
|
||
|
||
const initials = newEmpName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() || 'НС';
|
||
const newEmp: EmployeeEntity = {
|
||
id: `emp-${Date.now()}`,
|
||
name: newEmpName.trim(),
|
||
role: newEmpRole,
|
||
status: 'off_shift',
|
||
shiftHours: '09:00 - 21:00',
|
||
salesToday: 0,
|
||
rating: 5.0,
|
||
phone: newEmpPhone,
|
||
avatarInitials: initials,
|
||
};
|
||
|
||
setEmployees((prev) => [newEmp, ...prev]);
|
||
setIsNewEmpModalOpen(false);
|
||
setNewEmpName('');
|
||
showToast({
|
||
title: 'Сотрудник добавлен',
|
||
message: `Карточка «${newEmp.name}» (${newEmp.role}) успешно создана`,
|
||
type: 'success',
|
||
});
|
||
};
|
||
|
||
const handleSaveShift = () => {
|
||
if (!editingShift) return;
|
||
|
||
const hasConflict = editingShift.hours > 12;
|
||
const conflictReason = hasConflict ? 'Смена превышает 12 ч (ТК РФ)' : undefined;
|
||
|
||
setShifts((prev) => {
|
||
const filtered = prev.filter(
|
||
(s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day)
|
||
);
|
||
if (editingShift.shiftText === 'Выходной' || editingShift.hours === 0) {
|
||
return filtered;
|
||
}
|
||
return [
|
||
...filtered,
|
||
{
|
||
employeeId: editingShift.empId,
|
||
day: editingShift.day,
|
||
shiftText: editingShift.shiftText,
|
||
hours: editingShift.hours,
|
||
hasConflict,
|
||
conflictReason,
|
||
},
|
||
];
|
||
});
|
||
|
||
setEditingShift(null);
|
||
showToast({
|
||
title: 'Смена обновлена',
|
||
message: `График на ${editingShift.day} сохранен`,
|
||
type: 'success',
|
||
});
|
||
};
|
||
|
||
const conflictCount = shifts.filter((s) => s.hasConflict).length;
|
||
|
||
return (
|
||
<div className="space-y-4 animate-fade-in">
|
||
{/* Header */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-sm font-bold text-krona-primary">
|
||
Сотрудники, графики и табель учета смен
|
||
</h2>
|
||
<MaturityBadge level="target" size="sm" />
|
||
</div>
|
||
<p className="text-xs text-krona-secondary mt-0.5">
|
||
Управление персоналом, планировщик смен, контроль переработок и предупреждения о конфликтах расписания
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center bg-krona-workspace p-1 rounded-lg border border-krona-border text-xs">
|
||
<button
|
||
onClick={() => setActiveTab('employees')}
|
||
className={`px-3 py-1 rounded font-medium transition-colors ${
|
||
activeTab === 'employees' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
|
||
}`}
|
||
>
|
||
Сотрудники ({employees.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('schedule')}
|
||
className={`px-3 py-1 rounded font-medium transition-colors flex items-center gap-1.5 ${
|
||
activeTab === 'schedule' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
|
||
}`}
|
||
>
|
||
График смен
|
||
{conflictCount > 0 && (
|
||
<span className="w-4 h-4 rounded-full bg-rose-500 text-white text-[10px] flex items-center justify-center font-bold">
|
||
{conflictCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
<Button variant="primary" size="sm" onClick={() => setIsNewEmpModalOpen(true)}>
|
||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||
Добавить сотрудника
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 1. EMPLOYEES TAB */}
|
||
{activeTab === 'employees' && (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||
<div className="relative w-72">
|
||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
|
||
<input
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="Поиск по имени или должности..."
|
||
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-green"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 text-xs text-krona-secondary">
|
||
<Badge variant="green" size="md">
|
||
На смене: {employees.filter((e) => e.status === 'on_shift').length}
|
||
</Badge>
|
||
<Badge variant="neutral" size="md">
|
||
Выходной: {employees.filter((e) => e.status !== 'on_shift').length}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3.5">
|
||
{filteredEmployees.map((emp) => (
|
||
<div
|
||
key={emp.id}
|
||
className="p-4 rounded-xl border border-krona-border bg-krona-surface shadow-krona-soft space-y-3 hover:border-krona-border-strong transition-all"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2.5">
|
||
<div className="w-10 h-10 rounded-full bg-krona-workspace border border-krona-border flex items-center justify-center font-bold text-sm text-krona-primary">
|
||
{emp.avatarInitials}
|
||
</div>
|
||
<div>
|
||
<h4 className="text-xs font-bold text-krona-primary">{emp.name}</h4>
|
||
<span className="text-[11px] text-krona-secondary">{emp.role}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Badge variant={emp.status === 'on_shift' ? 'green' : 'neutral'} size="sm">
|
||
{emp.status === 'on_shift' ? 'На смене' : 'Выходной'}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2 text-xs py-1 border-y border-krona-border/50">
|
||
<div>
|
||
<span className="text-krona-secondary block text-[10px]">График сегодня:</span>
|
||
<span className="font-semibold text-krona-primary flex items-center gap-1">
|
||
<Clock className="w-3 h-3 text-krona-gold" />
|
||
{emp.shiftHours}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-krona-secondary block text-[10px]">Рейтинг качества:</span>
|
||
<span className="font-semibold text-krona-primary flex items-center gap-1">
|
||
<Star className="w-3 h-3 text-krona-gold fill-krona-gold" />
|
||
{emp.rating} / 5.0
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between text-xs pt-1">
|
||
<div className="flex items-center gap-1 text-krona-secondary">
|
||
<Phone className="w-3.5 h-3.5" />
|
||
<span className="font-mono font-medium text-krona-primary">{emp.phone}</span>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
setEmployees(
|
||
employees.map((x) =>
|
||
x.id === emp.id
|
||
? { ...x, status: x.status === 'on_shift' ? 'off_shift' : 'on_shift' }
|
||
: x
|
||
)
|
||
);
|
||
}}
|
||
className="text-xs text-krona-green font-medium hover:underline"
|
||
>
|
||
{emp.status === 'on_shift' ? 'Завершить смену' : 'Вывести на смену'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 2. SCHEDULE PLANNER TAB */}
|
||
{activeTab === 'schedule' && (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3 rounded-xl border border-krona-border">
|
||
<div className="flex items-center gap-2">
|
||
<Calendar className="w-4 h-4 text-krona-gold" />
|
||
<span className="font-bold text-xs text-krona-primary">
|
||
Недельный график смен (07.09.2026 — 13.09.2026)
|
||
</span>
|
||
{conflictCount > 0 ? (
|
||
<Badge variant="danger" size="sm">
|
||
{conflictCount} конфликт расписания!
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="green" size="sm">Конфликтов нет</Badge>
|
||
)}
|
||
</div>
|
||
|
||
<p className="text-[11px] text-krona-secondary">
|
||
Кликните по ячейке для назначения или изменения времени смены сотрудника
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-xs border-collapse min-w-[700px]">
|
||
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
|
||
<tr>
|
||
<th className="p-3 font-semibold w-48">Сотрудник / Должность</th>
|
||
{DAYS_OF_WEEK.map((d) => (
|
||
<th key={d} className="p-3 font-semibold text-center border-l border-krona-border/40">
|
||
{d}
|
||
</th>
|
||
))}
|
||
<th className="p-3 font-semibold text-center border-l border-krona-border/40 w-24">
|
||
Часы / нед.
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-krona-border/50">
|
||
{employees.map((emp) => {
|
||
const empShifts = shifts.filter((s) => s.employeeId === emp.id);
|
||
const totalHours = empShifts.reduce((acc, s) => acc + s.hours, 0);
|
||
|
||
return (
|
||
<tr key={emp.id} className="hover:bg-krona-workspace/40 transition-colors">
|
||
<td className="p-3 font-medium text-krona-primary">
|
||
<div className="font-semibold">{emp.name}</div>
|
||
<div className="text-[11px] text-krona-secondary">{emp.role}</div>
|
||
</td>
|
||
|
||
{DAYS_OF_WEEK.map((day) => {
|
||
const shift = empShifts.find((s) => s.day === day);
|
||
|
||
return (
|
||
<td
|
||
key={day}
|
||
onClick={() => {
|
||
setEditingShift({
|
||
empId: emp.id,
|
||
day,
|
||
shiftText: shift ? shift.shiftText : '10:00 - 22:00',
|
||
hours: shift ? shift.hours : 12,
|
||
});
|
||
}}
|
||
className="p-2 border-l border-krona-border/40 text-center cursor-pointer hover:bg-krona-workspace transition-colors"
|
||
>
|
||
{shift ? (
|
||
<div
|
||
className={`p-1.5 rounded-lg border text-[11px] font-mono leading-tight ${
|
||
shift.hasConflict
|
||
? 'bg-rose-500/15 border-rose-500/50 text-rose-700 dark:text-rose-300 font-bold'
|
||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-800 dark:text-emerald-300 font-medium'
|
||
}`}
|
||
title={shift.conflictReason || 'Смена подтверждена'}
|
||
>
|
||
<div>{shift.shiftText}</div>
|
||
<div className="text-[9px] opacity-75">{shift.hours} ч</div>
|
||
{shift.hasConflict && (
|
||
<div className="flex items-center justify-center gap-0.5 mt-0.5 text-rose-600 font-bold text-[9px]">
|
||
<AlertTriangle className="w-2.5 h-2.5" /> Превышение!
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="text-[11px] text-krona-secondary/60 hover:text-krona-primary">
|
||
—
|
||
</span>
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
|
||
<td className="p-3 border-l border-krona-border/40 text-center font-mono font-bold">
|
||
<span className={totalHours > 40 ? 'text-amber-600' : 'text-krona-primary'}>
|
||
{totalHours} ч
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal: New Employee */}
|
||
{isNewEmpModalOpen && (
|
||
<Modal
|
||
isOpen={isNewEmpModalOpen}
|
||
onClose={() => setIsNewEmpModalOpen(false)}
|
||
title="Новый сотрудник"
|
||
subtitle="Создание профиля и назначение должности"
|
||
maxWidth="sm"
|
||
footer={
|
||
<div className="flex gap-2 w-full justify-end">
|
||
<Button variant="secondary" onClick={() => setIsNewEmpModalOpen(false)}>
|
||
Отмена
|
||
</Button>
|
||
<Button variant="primary" onClick={handleCreateEmployee}>
|
||
Сохранить
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-3 text-xs">
|
||
<div className="space-y-1">
|
||
<label className="font-semibold text-krona-primary">ФИО сотрудника:</label>
|
||
<input
|
||
type="text"
|
||
value={newEmpName}
|
||
onChange={(e) => setNewEmpName(e.target.value)}
|
||
placeholder="Иванова Екатерина Сергеевна"
|
||
className="w-full p-2 bg-krona-workspace border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<label className="font-semibold text-krona-primary">Должность:</label>
|
||
<select
|
||
value={newEmpRole}
|
||
onChange={(e) => setNewEmpRole(e.target.value)}
|
||
className="w-full p-2 bg-krona-workspace border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||
>
|
||
<option value="Официант">Официант</option>
|
||
<option value="Су-шеф">Су-шеф</option>
|
||
<option value="Повар-сушист">Повар-сушист</option>
|
||
<option value="Курьер">Курьер</option>
|
||
<option value="Администратор">Администратор</option>
|
||
<option value="Бармен">Бармен</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="font-semibold text-krona-primary">Ставка в час (₽):</label>
|
||
<input
|
||
type="number"
|
||
value={newEmpRate}
|
||
onChange={(e) => setNewEmpRate(Number(e.target.value))}
|
||
className="w-full p-2 bg-krona-workspace border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="font-semibold text-krona-primary">Контактный телефон:</label>
|
||
<input
|
||
type="text"
|
||
value={newEmpPhone}
|
||
onChange={(e) => setNewEmpPhone(e.target.value)}
|
||
className="w-full p-2 bg-krona-workspace border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
{/* Modal: Edit Shift */}
|
||
{editingShift && (
|
||
<Modal
|
||
isOpen={Boolean(editingShift)}
|
||
onClose={() => setEditingShift(null)}
|
||
title={`Назначение смены: ${editingShift.day}`}
|
||
subtitle={`Сотрудник: ${employees.find((e) => e.id === editingShift.empId)?.name}`}
|
||
maxWidth="sm"
|
||
footer={
|
||
<div className="flex gap-2 w-full justify-between">
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
onClick={() => {
|
||
setEditingShift({ ...editingShift, shiftText: 'Выходной', hours: 0 });
|
||
setTimeout(handleSaveShift, 50);
|
||
}}
|
||
>
|
||
Сделать выходным
|
||
</Button>
|
||
<div className="flex gap-2">
|
||
<Button variant="secondary" onClick={() => setEditingShift(null)}>
|
||
Отмена
|
||
</Button>
|
||
<Button variant="primary" onClick={handleSaveShift}>
|
||
Сохранить смену
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-3 text-xs">
|
||
<div className="space-y-1">
|
||
<label className="font-semibold text-krona-primary">Время смены:</label>
|
||
<select
|
||
value={editingShift.shiftText}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
let hrs = 12;
|
||
if (val.includes('09:00 - 23:00')) hrs = 14;
|
||
else if (val.includes('10:00 - 18:00')) hrs = 8;
|
||
else if (val === 'Выходной') hrs = 0;
|
||
setEditingShift({ ...editingShift, shiftText: val, hours: hrs });
|
||
}}
|
||
className="w-full p-2 bg-krona-workspace border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||
>
|
||
<option value="10:00 - 22:00">10:00 - 22:00 (12 часов, норма)</option>
|
||
<option value="11:00 - 23:00">11:00 - 23:00 (12 часов, норма)</option>
|
||
<option value="12:00 - 00:00">12:00 - 00:00 (12 часов, норма)</option>
|
||
<option value="10:00 - 18:00">10:00 - 18:00 (8 часов, сокращенная)</option>
|
||
<option value="09:00 - 23:00">09:00 - 23:00 (14 часов, переработка!)</option>
|
||
<option value="Выходной">Выходной (0 часов)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{editingShift.hours > 12 && (
|
||
<div className="p-2.5 bg-rose-500/10 border border-rose-500/30 rounded-lg flex items-center gap-2 text-rose-700 dark:text-rose-300">
|
||
<AlertTriangle className="w-4 h-4 shrink-0 text-rose-600" />
|
||
<span>
|
||
Внимание: смена длится {editingShift.hours} часов. Согласно Трудовому кодексу, стандартная смена не должна превышать 12 часов.
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|