= {};
+ Object.entries(columnMapping).forEach(([csvCol, targetField]) => {
+ if (targetField) {
+ mapped[targetField] = row[csvCol] || '';
+ }
+ });
+
+ const errors: string[] = [];
+
+ if (entityType === 'contact') {
+ if (!mapped.firstName?.trim()) {
+ errors.push('Имя обязательно');
+ }
+ if (!mapped.lastName?.trim()) {
+ errors.push('Фамилия обязательна');
+ }
+ if (mapped.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(mapped.email)) {
+ errors.push(`Некорректный email: "${mapped.email}"`);
+ }
+ } else {
+ if (!mapped.sku?.trim()) {
+ errors.push('Артикул (SKU) обязателен');
+ }
+ if (!mapped.name?.trim()) {
+ errors.push('Наименование обязательно');
+ }
+ if (mapped.price) {
+ const numPrice = Number(mapped.price);
+ if (isNaN(numPrice) || numPrice <= 0) {
+ errors.push(`Цена должна быть положительным числом: "${mapped.price}"`);
+ }
+ } else {
+ errors.push('Цена обязательна');
+ }
+ }
+
+ return {
+ rowIndex: idx + 1,
+ raw: row,
+ mapped,
+ isValid: errors.length === 0,
+ errors,
+ };
+ });
+
+ const validCount = parsedRows.filter((r) => r.isValid).length;
+ const errorCount = parsedRows.filter((r) => !r.isValid).length;
+
+ const handleExecuteImport = async () => {
+ setIsImporting(true);
+ try {
+ const rowsToImport = skipErrors ? parsedRows.filter((r) => r.isValid) : parsedRows;
+ let count = 0;
+
+ if (entityType === 'contact') {
+ for (const r of rowsToImport) {
+ await crmService.contacts.create({
+ firstName: r.mapped.firstName || 'Контакт',
+ lastName: r.mapped.lastName || '',
+ phone: r.mapped.phone || '',
+ email: r.mapped.email || '',
+ roles: ['client'],
+ status: 'active',
+ });
+ count++;
+ }
+ } else {
+ for (const r of rowsToImport) {
+ const priceMinor = Math.round(Number(r.mapped.price) || 10000);
+ await commerceService.products.create({
+ sku: r.mapped.sku || `SKU-${Date.now()}`,
+ name: r.mapped.name || 'Товар',
+ categoryId: 'cat_default',
+ isService: false,
+ trackInventory: true,
+ status: 'active',
+ price: { minor: priceMinor, currency: 'RUB' },
+ costPrice: { minor: Math.round(priceMinor * 0.6), currency: 'RUB' },
+ unit: r.mapped.unit || 'шт',
+ });
+ count++;
+ }
+ }
+
+ setImportedCount(count);
+ setStep(4);
+ showToast(`Успешно импортировано ${count} записей в IndexedDB!`, 'success');
+ if (onComplete) {
+ onComplete(count);
+ }
+ } catch (err) {
+ showToast(`Ошибка импорта: ${err instanceof Error ? err.message : String(err)}`, 'error');
+ } finally {
+ setIsImporting(false);
+ }
+ };
+
+ const targetFields = entityType === 'contact' ? CONTACT_FIELDS : PRODUCT_FIELDS;
+
+ return (
+
+ {/* Header & Steps Indicator */}
+
+
+
+
+ Мастер импорта данных из CSV
+
+
+ {entityType === 'contact' ? 'Контакты CRM' : 'Товары'}
+
+
+
+ Пакетная загрузка записей с проверкой форматов, сопоставлением колонок и отчетом валидации
+
+
+
+ {/* Step circles */}
+
+ {[
+ { num: 1, label: 'Файл' },
+ { num: 2, label: 'Сопоставление' },
+ { num: 3, label: 'Валидация' },
+ { num: 4, label: 'Готово' },
+ ].map((s) => (
+
+
s.num
+ ? 'bg-[#2F684C]/20 text-krona-green'
+ : 'bg-krona-workspace text-krona-secondary border border-krona-border'
+ }`}
+ >
+ {step > s.num ? : s.num}
+
+
+ {s.label}
+
+ {s.num < 4 &&
}
+
+ ))}
+
+
+
+ {/* STEP 1: Upload / Load Sample */}
+ {step === 1 && (
+
+
+
+
+ Целевая сущность
+
+
+
setEntityType('contact')}
+ className={`flex-1 p-3 rounded-lg border text-left text-xs transition-all ${
+ entityType === 'contact'
+ ? 'border-krona-green bg-krona-green/10 text-krona-primary font-semibold'
+ : 'border-krona-border bg-krona-workspace text-krona-secondary hover:border-krona-border/80'
+ }`}
+ >
+ Контакты
+
+ Физлица, клиенты, сотрудники контрагентов
+
+
+
setEntityType('product')}
+ className={`flex-1 p-3 rounded-lg border text-left text-xs transition-all ${
+ entityType === 'product'
+ ? 'border-krona-green bg-krona-green/10 text-krona-primary font-semibold'
+ : 'border-krona-border bg-krona-workspace text-krona-secondary hover:border-krona-border/80'
+ }`}
+ >
+ Товары
+
+ Номенклатура, артикулы, цены и остатки
+
+
+
+
+
+
+
+ Быстрый старт с образцами данных
+
+
+ handleLoadSample('contact')}
+ >
+
+ Загрузить образец контактов (с корректными и ошибочными строками)
+
+ handleLoadSample('product')}
+ >
+
+ Загрузить образец товаров (с корректными и ошибочными строками)
+
+
+
+
+
+ {/* Drag & Drop File Zone */}
+
+
+
+ Перетащите сюда файл .csv или выберите на диске
+
+
+ Поддерживается разделитель запятая (,), кодировка UTF-8
+
+
+
+ Выбрать файл на компьютере
+
+
+
+
+
+ )}
+
+ {/* STEP 2: Column Mapping */}
+ {step === 2 && (
+
+
+
+ Файл:
+ {fileName}
+ Строк:
+ {rawRows.length}
+
+
setStep(1)}>
+ Выбрать другой файл
+
+
+
+
+
+
+
+ Колонка в CSV-файле
+ Пример значения (первая строка)
+ Поле в системе Krona
+
+
+
+ {headers.map((h) => (
+
+ {h}
+
+ {rawRows[0]?.[h] || '—'}
+
+
+
+ setColumnMapping({ ...columnMapping, [h]: e.target.value })
+ }
+ className="w-64 px-2.5 py-1 text-xs bg-krona-workspace border border-krona-border rounded-md text-krona-primary focus:outline-none focus:border-krona-green"
+ >
+ -- Не импортировать --
+ {targetFields.map((f) => (
+
+ {f.label} {f.required ? '*' : ''}
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+
+
setStep(1)}>
+ Назад
+
+
setStep(3)}>
+ Перейти к валидации
+
+
+
+ )}
+
+ {/* STEP 3: Validation Report & Error Highlighting */}
+ {step === 3 && (
+
+ {/* Summary KPIs */}
+
+
+
+ Всего строк в файле
+
+
+ {parsedRows.length}
+
+
+
+
+ Корректные строки
+
+
+ {validCount}
+
+
+
+
+
+ {/* Validation options */}
+
+ setSkipErrors(e.target.checked)}
+ className="rounded border-krona-border text-krona-green focus:ring-krona-green"
+ />
+
+ Импортировать только корректные строки ({validCount} из {parsedRows.length}), пропуская ошибочные
+
+
+
+ {/* Detailed Preview Table with Row Status */}
+
+
+
+
+ №
+ Статус
+ {targetFields.map((f) => (
+
+ {f.label}
+
+ ))}
+ Ошибки / Замечания
+
+
+
+ {parsedRows.map((r) => (
+
+ {r.rowIndex}
+
+ {r.isValid ? (
+
+ Корректно
+
+ ) : (
+
+ Ошибка
+
+ )}
+
+ {targetFields.map((f) => (
+
+ {r.mapped[f.key] || — }
+
+ ))}
+
+ {r.errors.length > 0 ? (
+
+ {r.errors.map((err, i) => (
+ {err}
+ ))}
+
+ ) : (
+
+ Проверка пройдена
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
setStep(2)}>
+ Назад
+
+
+ {isImporting ? (
+ <>
+ Выполняется импорт...
+ >
+ ) : (
+ <>
+ Импортировать {skipErrors ? validCount : parsedRows.length} записей в базу
+ >
+ )}
+
+
+
+ )}
+
+ {/* STEP 4: Success Summary */}
+ {step === 4 && (
+
+
+
+
+
+
+ Импорт успешно завершен!
+
+
+ В локальную базу данных IndexedDB успешно добавлено{' '}
+ {importedCount} записей{' '}
+ ({entityType === 'contact' ? 'контактов' : 'товаров'}). Данные мгновенно доступны для использования во всех модулях платформы.
+
+
+
+
+ {onClose && (
+
+ Закрыть мастер
+
+ )}
+ {
+ setStep(1);
+ setCsvContent('');
+ setFileName('');
+ setRawRows([]);
+ }}
+ >
+ Импортировать еще файл
+
+
+
+ )}
+
+ );
+};
diff --git a/src/features/dashboard/DynamicDashboard.tsx b/src/features/dashboard/DynamicDashboard.tsx
index eb1c082..4ede6ec 100644
--- a/src/features/dashboard/DynamicDashboard.tsx
+++ b/src/features/dashboard/DynamicDashboard.tsx
@@ -3,6 +3,7 @@ import { useDemoStore } from '../../mock/store';
import { StatCard } from '../../shared/components/StatCard';
import { Button } from '../../shared/components/Button';
import { Badge } from '../../shared/components/Badge';
+import { MaturityBadge } from '../../shared/ui/MaturityBadge';
import { formatCurrency, formatNumber } from '../../shared/utils/formatters';
import {
TrendingUp,
@@ -462,6 +463,7 @@ export const DynamicDashboard: React.FC = () => {
Операционный центр • {profile.companyName}
{profile.category}
+
Локация: {activeLocation} • Контур реального времени синхронизирован
diff --git a/src/features/employees/EmployeesModule.tsx b/src/features/employees/EmployeesModule.tsx
index a9fec12..edbff41 100644
--- a/src/features/employees/EmployeesModule.tsx
+++ b/src/features/employees/EmployeesModule.tsx
@@ -2,78 +2,532 @@ import React, { useState } from 'react';
import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
+import { MaturityBadge } from '../../shared/ui/MaturityBadge';
+import { Modal } from '../../shared/components/Modal';
+import { useToast } from '../../shared/ui/useToast';
import { formatCurrency } from '../../shared/utils/formatters';
-import { UserCheck, Clock, Star, Phone, Plus, Calendar } from 'lucide-react';
+import {
+ UserCheck,
+ Clock,
+ Star,
+ Phone,
+ Plus,
+ Calendar,
+ Search,
+ Filter,
+ AlertTriangle,
+ CheckCircle2,
+ Users,
+ Shield,
+ Briefcase,
+ Edit2,
+} 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(INITIAL_EMPLOYEES);
+ const [shifts, setShifts] = useState(INITIAL_SHIFTS);
+ const [search, setSearch] = useState('');
+ const [departmentFilter, setDepartmentFilter] = 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 departments = ['Все отделы', 'Кухня', 'Зал', 'Доставка', 'Менеджмент'];
+
+ 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 (
+ {/* Header */}
-
- Сотрудники, смены и табель учета рабочего времени
-
-
- Управление графиками работы, ставками, опозданиями и персональным KPI
+
+
+ Сотрудники, графики и табель учета смен
+
+
+
+
+ Управление персоналом, планировщик смен, контроль переработок и предупреждения о конфликтах расписания
-
+
+ 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})
+
+ 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 && (
+
+ {conflictCount}
+
+ )}
+
+
+
+ setIsNewEmpModalOpen(true)}>
Добавить сотрудника
-
- {employees.map((emp) => (
-
-
-
-
- {emp.avatarInitials}
-
-
-
{emp.name}
- {emp.role}
-
-
+ {/* 1. EMPLOYEES TAB */}
+ {activeTab === 'employees' && (
+
+
+
+
+ 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"
+ />
+
-
- {emp.status === 'on_shift' ? 'На смене' : 'Выходной'}
+
+
+ На смене: {employees.filter((e) => e.status === 'on_shift').length}
+
+
+ Выходной: {employees.filter((e) => e.status !== 'on_shift').length}
+
-
-
- График сегодня:
-
-
- {emp.shiftHours}
-
-
-
-
Рейтинг качества:
-
-
- {emp.rating} / 5.0
-
+
+ {filteredEmployees.map((emp) => (
+
+
+
+
+ {emp.avatarInitials}
+
+
+
{emp.name}
+ {emp.role}
+
+
+
+
+ {emp.status === 'on_shift' ? 'На смене' : 'Выходной'}
+
+
+
+
+
+ График сегодня:
+
+
+ {emp.shiftHours}
+
+
+
+ Рейтинг качества:
+
+
+ {emp.rating} / 5.0
+
+
+
+
+
+
+
{
+ 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' ? 'Завершить смену' : 'Вывести на смену'}
+
+
+ ))}
+
+
+ )}
+
+ {/* 2. SCHEDULE PLANNER TAB */}
+ {activeTab === 'schedule' && (
+
+
+
+
+
+ Недельный график смен (07.09.2026 — 13.09.2026)
+
+ {conflictCount > 0 ? (
+
+ {conflictCount} конфликт расписания!
+
+ ) : (
+ Конфликтов нет
+ )}
-
-
Телефон:
-
{emp.phone}
+
+ Кликните по ячейке для назначения или изменения времени смены сотрудника
+
+
+
+
+
+
+
+
+ Сотрудник / Должность
+ {DAYS_OF_WEEK.map((d) => (
+
+ {d}
+
+ ))}
+
+ Часы / нед.
+
+
+
+
+ {employees.map((emp) => {
+ const empShifts = shifts.filter((s) => s.employeeId === emp.id);
+ const totalHours = empShifts.reduce((acc, s) => acc + s.hours, 0);
+
+ return (
+
+
+ {emp.name}
+ {emp.role}
+
+
+ {DAYS_OF_WEEK.map((day) => {
+ const shift = empShifts.find((s) => s.day === day);
+
+ return (
+ {
+ 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 ? (
+
+
{shift.shiftText}
+
{shift.hours} ч
+ {shift.hasConflict && (
+
+ )}
+
+ ) : (
+
+ —
+
+ )}
+
+ );
+ })}
+
+
+ 40 ? 'text-amber-600' : 'text-krona-primary'}>
+ {totalHours} ч
+
+
+
+ );
+ })}
+
+
- ))}
-
+
+ )}
+
+ {/* Modal: New Employee */}
+ {isNewEmpModalOpen && (
+
setIsNewEmpModalOpen(false)}
+ title="Новый сотрудник"
+ subtitle="Создание профиля и назначение должности"
+ maxWidth="sm"
+ footer={
+
+ setIsNewEmpModalOpen(false)}>
+ Отмена
+
+
+ Сохранить
+
+
+ }
+ >
+
+
+ ФИО сотрудника:
+ 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"
+ />
+
+
+
+
+ Должность:
+ 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"
+ >
+ Официант
+ Су-шеф
+ Повар-сушист
+ Курьер
+ Администратор
+ Бармен
+
+
+
+
+ Ставка в час (₽):
+ 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"
+ />
+
+
+
+
+ Контактный телефон:
+ 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"
+ />
+
+
+
+ )}
+
+ {/* Modal: Edit Shift */}
+ {editingShift && (
+
setEditingShift(null)}
+ title={`Назначение смены: ${editingShift.day}`}
+ subtitle={`Сотрудник: ${employees.find((e) => e.id === editingShift.empId)?.name}`}
+ maxWidth="sm"
+ footer={
+
+
{
+ setEditingShift({ ...editingShift, shiftText: 'Выходной', hours: 0 });
+ setTimeout(handleSaveShift, 50);
+ }}
+ >
+ Сделать выходным
+
+
+ setEditingShift(null)}>
+ Отмена
+
+
+ Сохранить смену
+
+
+
+ }
+ >
+
+
+ Время смены:
+ {
+ 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"
+ >
+ 10:00 - 22:00 (12 часов, норма)
+ 11:00 - 23:00 (12 часов, норма)
+ 12:00 - 00:00 (12 часов, норма)
+ 10:00 - 18:00 (8 часов, сокращенная)
+ 09:00 - 23:00 (14 часов, переработка!)
+ Выходной (0 часов)
+
+
+
+ {editingShift.hours > 12 && (
+
+
+
+ Внимание: смена длится {editingShift.hours} часов. Согласно Трудовому кодексу, стандартная смена не должна превышать 12 часов.
+
+
+ )}
+
+
+ )}
);
};
diff --git a/src/features/finance/FinanceModule.tsx b/src/features/finance/FinanceModule.tsx
index 4f6436a..e8bb8a6 100644
--- a/src/features/finance/FinanceModule.tsx
+++ b/src/features/finance/FinanceModule.tsx
@@ -1,205 +1,563 @@
-import React, { useState } from 'react';
-import { INITIAL_FINANCE, FinanceEntry } from '../../mock/mockData';
-import { Badge } from '../../shared/components/Badge';
-import { Button } from '../../shared/components/Button';
-import { StatCard } from '../../shared/components/StatCard';
-import { Modal } from '../../shared/components/Modal';
-import { Input } from '../../shared/components/Input';
-import { formatCurrency } from '../../shared/utils/formatters';
-import { BadgeDollarSign, TrendingUp, TrendingDown, Plus, Wallet, CreditCard } from 'lucide-react';
+import React, { useState, useMemo } from 'react';
+import { useFinance } from '../../services/hooks/useFinance';
+import { TransactionCategory } from '../../services/contracts/finance';
+import { Badge } from '../../shared/ui/Badge';
+import { MaturityBadge } from '../../shared/ui/MaturityBadge';
+import { Button } from '../../shared/ui/Button';
+import { Modal } from '../../shared/ui/Modal';
+import { useToast } from '../../shared/ui/useToast';
+import { formatCurrencyMinor } from '../../shared/lib/currency';
+import { formatDateTime } from '../../shared/lib/date';
+import {
+ BadgeDollarSign,
+ TrendingUp,
+ TrendingDown,
+ Plus,
+ Wallet,
+ CreditCard,
+ Building,
+ ArrowUpRight,
+ ArrowDownLeft,
+ PieChart,
+ Search,
+} from 'lucide-react';
export const FinanceModule: React.FC = () => {
- const [entries, setEntries] = useState
(INITIAL_FINANCE);
- const [isAddModalOpen, setIsAddModalOpen] = useState(false);
- const [entryType, setEntryType] = useState<'income' | 'expense'>('expense');
- const [category, setCategory] = useState('');
- const [amount, setAmount] = useState('');
- const [account, setAccount] = useState<'Касса Горский' | 'Расчетный счет (Точка)'>('Касса Горский');
- const [comment, setComment] = useState('');
+ const { showToast } = useToast();
+ const {
+ accounts,
+ transactions,
+ pnlMetrics,
+ createManualTransaction,
+ } = useFinance();
- const totalIncome = entries.filter(e => e.type === 'income').reduce((s, e) => s + e.amount, 0);
- const totalExpense = entries.filter(e => e.type === 'expense').reduce((s, e) => s + e.amount, 0);
- const netCashFlow = totalIncome - totalExpense;
+ const [activeTab, setActiveTab] = useState<'transactions' | 'pnl' | 'accounts'>('transactions');
+ const [search, setSearch] = useState('');
+ const [filterType, setFilterType] = useState<'all' | 'inflow' | 'outflow'>('all');
- const handleAddEntry = (e: React.FormEvent) => {
+ // Modal state
+ const [isManualModalOpen, setIsManualModalOpen] = useState(false);
+ const [manualType, setManualType] = useState<'inflow' | 'outflow'>('inflow');
+ const [manualCategory, setManualCategory] = useState('sales_revenue');
+ const [manualAmountMinor, setManualAmountMinor] = useState(1500000);
+ const [manualAccountId, setManualAccountId] = useState('');
+ const [manualDescription, setManualDescription] = useState('');
+
+ // Transactions calculations
+ const totalInflowMinor = useMemo(
+ () => transactions.filter((t) => t.type === 'inflow').reduce((s, t) => s + t.amount.minor, 0),
+ [transactions]
+ );
+
+ const totalOutflowMinor = useMemo(
+ () => transactions.filter((t) => t.type === 'outflow').reduce((s, t) => s + t.amount.minor, 0),
+ [transactions]
+ );
+
+ const netCashFlowMinor = totalInflowMinor - totalOutflowMinor;
+
+ const filteredTransactions = useMemo(() => {
+ return [...transactions]
+ .filter((t) => {
+ const matchType = filterType === 'all' || t.type === filterType;
+ const matchSearch =
+ !search ||
+ t.description.toLowerCase().includes(search.toLowerCase()) ||
+ t.transactionNumber.toLowerCase().includes(search.toLowerCase()) ||
+ t.accountName.toLowerCase().includes(search.toLowerCase());
+ return matchType && matchSearch;
+ })
+ .sort((a, b) => (b.timestamp > a.timestamp ? 1 : -1));
+ }, [transactions, filterType, search]);
+
+ const handleCreateManualTx = async (e: React.FormEvent) => {
e.preventDefault();
- const newEnt: FinanceEntry = {
- id: `fin-${Date.now()}`,
- date: new Date().toLocaleDateString('ru-RU'),
- type: entryType,
- category: category || (entryType === 'income' ? 'Прочая выручка' : 'Хоз. расходы'),
- amount: Number(amount) || 0,
- account,
- comment,
- status: 'completed',
- };
- setEntries([newEnt, ...entries]);
- setIsAddModalOpen(false);
- setAmount('');
- setCategory('');
- setComment('');
+ const accId = manualAccountId || accounts[0]?.id || 'acc_default';
+
+ try {
+ await createManualTransaction(
+ manualType,
+ manualCategory,
+ { minor: manualAmountMinor, currency: 'RUB' },
+ accId,
+ manualDescription || (manualType === 'inflow' ? 'Внереализационный доход' : 'Хозяйственные расходы')
+ );
+
+ showToast('Финансовая проводка успешно записана в ДДС!', 'success');
+ setIsManualModalOpen(false);
+ setManualDescription('');
+ } catch (err) {
+ showToast(`Ошибка: ${err instanceof Error ? err.message : String(err)}`, 'error');
+ }
+ };
+
+ const getCategoryLabel = (category: TransactionCategory) => {
+ switch (category) {
+ case 'sales_revenue':
+ return 'Выручка от продаж (Заказ)';
+ case 'refund':
+ return 'Возврат клиенту (Заказ)';
+ case 'supplier_payment':
+ return 'Оплата поставщику';
+ case 'payroll':
+ return 'Заработная плата';
+ case 'rent':
+ return 'Аренда помещений';
+ case 'taxes':
+ return 'Налоги и сборы';
+ case 'utilities':
+ return 'Коммунальные услуги';
+ default:
+ return 'Прочие операции';
+ }
};
return (
+ {/* Header bar */}
-
- Финансы, кассовые смены и отчет ДДС
-
-
- Учет движения денежных средств (Cash Flow), безналичных счетов и инкассаций
+
+
+ Финансы, Казначейство & ДДС (Cash Flow)
+
+
+
+
+ Сквозной учет движения денежных средств (ДДС): автоматические платежи и возвраты по заказам, балансы расчетных счетов
+
{
+ setManualType('outflow');
+ setManualCategory('supplier_payment');
+ setIsManualModalOpen(true);
+ }}
+ >
+ Расходный ордер
+
{
- setEntryType('expense');
- setIsAddModalOpen(true);
+ setManualType('inflow');
+ setManualCategory('sales_revenue');
+ setIsManualModalOpen(true);
}}
>
-
- Расходный ордер
-
-
{
- setEntryType('income');
- setIsAddModalOpen(true);
- }}
- >
-
- Приход денег
+ Приход денег
- {/* Financial KPIs */}
-
-
}
- />
-
}
- />
-
}
- />
+ {/* KPI Stats Cards */}
+
+
+
+
Поступления (Inflow)
+
+
+
+
+
+ {formatCurrencyMinor(totalInflowMinor)}
+
+
Оплаты заказов и выручка
+
+
+
+
+
Выплаты (Outflow)
+
+
+
+
+
+ {formatCurrencyMinor(totalOutflowMinor)}
+
+
Возвраты, закупки, расходы
+
+
+
+
+
Чистый поток (Net Cash Flow)
+
+
+
+
+
= 0 ? 'text-krona-green' : 'text-rose-600 dark:text-rose-400'}`}>
+ {formatCurrencyMinor(netCashFlowMinor)}
+
+
Сальдо движения денег
+
+
+
+
+
Рентабельность (P&L Margin)
+
+
+
+ {pnlMetrics ? `${pnlMetrics.operatingMarginPercent}%` : '58.0%'}
+
+
Маржинальность операций
+
- {/* Transactions Table */}
-
-
- Журнал кассовых и банковских операций
-
+ {/* Account Balances Overview */}
+
+ {accounts.map((acc) => (
+
+
+
+ {acc.type === 'bank' ? (
+
+ ) : acc.type === 'cash_desk' ? (
+
+ ) : (
+
+ )}
+
+
+
{acc.name}
+
+ {acc.type === 'bank' ? 'Расчетный счет' : acc.type === 'cash_desk' ? 'Операционная касса' : 'Эквайринг'}
+
+
+
+
+
+ {formatCurrencyMinor(acc.balance.minor)}
+
+
+ {acc.isDefault ? 'Основной' : 'Активен'}
+
+
+
+ ))}
+
-
-
-
+ {/* Navigation Tabs */}
+
+
+ setActiveTab('transactions')}
+ className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
+ activeTab === 'transactions'
+ ? 'bg-krona-green text-white shadow-sm'
+ : 'bg-krona-surface text-krona-secondary hover:text-krona-primary border border-krona-border'
+ }`}
+ >
+ Движение денежных средств (ДДС) ({transactions.length})
+
+ setActiveTab('pnl')}
+ className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
+ activeTab === 'pnl'
+ ? 'bg-krona-green text-white shadow-sm'
+ : 'bg-krona-surface text-krona-secondary hover:text-krona-primary border border-krona-border'
+ }`}
+ >
+ Отчет о прибылях и убытках (P&L)
+
+ setActiveTab('accounts')}
+ className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
+ activeTab === 'accounts'
+ ? 'bg-krona-green text-white shadow-sm'
+ : 'bg-krona-surface text-krona-secondary hover:text-krona-primary border border-krona-border'
+ }`}
+ >
+ Банковские счета и кассы ({accounts.length})
+
+
+
+ {activeTab === 'transactions' && (
+
+
setFilterType(e.target.value as 'all' | 'inflow' | 'outflow')}
+ className="px-2.5 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary focus:outline-none"
+ >
+ Все операции
+ Только приход (+)
+ Только расход (-)
+
+
+
+
+ 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 focus:outline-none"
+ />
+
+
+ )}
+
+
+ {/* ---------------------------------------------------- */}
+ {/* 1. CASH FLOW TRANSACTIONS TABLE */}
+ {/* ---------------------------------------------------- */}
+ {activeTab === 'transactions' && (
+
+
+
- Дата
- Тип
- Статья ДДС
+ Номер транзакции
+ Дата и время
+ Тип операции
+ Статья / Категория
Счет / Касса
- Комментарий
+ Основание / Описание
Сумма
-
- {entries.map((entry) => (
-
- {entry.date}
-
-
- {entry.type === 'income' ? 'Поступление' : 'Расход'}
-
+
+ {filteredTransactions.map((tx) => (
+
+
+ {tx.transactionNumber}
- {entry.category}
- {entry.account}
- {entry.comment}
-
- {entry.type === 'income' ? '+' : '-'}{formatCurrency(entry.amount)}
+
+ {formatDateTime(tx.timestamp)}
+
+
+ {tx.type === 'inflow' ? (
+
+ Приход (+)
+
+ ) : (
+
+ Расход (-)
+
+ )}
+
+
+ {getCategoryLabel(tx.category)}
+
+ {tx.accountName}
+ {tx.description}
+
+
+ {tx.type === 'inflow' ? '+' : '-'}
+ {formatCurrencyMinor(tx.amount.minor)}
+
+
+
+ ))}
+
+ {filteredTransactions.length === 0 && (
+
+
+ Финансовые проводки не найдены
+
+
+ )}
+
+
+
+ )}
+
+ {/* ---------------------------------------------------- */}
+ {/* 2. P&L FINANCIAL STATEMENT VIEW */}
+ {/* ---------------------------------------------------- */}
+ {activeTab === 'pnl' && (
+
+
+
+ Отчет о финансовых результатах (P&L / ОПУ)
+
+
+ Текущий отчетный период на базе реальных проведенных операций в системе Krona
+
+
+
+
+
+ 1. Выручка от продаж (Revenue)
+
+ {formatCurrencyMinor(pnlMetrics?.revenue.minor || totalInflowMinor)}
+
+
+
+
+ Себестоимость проданных товаров (COGS, 42%)
+
+ -{formatCurrencyMinor(pnlMetrics?.costOfGoodsSold.minor || Math.round(totalInflowMinor * 0.42))}
+
+
+
+
+ 2. Валовая прибыль (Gross Profit)
+
+ {formatCurrencyMinor(pnlMetrics?.grossProfit.minor || Math.round(totalInflowMinor * 0.58))}
+
+
+
+
+ Операционные и административные расходы (OPEX)
+ -{formatCurrencyMinor(pnlMetrics?.operatingExpenses.minor || totalOutflowMinor)}
+
+
+
+
+
3. Чистая прибыль (Net Profit)
+
+ Рентабельность: {pnlMetrics?.operatingMarginPercent || 58.0}%
+
+
+
+ {formatCurrencyMinor(pnlMetrics?.netProfit.minor || (totalInflowMinor - totalOutflowMinor))}
+
+
+
+
+ )}
+
+ {/* ---------------------------------------------------- */}
+ {/* 3. ACCOUNTS DETAIL VIEW */}
+ {/* ---------------------------------------------------- */}
+ {activeTab === 'accounts' && (
+
+
+
+
+ Наименование счета
+ Тип счета
+ Банк
+ Номер счета
+ Текущий баланс
+ Статус
+
+
+
+ {accounts.map((acc) => (
+
+ {acc.name}
+
+ {acc.type === 'bank' ? 'Расчетный счет' : acc.type === 'cash_desk' ? 'Касса' : 'Эквайринг'}
+
+ {acc.bankName || 'ПАО Сбербанк'}
+ {acc.accountNumber || '40702810900000012345'}
+
+ {formatCurrencyMinor(acc.balance.minor)}
+
+
+
+ {acc.isDefault ? 'Основной' : 'Активен'}
+
))}
-
-
- {/* Add Finance Entry Modal */}
- {isAddModalOpen && (
- setIsAddModalOpen(false)}
- title={entryType === 'income' ? 'Внесение дохода' : 'Оформление расхода'}
- subtitle="Запись в финансовый журнал ДДС"
- maxWidth="sm"
- >
-
-
)}
+
+ {/* ---------------------------------------------------- */}
+ {/* MODAL: MANUAL TRANSACTION */}
+ {/* ---------------------------------------------------- */}
+ setIsManualModalOpen(false)}
+ title={manualType === 'inflow' ? 'Оформление прихода денежных средств' : 'Оформление расходного кассового ордера'}
+ size="md"
+ >
+
+
);
};
diff --git a/src/features/helpdesk/HelpDeskModule.tsx b/src/features/helpdesk/HelpDeskModule.tsx
index aa652d3..dea0e70 100644
--- a/src/features/helpdesk/HelpDeskModule.tsx
+++ b/src/features/helpdesk/HelpDeskModule.tsx
@@ -2,12 +2,65 @@ import React, { useState } from 'react';
import { INITIAL_TICKETS, TicketEntity } from '../../mock/mockData';
import { Badge } from '../../shared/components/Badge';
import { Button } from '../../shared/components/Button';
+import { MaturityBadge } from '../../shared/ui/MaturityBadge';
import { Modal } from '../../shared/components/Modal';
-import { Headset, AlertCircle, Clock, CheckCircle2, Sparkles, MessageSquare } from 'lucide-react';
+import { useToast } from '../../shared/ui/useToast';
+import {
+ Headset,
+ AlertCircle,
+ Clock,
+ CheckCircle2,
+ Sparkles,
+ MessageSquare,
+ UserCheck,
+ Send,
+ Lock,
+ Globe,
+ Filter,
+ Search,
+ Check,
+ AlertTriangle,
+} from 'lucide-react';
+
+interface ExtendedTicket extends Omit {
+ status: 'new' | 'in_progress' | 'waiting' | 'resolved' | 'closed';
+ internalNotes?: { text: string; author: string; time: string }[];
+ publicReplies?: { text: string; author: string; time: string }[];
+}
+
+const INITIAL_EXTENDED_TICKETS: ExtendedTicket[] = INITIAL_TICKETS.map((t, idx) => ({
+ ...t,
+ status: idx === 0 ? 'in_progress' : idx === 1 ? 'new' : 'resolved',
+ internalNotes: [
+ { text: 'Проверили чек в кассе — соусы действительно не были вложены комплектовщиком смены.', author: 'Менеджер смены', time: 'Вчера 16:35' },
+ ],
+ publicReplies: [
+ { text: 'Здравствуйте, Анна! Приносим искренние извинения за ситуацию.', author: 'Поддержка Кроны', time: 'Вчера 16:40' },
+ ],
+}));
+
+const ASSIGNEES = ['Алина Соколова', 'Артем Васильев', 'Екатерина Иванова', 'Сергей Петров (Старший менеджер)'];
export const HelpDeskModule: React.FC = () => {
- const [tickets, setTickets] = useState(INITIAL_TICKETS);
- const [selectedTicket, setSelectedTicket] = useState(null);
+ const { showToast } = useToast();
+ const [tickets, setTickets] = useState(INITIAL_EXTENDED_TICKETS);
+ const [selectedTicket, setSelectedTicket] = useState(null);
+ const [search, setSearch] = useState('');
+ const [filterStatus, setFilterStatus] = useState('all');
+
+ // Reply state in detail modal
+ const [noteType, setNoteType] = useState<'public' | 'internal'>('public');
+ const [replyText, setReplyText] = useState('');
+
+ const filteredTickets = tickets.filter((t) => {
+ const matchStatus = filterStatus === 'all' || t.status === filterStatus;
+ const matchSearch =
+ !search ||
+ t.subject.toLowerCase().includes(search.toLowerCase()) ||
+ t.clientName.toLowerCase().includes(search.toLowerCase()) ||
+ t.id.toLowerCase().includes(search.toLowerCase());
+ return matchStatus && matchSearch;
+ });
const getPriorityBadge = (p: TicketEntity['priority']) => {
switch (p) {
@@ -22,101 +75,271 @@ export const HelpDeskModule: React.FC = () => {
}
};
+ const getStatusBadge = (status: ExtendedTicket['status']) => {
+ switch (status) {
+ case 'new':
+ return Новый ;
+ case 'in_progress':
+ return В работе ;
+ case 'waiting':
+ return Ожидает ответа ;
+ case 'resolved':
+ return Решен ;
+ case 'closed':
+ return Закрыт ;
+ }
+ };
+
+ const getSlaBadge = (slaMinutes: number) => {
+ if (slaMinutes <= 0) {
+ return (
+
+ SLA: Нарушен!
+
+ );
+ }
+ if (slaMinutes < 15) {
+ return (
+
+ SLA: {slaMinutes} мин осталось
+
+ );
+ }
+ if (slaMinutes <= 60) {
+ return (
+
+ SLA: {slaMinutes} мин
+
+ );
+ }
+ return (
+
+ SLA: {Math.floor(slaMinutes / 60)}ч {slaMinutes % 60}м
+
+ );
+ };
+
+ const handleUpdateTicketStatus = (ticketId: string, nextStatus: ExtendedTicket['status']) => {
+ setTickets((prev) =>
+ prev.map((t) => (t.id === ticketId ? { ...t, status: nextStatus } : t))
+ );
+ if (selectedTicket && selectedTicket.id === ticketId) {
+ setSelectedTicket({ ...selectedTicket, status: nextStatus });
+ }
+ showToast({
+ title: 'Статус обновлен',
+ message: `Тикет переведен в статус «${nextStatus}»`,
+ type: 'success',
+ });
+ };
+
+ const handleUpdateAssignee = (ticketId: string, assignee: string) => {
+ setTickets((prev) =>
+ prev.map((t) => (t.id === ticketId ? { ...t, assignee } : t))
+ );
+ if (selectedTicket && selectedTicket.id === ticketId) {
+ setSelectedTicket({ ...selectedTicket, assignee });
+ }
+ showToast({
+ title: 'Ответственный назначен',
+ message: `Назначен сотрудник: ${assignee}`,
+ type: 'info',
+ });
+ };
+
+ const handleSendNote = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!replyText.trim() || !selectedTicket) return;
+
+ const newNote = {
+ text: replyText.trim(),
+ author: 'Вы (Оператор службы заботы)',
+ time: 'Только что',
+ };
+
+ const updatedTicket: ExtendedTicket = {
+ ...selectedTicket,
+ ...(noteType === 'internal'
+ ? { internalNotes: [...(selectedTicket.internalNotes || []), newNote] }
+ : { publicReplies: [...(selectedTicket.publicReplies || []), newNote] }),
+ };
+
+ setTickets((prev) => prev.map((t) => (t.id === selectedTicket.id ? updatedTicket : t)));
+ setSelectedTicket(updatedTicket);
+ setReplyText('');
+
+ showToast({
+ title: noteType === 'internal' ? 'Внутренняя заметка сохранена' : 'Ответ отправлен клиенту',
+ message: noteType === 'internal' ? 'Заметка видна только сотрудникам' : 'Сообщение отправлено в канал связи гостя',
+ type: 'success',
+ });
+ };
+
return (
+ {/* Header */}
-
- HelpDesk & Центр решения обращений клиентов
-
-
- Очередь инцидентов, контроль SLA ответа, база знаний и AI-подсказки
+
+
+ HelpDesk & Центр решения обращений и жалоб гостей
+
+
+
+
+ Очередь обращений, контроль жестких SLA нормативов, база знаний и AI-рекомендации
- 1 срочный инцидент
- SLA 98.4%
+
+ 1 срочный тикет
+
+
+ SLA 98.4% выполнения
+
+ {/* Filters & Search bar */}
+
+
+ {[
+ { id: 'all', label: 'Все обращения' },
+ { id: 'new', label: 'Новые' },
+ { id: 'in_progress', label: 'В работе' },
+ { id: 'waiting', label: 'Ожидают' },
+ { id: 'resolved', label: 'Решенные' },
+ ].map((f) => (
+ setFilterStatus(f.id)}
+ className={`px-3 py-1.5 rounded-lg font-medium transition-colors whitespace-nowrap ${
+ filterStatus === f.id
+ ? 'bg-[#2F684C] text-white shadow-sm'
+ : 'bg-krona-surface text-krona-secondary hover:text-krona-primary border border-krona-border'
+ }`}
+ >
+ {f.label}
+
+ ))}
+
+
+
+
+ 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"
+ />
+
+
+
+ {/* Tickets Grid */}
- {tickets.map((t) => (
+ {filteredTickets.map((t) => (
setSelectedTicket(t)}
- className="p-4 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-border-strong shadow-krona-soft cursor-pointer transition-all space-y-3"
+ className="p-4 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-border-strong shadow-krona-soft cursor-pointer transition-all flex flex-col justify-between space-y-3"
>
-
- {t.id}
- {getPriorityBadge(t.priority)}
-
-
-
- {t.subject}
-
-
-
-
-
Клиент:
-
{t.clientName}
+
+
+
{t.id}
+
+ {getStatusBadge(t.status)}
+ {getPriorityBadge(t.priority)}
+
-
- Канал обращения:
- {t.channel}
-
-
-
Остаток по SLA:
-
- {t.slaMinutesLeft} мин
-
+
+
+ {t.subject}
+
+
+
+
+ Клиент:
+ {t.clientName}
+
+
+ Канал:
+ {t.channel}
+
+
+ Ответственный:
+ {t.assignee}
+
-
-
- Разрешить инцидент →
-
+
+ {getSlaBadge(t.slaMinutesLeft)}
+
+ Открыть →
+
))}
- {/* Ticket Modal with AI suggestion */}
+ {/* Ticket Modal with Internal Notes & SLA Details */}
{selectedTicket && (
setSelectedTicket(null)}
- title={`Тикет: ${selectedTicket.id}`}
- subtitle={`Клиент: ${selectedTicket.clientName} (${selectedTicket.createdTime})`}
- maxWidth="md"
- footer={
-
- setSelectedTicket(null)}>
- Закрыть
-
- {
- setTickets(tickets.filter(x => x.id !== selectedTicket.id));
- setSelectedTicket(null);
- }}
- >
- Отметить решенным
-
-
- }
+ title={`Обращение: ${selectedTicket.id}`}
+ subtitle={`Клиент: ${selectedTicket.clientName} • ${selectedTicket.createdTime}`}
+ maxWidth="lg"
>
-
-
-
{selectedTicket.subject}
-
- Обращение поступило через {selectedTicket.channel} в {selectedTicket.createdTime}. Назначено на сотрудника: {selectedTicket.assignee}.
+
+ {/* Top Status & Assignment Row */}
+
+
+ Статус инцидента:
+ handleUpdateTicketStatus(selectedTicket.id, e.target.value as any)}
+ className="w-full p-1.5 bg-krona-surface border border-krona-border rounded-lg text-xs font-semibold text-krona-primary focus:outline-none"
+ >
+ Новый
+ В работе
+ Ожидает ответа
+ Решен
+ Закрыт
+
+
+
+
+ Назначить сотрудника:
+ handleUpdateAssignee(selectedTicket.id, e.target.value)}
+ className="w-full p-1.5 bg-krona-surface border border-krona-border rounded-lg text-xs font-semibold text-krona-primary focus:outline-none"
+ >
+ {ASSIGNEES.map((a) => (
+ {a}
+ ))}
+
+
+
+
+
Контроль SLA:
+
{getSlaBadge(selectedTicket.slaMinutesLeft)}
+
+
+
+ {/* Subject and description */}
+
+
{selectedTicket.subject}
+
+ Обращение зарегистрировано через канал {selectedTicket.channel}. Заказ привязан к локации: Красный пр-т 186а.
{/* AI Suggestion Box */}
-
+
Рекомендация Krona AI:
@@ -125,6 +348,76 @@ export const HelpDeskModule: React.FC = () => {
Начислить клиенту 300 бонусных баллов с извинениями и отправить курьера с недостающими соусами и комплиментом (морс 0.5л). Клиент относится к категории VIP (LTV 84 200 ₽).
+
+ {/* Public Replies & Internal Notes history */}
+
+
История переписки и служебных заметок:
+
+ {selectedTicket.publicReplies?.map((note, idx) => (
+
+
+ Ответ гостю ({note.author})
+ {note.time}
+
+
{note.text}
+
+ ))}
+
+ {selectedTicket.internalNotes?.map((note, idx) => (
+
+
+ Внутренняя заметка команды ({note.author})
+ {note.time}
+
+
{note.text}
+
+ ))}
+
+
+
+ {/* Response Box with Public vs Internal toggle */}
+
)}
diff --git a/src/features/inventory/InventoryModule.tsx b/src/features/inventory/InventoryModule.tsx
index d56a592..7748c3d 100644
--- a/src/features/inventory/InventoryModule.tsx
+++ b/src/features/inventory/InventoryModule.tsx
@@ -1,169 +1,543 @@
-import React, { useState } from 'react';
-import { INITIAL_INVENTORY, InventoryItemEntity } from '../../mock/mockData';
-import { Badge } from '../../shared/components/Badge';
-import { Button } from '../../shared/components/Button';
-import { Modal } from '../../shared/components/Modal';
-import { Input } from '../../shared/components/Input';
-import { formatCurrency } from '../../shared/utils/formatters';
-import { Boxes, AlertTriangle, ArrowRightLeft, Trash2, Plus, Search } from 'lucide-react';
+import React, { useState, useMemo } from 'react';
+import { useInventory } from '../../services/hooks/useInventory';
+import { useOrders } from '../../services/hooks/useOrders';
+import { StockItem, StockMovement, GoodsReceiptItem } from '../../services/contracts/inventory';
+import { Button } from '../../shared/ui/Button';
+import { Badge } from '../../shared/ui/Badge';
+import { MaturityBadge } from '../../shared/ui/MaturityBadge';
+import { Modal } from '../../shared/ui/Modal';
+import { useToast } from '../../shared/ui/useToast';
+import { formatDateTime } from '../../shared/lib/date';
+import { formatCurrencyMinor } from '../../shared/lib/currency';
+import {
+ Plus,
+ Search,
+} from 'lucide-react';
export const InventoryModule: React.FC = () => {
- const [items, setItems] = useState
(INITIAL_INVENTORY);
- const [search, setSearch] = useState('');
- const [isWriteoffModalOpen, setIsWriteoffModalOpen] = useState(false);
- const [selectedItem, setSelectedItem] = useState(null);
- const [writeoffAmount, setWriteoffAmount] = useState('1');
- const [writeoffReason, setWriteoffReason] = useState('Порча / Срок годности');
+ const { showToast } = useToast();
+ const {
+ warehouses,
+ stockItems,
+ movements,
+ receipts,
+ adjustStock,
+ createAndReceiveGoods,
+ } = useInventory();
- const handleWriteoff = (e: React.FormEvent) => {
+ const { products } = useOrders();
+
+ const [activeTab, setActiveTab] = useState<'balances' | 'movements' | 'receipts'>('balances');
+ const [search, setSearch] = useState('');
+ const [selectedWarehouseFilter, setSelectedWarehouseFilter] = useState('all');
+
+ // Modals
+ const [isReceiptModalOpen, setIsReceiptModalOpen] = useState(false);
+ const [isAdjustModalOpen, setIsAdjustModalOpen] = useState(false);
+
+ // Goods Receipt Form
+ const [receiptSupplier, setReceiptSupplier] = useState('');
+ const [receiptWarehouseId, setReceiptWarehouseId] = useState('wh_main');
+ const [receiptProductId, setReceiptProductId] = useState('');
+ const [receiptQty, setReceiptQty] = useState(50);
+ const [receiptCostMinor, setReceiptCostMinor] = useState(120000);
+
+ // Adjustment Form
+ const [adjustItem, setAdjustItem] = useState(null);
+ const [adjustDelta, setAdjustDelta] = useState('-1');
+ const [adjustReason, setAdjustReason] = useState('Порча / Срок годности');
+
+ const filteredStockItems = useMemo(() => {
+ return stockItems.filter((item) => {
+ const matchWh =
+ selectedWarehouseFilter === 'all' || item.warehouseId === selectedWarehouseFilter;
+ const matchSearch =
+ !search ||
+ item.productName.toLowerCase().includes(search.toLowerCase()) ||
+ item.sku.toLowerCase().includes(search.toLowerCase());
+ return matchWh && matchSearch;
+ });
+ }, [stockItems, selectedWarehouseFilter, search]);
+
+ const sortedMovements = useMemo(() => {
+ return [...movements].sort((a, b) => (b.timestamp > a.timestamp ? 1 : -1));
+ }, [movements]);
+
+ // Handle Goods Receipt creation & real-time stock increase
+ const handleCreateReceipt = async (e: React.FormEvent) => {
e.preventDefault();
- if (!selectedItem) return;
- const qty = Number(writeoffAmount) || 0;
- setItems((prev) =>
- prev.map((it) =>
- it.id === selectedItem.id
- ? { ...it, currentStock: Math.max(0, it.currentStock - qty) }
- : it
- )
- );
- setIsWriteoffModalOpen(false);
+ const product = products.find((p) => p.id === receiptProductId) || products[0];
+ if (!product) {
+ showToast('Выберите товар', 'error');
+ return;
+ }
+
+ try {
+ const receiptNumber = `REC-${Date.now().toString().slice(-6)}`;
+ const receiptItems: GoodsReceiptItem[] = [
+ {
+ productId: product.id,
+ sku: product.sku,
+ name: product.name,
+ quantity: receiptQty,
+ costPrice: { minor: receiptCostMinor, currency: 'RUB' },
+ },
+ ];
+
+ await createAndReceiveGoods(
+ receiptWarehouseId,
+ receiptNumber,
+ receiptSupplier || 'ООО «Поставщик Продукт»',
+ receiptItems
+ );
+
+ showToast(`Поступление по накладной №${receiptNumber} проведено! Остатки увеличены.`, 'success');
+ setIsReceiptModalOpen(false);
+ setReceiptSupplier('');
+ } catch (err) {
+ showToast(`Ошибка: ${err instanceof Error ? err.message : String(err)}`, 'error');
+ }
};
- const filteredItems = items.filter(
- (i) => i.name.toLowerCase().includes(search.toLowerCase()) || i.sku.includes(search)
- );
+ // Handle Stock Adjustment / Write-off
+ const handleStockAdjustment = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!adjustItem) return;
+
+ try {
+ const delta = Number(adjustDelta);
+ await adjustStock(adjustItem.warehouseId, adjustItem.productId, delta, adjustReason);
+ showToast(
+ delta < 0 ? 'Списание со склада проведено.' : 'Корректировка остатка проведена.',
+ 'success'
+ );
+ setIsAdjustModalOpen(false);
+ setAdjustItem(null);
+ } catch (err) {
+ showToast(`Ошибка: ${err instanceof Error ? err.message : String(err)}`, 'error');
+ }
+ };
+
+ // Movement badge color
+ const getMovementBadge = (type: StockMovement['type']) => {
+ switch (type) {
+ case 'receipt':
+ return Поступление (+) ;
+ case 'shipment':
+ return Отгрузка (-) ;
+ case 'transfer':
+ return Резерв / Трансфер ;
+ case 'loss':
+ return Списание (-) ;
+ case 'adjustment':
+ default:
+ return Коррекция ;
+ }
+ };
return (
+ {/* Header bar */}
-
- Складской учет, остатки и партии
-
-
- Контроль неснижаемых остатков, себестоимости партий и перемещений между складами
+
+
+ Складской учет, остатки и движение партий
+
+
+
+
+ Точный баланс: Физический остаток, Резервы заказов, Доступное количество и Складской журнал
-
{
- setSelectedItem(items[0]);
- setIsWriteoffModalOpen(true);
- }}
- >
-
- Оформить списание
-
-
-
- Приходная накладная
+ setIsReceiptModalOpen(true)}>
+ Приходная накладная
- {/* Stock Table */}
-
-
-
-
setSearch(e.target.value)}
- placeholder="Поиск по артикулу, названию сырья..."
- className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-workspace border border-krona-border rounded-lg text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-green"
- />
+ {/* Tabs & Filters */}
+
+
+ setActiveTab('balances')}
+ className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
+ activeTab === 'balances'
+ ? 'bg-krona-green text-white shadow-sm'
+ : 'bg-krona-surface text-krona-secondary hover:text-krona-primary border border-krona-border'
+ }`}
+ >
+ Остатки на складах ({stockItems.length})
+
+ setActiveTab('movements')}
+ className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
+ activeTab === 'movements'
+ ? 'bg-krona-green text-white shadow-sm'
+ : 'bg-krona-surface text-krona-secondary hover:text-krona-primary border border-krona-border'
+ }`}
+ >
+ Журнал движений ({movements.length})
+
+ setActiveTab('receipts')}
+ className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
+ activeTab === 'receipts'
+ ? 'bg-krona-green text-white shadow-sm'
+ : 'bg-krona-surface text-krona-secondary hover:text-krona-primary border border-krona-border'
+ }`}
+ >
+ Поступления ({receipts.length})
+
-
-
-
+
+
setSelectedWarehouseFilter(e.target.value)}
+ className="px-2.5 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
+ >
+ Все склады
+ {warehouses.map((wh) => (
+
+ {wh.name}
+
+ ))}
+
+
+
+
+ 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 focus:outline-none focus:border-krona-green"
+ />
+
+
+
+
+ {/* ---------------------------------------------------- */}
+ {/* 1. STOCK BALANCES TABLE */}
+ {/* ---------------------------------------------------- */}
+ {activeTab === 'balances' && (
+
+
+
- Артикул
- Наименование позиции
+ Артикул (SKU)
+ Наименование товара
Склад
- Текущий остаток
+ Физический остаток (On Hand)
+ В резерве (Reserved)
+ Доступно к заказу (Available)
Мин. порог
- Себестоимость
- Поставщик
- Статус
+ Действие
-
- {filteredItems.map((item) => (
-
- {item.sku}
- {item.name}
- {item.warehouse}
-
- {item.currentStock} {item.unit}
-
-
- {item.minStock} {item.unit}
-
- {formatCurrency(item.unitCost)} / {item.unit}
- {item.supplier}
-
- {item.status === 'critical' ? (
- Критический!
- ) : item.status === 'low' ? (
- Заканчивается
- ) : (
- В норме
- )}
+
+ {filteredStockItems.map((item) => {
+ const isLowStock = item.minThreshold !== undefined && item.available <= item.minThreshold;
+ return (
+
+ {item.sku}
+ {item.productName}
+ {item.warehouseName}
+
+ {item.onHand} {item.unit}
+
+
+ {item.reserved > 0 ? `${item.reserved} ${item.unit}` : '0'}
+
+
+ 0
+ ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
+ : 'bg-rose-500/10 text-rose-600 dark:text-rose-400'
+ }`}
+ >
+ {item.available} {item.unit}
+
+
+
+ {item.minThreshold !== undefined ? (
+
+ {item.minThreshold} {item.unit}
+ {isLowStock && (
+
+ Мало
+
+ )}
+
+ ) : (
+ '—'
+ )}
+
+
+ {
+ setAdjustItem(item);
+ setAdjustDelta('-1');
+ setIsAdjustModalOpen(true);
+ }}
+ >
+ Коррекция / Списание
+
+
+
+ );
+ })}
+
+ {filteredStockItems.length === 0 && (
+
+
+ Остатки не найдены
- ))}
+ )}
-
+ )}
- {/* Writeoff Modal */}
- {isWriteoffModalOpen && selectedItem && (
- setIsWriteoffModalOpen(false)}
- title="Списание остатков со склада"
- subtitle={`Позиция: ${selectedItem.name} (${selectedItem.sku})`}
- maxWidth="sm"
- >
-