krona-front/src/features/employees/EmployeesModule.tsx

547 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback } from 'react';
import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData';
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
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('');
// Load persisted employees and shifts on mount
useEffect(() => {
async function loadData() {
try {
const savedEmp = await getStoreItem<{ key: string; employees: EmployeeEntity[] }>('settings', 'krona_persisted_employees');
if (savedEmp?.employees && Array.isArray(savedEmp.employees) && savedEmp.employees.length > 0) {
setEmployees(savedEmp.employees);
}
const savedShifts = await getStoreItem<{ key: string; shifts: ShiftPlanItem[] }>('settings', 'krona_persisted_shifts');
if (savedShifts?.shifts && Array.isArray(savedShifts.shifts) && savedShifts.shifts.length > 0) {
setShifts(savedShifts.shifts);
}
} catch {
// Keep initial
}
}
loadData();
}, []);
const persistEmployees = useCallback((updated: EmployeeEntity[]) => {
setEmployees(updated);
void putStoreItem('settings', { key: 'krona_persisted_employees', employees: updated }).catch(() => {});
}, []);
const persistShifts = useCallback((updated: ShiftPlanItem[]) => {
setShifts(updated);
void putStoreItem('settings', { key: 'krona_persisted_shifts', shifts: updated }).catch(() => {});
}, []);
// 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,
};
const updatedEmployees = [newEmp, ...employees];
persistEmployees(updatedEmployees);
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;
const filtered = shifts.filter(
(s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day)
);
const updatedShifts = (editingShift.shiftText === 'Выходной' || editingShift.hours === 0)
? filtered
: [
...filtered,
{
employeeId: editingShift.empId,
day: editingShift.day,
shiftText: editingShift.shiftText,
hours: editingShift.hours,
hasConflict,
conflictReason,
},
];
persistShifts(updatedShifts);
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>
);
};