mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Refactor Hub UI feature structure
This commit is contained in:
1 parent
fbef0a6dc1
commit
7f9cb481b2
23 files changed
+2127
-381
No files matched your search
@@ -0,0 +1,42 @@
|
||||
import type { Employee, RouteKey } from "../../types";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Avatar, RoleBadge, StatusPill } from "../../shared/ui";
|
||||
import type { EmployeeForm, EmployeeStatus } from "./model";
|
||||
|
||||
export function EmployeeDetailHeader({
|
||||
employee,
|
||||
departmentLabel,
|
||||
form,
|
||||
saveEmployee,
|
||||
saving,
|
||||
setRoute,
|
||||
status,
|
||||
}: {
|
||||
employee: Employee;
|
||||
departmentLabel: string;
|
||||
form: EmployeeForm;
|
||||
saveEmployee: () => void;
|
||||
saving: boolean;
|
||||
setRoute: (route: RouteKey) => void;
|
||||
status: EmployeeStatus;
|
||||
}) {
|
||||
return (
|
||||
<section className="employee-detail-header">
|
||||
<div className="employee-detail-main">
|
||||
<Avatar employee={employee} />
|
||||
<div className="employee-title">
|
||||
<div>
|
||||
<h1>{form.fullName || form.email}</h1>
|
||||
<RoleBadge role={form.role} />
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
<p>{form.email} · {departmentLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="employee-header-actions">
|
||||
<button className="secondary-button" type="button" onClick={() => setRoute("employees")}>Отмена</button>
|
||||
<button className="primary-button" type="button" onClick={saveEmployee} disabled={saving}><Icon name="save" size={15} />{saving ? "Сохранение" : "Сохранить"}</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import type { Employee, RouteKey } from "../../types";
|
||||
import { EmployeeDetailHeader } from "./EmployeeDetailHeader";
|
||||
import { EmployeeDetailRail } from "./EmployeeDetailRail";
|
||||
import { EmployeeDetailSections } from "./EmployeeDetailSections";
|
||||
import { employeeDetails, employeeForm, employeeStatusKey, type EmployeeForm } from "./model";
|
||||
|
||||
export function EmployeeDetailPage({ employee, reload, setRoute }: { employee: Employee; reload: () => void; setRoute: (route: RouteKey) => void }) {
|
||||
const [currentEmployee, setCurrentEmployee] = useState(employee);
|
||||
const [form, setForm] = useState<EmployeeForm>(() => employeeForm(employee));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const details = employeeDetails(currentEmployee);
|
||||
const status = employeeStatusKey(currentEmployee);
|
||||
const blocked = status === "blocked";
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentEmployee(employee);
|
||||
setForm(employeeForm(employee));
|
||||
setMessage("");
|
||||
}, [employee]);
|
||||
|
||||
function updateForm(field: keyof EmployeeForm, value: string | boolean) {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
setMessage("");
|
||||
}
|
||||
|
||||
async function saveEmployee() {
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const payload = await api<{ employee: Employee }>(`/api/v1/employees/${currentEmployee.id}/update/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
setCurrentEmployee(payload.employee);
|
||||
setMessage("Изменения сохранены");
|
||||
reload();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось сохранить изменения");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword() {
|
||||
setMessage("");
|
||||
const payload = await api<{ employee: Employee; temporaryPassword: string }>(`/api/v1/employees/${currentEmployee.id}/reset-password/`, { method: "POST" });
|
||||
setCurrentEmployee(payload.employee);
|
||||
setMessage(`Временный пароль: ${payload.temporaryPassword}`);
|
||||
reload();
|
||||
}
|
||||
|
||||
async function revokeSessions() {
|
||||
const payload = await api<{ employee: Employee; revoked: number }>(`/api/v1/employees/${currentEmployee.id}/revoke-sessions/`, { method: "POST" });
|
||||
setCurrentEmployee(payload.employee);
|
||||
setMessage(`Сессии завершены: ${payload.revoked}`);
|
||||
}
|
||||
|
||||
async function toggleBlocked() {
|
||||
const action = blocked ? "unblock" : "block";
|
||||
const payload = await api<{ employee: Employee }>(`/api/v1/employees/${currentEmployee.id}/${action}/`, { method: "POST" });
|
||||
setCurrentEmployee(payload.employee);
|
||||
setMessage(blocked ? "Сотрудник разблокирован" : "Сотрудник заблокирован");
|
||||
reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EmployeeDetailHeader employee={employee} departmentLabel={details.departmentLabel} form={form} saveEmployee={saveEmployee} saving={saving} setRoute={setRoute} status={status} />
|
||||
{message && <div className="employee-action-message">{message}</div>}
|
||||
<div className="employee-detail-grid">
|
||||
<EmployeeDetailSections
|
||||
blocked={blocked}
|
||||
currentEmployee={currentEmployee}
|
||||
details={details}
|
||||
form={form}
|
||||
resetPassword={resetPassword}
|
||||
revokeSessions={revokeSessions}
|
||||
toggleBlocked={toggleBlocked}
|
||||
updateForm={updateForm}
|
||||
/>
|
||||
<EmployeeDetailRail details={details} status={status} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { KeyValue, MetricBox } from "../../shared/form-controls";
|
||||
import { StatusPill } from "../../shared/ui";
|
||||
import type { EmployeeStatus } from "./model";
|
||||
|
||||
export function EmployeeDetailRail({ details, status }: { details: ReturnType<typeof import("./model").employeeDetails>; status: EmployeeStatus }) {
|
||||
return (
|
||||
<aside className="employee-detail-rail">
|
||||
<section className="employee-detail-card compact">
|
||||
<h3>Статус аккаунта</h3>
|
||||
<KeyValue label="Статус" value={<StatusPill status={status} />} />
|
||||
<KeyValue label="Создан" value={details.account.createdAt} />
|
||||
<KeyValue label="Приглашение принято" value={details.account.inviteAcceptedAt} />
|
||||
<KeyValue label="Последний вход" value={details.account.lastLogin} />
|
||||
</section>
|
||||
|
||||
<section className="employee-detail-card compact">
|
||||
<h3>Текущая нагрузка</h3>
|
||||
<div className="workload-grid">
|
||||
<MetricBox label="Активные диалоги" value={details.workload.activeDialogs} />
|
||||
<MetricBox label="В очереди" value={details.workload.queue} />
|
||||
<MetricBox label="Продажи · сегодня" value={details.workload.salesToday} />
|
||||
<MetricBox label="Ср. ответ" value={details.workload.avgReply} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="employee-detail-card compact">
|
||||
<h3>Последние действия</h3>
|
||||
<div className="employee-activity-list">
|
||||
{details.activity.map((item) => (
|
||||
<div key={item.text}>
|
||||
<span style={{ background: item.dot }} />
|
||||
<p><strong>{item.text}</strong><small>{item.time}</small></p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Employee } from "../../types";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { FormField, SelectField, SwitchButton } from "../../shared/form-controls";
|
||||
import type { EmployeeForm } from "./model";
|
||||
|
||||
export function EmployeeDetailSections({
|
||||
blocked,
|
||||
currentEmployee,
|
||||
details,
|
||||
form,
|
||||
resetPassword,
|
||||
revokeSessions,
|
||||
toggleBlocked,
|
||||
updateForm,
|
||||
}: {
|
||||
blocked: boolean;
|
||||
currentEmployee: Employee;
|
||||
details: ReturnType<typeof import("./model").employeeDetails>;
|
||||
form: EmployeeForm;
|
||||
resetPassword: () => void;
|
||||
revokeSessions: () => void;
|
||||
toggleBlocked: () => void;
|
||||
updateForm: (field: keyof EmployeeForm, value: string | boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="employee-detail-left">
|
||||
<section className="employee-detail-card">
|
||||
<h3>Основные данные</h3>
|
||||
<div className="employee-form-grid">
|
||||
<FormField label="Имя" value={form.fullName} onChange={(value) => updateForm("fullName", value)} />
|
||||
<FormField label="Телефон" value={form.phone} onChange={(value) => updateForm("phone", value)} />
|
||||
<FormField label="Email · используется для входа" value={form.email} onChange={(value) => updateForm("email", value)} mono wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="employee-detail-card">
|
||||
<h3>Роль и доступ</h3>
|
||||
<div className="employee-form-grid">
|
||||
<SelectField label="Роль" value={form.role} onChange={(value) => updateForm("role", value)} options={[["OPERATOR", "OPERATOR"], ["OWNER", "OWNER"]]} />
|
||||
<SelectField label="Отдел" value={form.department} onChange={(value) => updateForm("department", value)} options={[["sales", "Отдел продаж"]]} />
|
||||
</div>
|
||||
<label className="employee-access-label">Доступные разделы</label>
|
||||
<div className="employee-access-list">
|
||||
<span className="allowed"><Icon name="check" size={12} />Диалоги</span>
|
||||
<span className="allowed"><Icon name="check" size={12} />Клиенты</span>
|
||||
<span className="allowed"><Icon name="check" size={12} />Заказы · чтение</span>
|
||||
<span>Финансы · нет</span>
|
||||
<span>Настройки · нет</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="employee-detail-card">
|
||||
<h3>Безопасность</h3>
|
||||
<div className="security-row">
|
||||
<div><strong>Пароль</strong><span>Последняя смена: {details.security.passwordChangedAt}</span></div>
|
||||
<button className="secondary-button" type="button" onClick={resetPassword}>Сбросить пароль</button>
|
||||
</div>
|
||||
<div className="security-row">
|
||||
<div><strong>Двухфакторная аутентификация (TOTP)</strong><span>{form.totpEnabled ? "Включена · требуется при каждом входе" : "Отключена для этого сотрудника"}</span></div>
|
||||
<SwitchButton checked={form.totpEnabled} className="detail-totp-switch" label="Переключить TOTP" onClick={() => updateForm("totpEnabled", !form.totpEnabled)} />
|
||||
</div>
|
||||
<div className="security-row last">
|
||||
<div><strong>Активные сессии</strong><span>{details.security.sessions}</span></div>
|
||||
<button className="secondary-button" type="button" onClick={revokeSessions}>Завершить все</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="employee-danger-card">
|
||||
<h3>Опасная зона</h3>
|
||||
<div>
|
||||
<p>{blocked ? "Сотрудник заблокирован и не может войти. Разблокировка восстановит доступ к разделам отдела." : "Блокировка немедленно завершит все сессии и закроет доступ. Активные диалоги вернутся в очередь. Действие требует подтверждения."}</p>
|
||||
<button type="button" onClick={toggleBlocked} disabled={currentEmployee.role === "OWNER"}>{blocked ? "Разблокировать сотрудника" : "Заблокировать сотрудника"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Employee } from "../../types";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Avatar, EmptyState, RoleBadge, StatusPill } from "../../shared/ui";
|
||||
import { employeeDetails, employeeStatusKey } from "./model";
|
||||
|
||||
export function EmployeeTable({
|
||||
block,
|
||||
employees,
|
||||
menuId,
|
||||
openEmployee,
|
||||
setMenuId,
|
||||
total,
|
||||
}: {
|
||||
block: (employee: Employee) => void;
|
||||
employees: Employee[];
|
||||
menuId: number | null;
|
||||
openEmployee: (employee: Employee) => void;
|
||||
setMenuId: (id: number | null) => void;
|
||||
total: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="table-card employees-card">
|
||||
<div className="table-scroll">
|
||||
<table className="baseline-table employees-table">
|
||||
<thead><tr><th>СОТРУДНИК</th><th>РОЛЬ</th><th>ОТДЕЛ</th><th>СТАТУС</th><th>ПОСЛЕДНИЙ ВХОД</th><th className="numeric">ДИАЛОГИ</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{employees.map((employee) => <EmployeeRow employee={employee} block={block} openEmployee={openEmployee} menuId={menuId} setMenuId={setMenuId} key={employee.id} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!employees.length && <EmptyState title="Сотрудники не найдены" />}
|
||||
<div className="employees-footer">
|
||||
<span>Показано {employees.length} из {total}</span>
|
||||
<span>Сессии и пароли управляются в карточке сотрудника</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmployeeRow({ employee, block, openEmployee, menuId, setMenuId }: { employee: Employee; block: (employee: Employee) => void; openEmployee: (employee: Employee) => void; menuId: number | null; setMenuId: (id: number | null) => void }) {
|
||||
const details = employeeDetails(employee);
|
||||
const dialogs = String(details.workload.activeDialogs);
|
||||
const status = employeeStatusKey(employee);
|
||||
const menuOpen = menuId === employee.id;
|
||||
const open = () => {
|
||||
setMenuId(null);
|
||||
openEmployee(employee);
|
||||
};
|
||||
return (
|
||||
<tr>
|
||||
<td><div className="person-cell"><Avatar employee={employee} /><button className="person-link" type="button" onClick={open}><strong>{employee.fullName || employee.email}</strong><small>{employee.email}</small></button></div></td>
|
||||
<td><RoleBadge role={employee.role} /></td>
|
||||
<td>{employee.department === "sales" ? "Продажи" : "—"}</td>
|
||||
<td><StatusPill status={status} /></td>
|
||||
<td>{details.account.lastLogin}</td>
|
||||
<td className={`numeric ${dialogs === "0" ? "muted-number" : ""}`}>{dialogs}</td>
|
||||
<td className="row-actions">
|
||||
<button className="row-menu-button" aria-label="Действия сотрудника" onClick={() => setMenuId(menuOpen ? null : employee.id)}><Icon name="more" /></button>
|
||||
{menuOpen && (
|
||||
<div className="row-menu">
|
||||
<button type="button" onClick={open}><Icon name="external" size={15} />Открыть карточку</button>
|
||||
<a href="#" onClick={(event) => event.preventDefault()}><Icon name="logout" size={15} />Завершить сессии</a>
|
||||
<a href="#" onClick={(event) => event.preventDefault()}><Icon name="key" size={15} />Сбросить пароль</a>
|
||||
<span />
|
||||
<button className={employee.isBlocked ? "success" : "danger"} onClick={() => block(employee)} disabled={employee.role === "OWNER" || employee.isBlocked}>{employee.isBlocked ? "Разблокировать" : "Заблокировать"}</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Role } from "../../types";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Segmented } from "../../shared/ui";
|
||||
import type { EmployeeStatusFilter } from "./model";
|
||||
|
||||
export function EmployeesFilters({
|
||||
query,
|
||||
role,
|
||||
status,
|
||||
resetFilters,
|
||||
setQuery,
|
||||
setRole,
|
||||
setStatus,
|
||||
}: {
|
||||
query: string;
|
||||
role: "all" | Role;
|
||||
status: EmployeeStatusFilter;
|
||||
resetFilters: () => void;
|
||||
setQuery: (query: string) => void;
|
||||
setRole: (role: "all" | Role) => void;
|
||||
setStatus: (status: EmployeeStatusFilter) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="filter-bar employees-filter">
|
||||
<label className="employee-search">
|
||||
<Icon name="search" size={15} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Поиск по имени или email…" />
|
||||
</label>
|
||||
<div className="filter-group">
|
||||
<span>Роль</span>
|
||||
<Segmented value={role} setValue={setRole} items={[["all", "Все"], ["OWNER", "OWNER"], ["OPERATOR", "OPERATOR"]]} />
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<span>Статус</span>
|
||||
<Segmented value={status} setValue={setStatus} items={[["all", "Все"], ["active", "Активные"], ["invited", "Приглашённые"], ["blocked", "Заблокированные"]]} />
|
||||
</div>
|
||||
<button className="reset-filter" type="button" onClick={resetFilters}>Сбросить</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import type { Employee, Role } from "../../types";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { PageHeader } from "../../shared/ui";
|
||||
import { EmployeeTable } from "./EmployeeTable";
|
||||
import { EmployeesFilters } from "./EmployeesFilters";
|
||||
import { filterEmployees, type EmployeeStatusFilter } from "./model";
|
||||
|
||||
export function EmployeesPage({ employees, reload, openEmployee }: { employees: Employee[]; reload: () => void; openEmployee: (employee: Employee) => void }) {
|
||||
const [role, setRole] = useState<"all" | Role>("all");
|
||||
const [status, setStatus] = useState<EmployeeStatusFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuId, setMenuId] = useState<number | null>(null);
|
||||
const filtered = filterEmployees(employees, role, status, query);
|
||||
|
||||
async function block(employee: Employee) {
|
||||
if (employee.role === "OWNER" || employee.isBlocked) return;
|
||||
await api(`/api/v1/employees/${employee.id}/block/`, { method: "POST" });
|
||||
setMenuId(null);
|
||||
reload();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setQuery("");
|
||||
setRole("all");
|
||||
setStatus("all");
|
||||
setMenuId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{menuId !== null && <button className="menu-scrim" aria-label="Закрыть меню" onClick={() => setMenuId(null)} />}
|
||||
<PageHeader
|
||||
title="Сотрудники"
|
||||
text={<>Доступ к Hub · показано <b>{filtered.length}</b> из {employees.length}</>}
|
||||
action={<button className="primary-button" type="button"><Icon name="team" size={16} />Добавить оператора</button>}
|
||||
/>
|
||||
<EmployeesFilters
|
||||
query={query}
|
||||
role={role}
|
||||
status={status}
|
||||
resetFilters={resetFilters}
|
||||
setQuery={(nextQuery) => { setQuery(nextQuery); setMenuId(null); }}
|
||||
setRole={(nextRole) => { setRole(nextRole); setMenuId(null); }}
|
||||
setStatus={(nextStatus) => { setStatus(nextStatus); setMenuId(null); }}
|
||||
/>
|
||||
<EmployeeTable block={block} employees={filtered} menuId={menuId} openEmployee={openEmployee} setMenuId={setMenuId} total={employees.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,365 +1,2 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import type { Employee, Role, RouteKey } from "../../types";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Avatar, EmptyState, PageHeader, RoleBadge, Segmented, StatusPill } from "../../shared/ui";
|
||||
|
||||
export function EmployeesPage({ employees, reload, openEmployee }: { employees: Employee[]; reload: () => void; openEmployee: (employee: Employee) => void }) {
|
||||
const [role, setRole] = useState<"all" | Role>("all");
|
||||
const [status, setStatus] = useState<"all" | "active" | "invited" | "blocked">("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuId, setMenuId] = useState<number | null>(null);
|
||||
const filtered = employees.filter((employee) => {
|
||||
const employeeStatus = employeeStatusKey(employee);
|
||||
const q = query.trim().toLowerCase();
|
||||
return (
|
||||
(role === "all" || employee.role === role) &&
|
||||
(status === "all" || employeeStatus === status) &&
|
||||
(!q || employee.fullName.toLowerCase().includes(q) || employee.email.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
async function block(employee: Employee) {
|
||||
if (employee.role === "OWNER" || employee.isBlocked) return;
|
||||
await api(`/api/v1/employees/${employee.id}/block/`, { method: "POST" });
|
||||
setMenuId(null);
|
||||
reload();
|
||||
}
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setRole("all");
|
||||
setStatus("all");
|
||||
setMenuId(null);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{menuId !== null && <button className="menu-scrim" aria-label="Закрыть меню" onClick={() => setMenuId(null)} />}
|
||||
<PageHeader
|
||||
title="Сотрудники"
|
||||
text={<>Доступ к Hub · показано <b>{filtered.length}</b> из {employees.length}</>}
|
||||
action={<button className="primary-button" type="button"><Icon name="team" size={16} />Добавить оператора</button>}
|
||||
/>
|
||||
<div className="filter-bar employees-filter">
|
||||
<label className="employee-search">
|
||||
<Icon name="search" size={15} />
|
||||
<input value={query} onChange={(event) => { setQuery(event.target.value); setMenuId(null); }} placeholder="Поиск по имени или email…" />
|
||||
</label>
|
||||
<div className="filter-group">
|
||||
<span>Роль</span>
|
||||
<Segmented value={role} setValue={(nextRole) => { setRole(nextRole); setMenuId(null); }} items={[["all", "Все"], ["OWNER", "OWNER"], ["OPERATOR", "OPERATOR"]]} />
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<span>Статус</span>
|
||||
<Segmented value={status} setValue={(nextStatus) => { setStatus(nextStatus); setMenuId(null); }} items={[["all", "Все"], ["active", "Активные"], ["invited", "Приглашённые"], ["blocked", "Заблокированные"]]} />
|
||||
</div>
|
||||
<button className="reset-filter" type="button" onClick={resetFilters}>Сбросить</button>
|
||||
</div>
|
||||
<div className="table-card employees-card">
|
||||
<div className="table-scroll">
|
||||
<table className="baseline-table employees-table">
|
||||
<thead><tr><th>СОТРУДНИК</th><th>РОЛЬ</th><th>ОТДЕЛ</th><th>СТАТУС</th><th>ПОСЛЕДНИЙ ВХОД</th><th className="numeric">ДИАЛОГИ</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{filtered.map((employee) => <EmployeeRow employee={employee} block={block} openEmployee={openEmployee} menuId={menuId} setMenuId={setMenuId} key={employee.id} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!filtered.length && <EmptyState title="Сотрудники не найдены" />}
|
||||
<div className="employees-footer">
|
||||
<span>Показано {filtered.length} из {employees.length}</span>
|
||||
<span>Сессии и пароли управляются в карточке сотрудника</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function employeeStatusKey(employee: Employee): "active" | "invited" | "blocked" {
|
||||
if (employee.isBlocked) return "blocked";
|
||||
if (employee.mustChangePassword) return "invited";
|
||||
return "active";
|
||||
}
|
||||
|
||||
function EmployeeRow({ employee, block, openEmployee, menuId, setMenuId }: { employee: Employee; block: (employee: Employee) => void; openEmployee: (employee: Employee) => void; menuId: number | null; setMenuId: (id: number | null) => void }) {
|
||||
const details = employeeDetails(employee);
|
||||
const dialogs = String(details.workload.activeDialogs);
|
||||
const status = employeeStatusKey(employee);
|
||||
const menuOpen = menuId === employee.id;
|
||||
const open = () => {
|
||||
setMenuId(null);
|
||||
openEmployee(employee);
|
||||
};
|
||||
return (
|
||||
<tr>
|
||||
<td><div className="person-cell"><Avatar employee={employee} /><button className="person-link" type="button" onClick={open}><strong>{employee.fullName || employee.email}</strong><small>{employee.email}</small></button></div></td>
|
||||
<td><RoleBadge role={employee.role} /></td>
|
||||
<td>{employee.department === "sales" ? "Продажи" : "—"}</td>
|
||||
<td><StatusPill status={status} /></td>
|
||||
<td>{details.account.lastLogin}</td>
|
||||
<td className={`numeric ${dialogs === "0" ? "muted-number" : ""}`}>{dialogs}</td>
|
||||
<td className="row-actions">
|
||||
<button className="row-menu-button" aria-label="Действия сотрудника" onClick={() => setMenuId(menuOpen ? null : employee.id)}><Icon name="more" /></button>
|
||||
{menuOpen && (
|
||||
<div className="row-menu">
|
||||
<button type="button" onClick={open}><Icon name="external" size={15} />Открыть карточку</button>
|
||||
<a href="#" onClick={(event) => event.preventDefault()}><Icon name="logout" size={15} />Завершить сессии</a>
|
||||
<a href="#" onClick={(event) => event.preventDefault()}><Icon name="key" size={15} />Сбросить пароль</a>
|
||||
<span />
|
||||
<button className={employee.isBlocked ? "success" : "danger"} onClick={() => block(employee)} disabled={employee.role === "OWNER" || employee.isBlocked}>{employee.isBlocked ? "Разблокировать" : "Заблокировать"}</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmployeeDetailPage({ employee, reload, setRoute }: { employee: Employee; reload: () => void; setRoute: (route: RouteKey) => void }) {
|
||||
const [currentEmployee, setCurrentEmployee] = useState(employee);
|
||||
const [form, setForm] = useState({
|
||||
fullName: employee.fullName || employee.email,
|
||||
phone: employee.phone || employeeDetails(employee).phone,
|
||||
email: employee.email,
|
||||
role: employee.role,
|
||||
department: employee.department ?? "sales",
|
||||
totpEnabled: employee.totpEnabled,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const details = employeeDetails(currentEmployee);
|
||||
const status = employeeStatusKey(currentEmployee);
|
||||
const blocked = status === "blocked";
|
||||
useEffect(() => {
|
||||
setCurrentEmployee(employee);
|
||||
setForm({
|
||||
fullName: employee.fullName || employee.email,
|
||||
phone: employee.phone || employeeDetails(employee).phone,
|
||||
email: employee.email,
|
||||
role: employee.role,
|
||||
department: employee.department ?? "sales",
|
||||
totpEnabled: employee.totpEnabled,
|
||||
});
|
||||
setMessage("");
|
||||
}, [employee]);
|
||||
const updateForm = (field: keyof typeof form, value: string | boolean) => {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
setMessage("");
|
||||
};
|
||||
async function saveEmployee() {
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const payload = await api<{ employee: Employee }>(`/api/v1/employees/${currentEmployee.id}/update/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
setCurrentEmployee(payload.employee);
|
||||
setMessage("Изменения сохранены");
|
||||
reload();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось сохранить изменения");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
async function resetPassword() {
|
||||
setMessage("");
|
||||
const payload = await api<{ employee: Employee; temporaryPassword: string }>(`/api/v1/employees/${currentEmployee.id}/reset-password/`, { method: "POST" });
|
||||
setCurrentEmployee(payload.employee);
|
||||
setMessage(`Временный пароль: ${payload.temporaryPassword}`);
|
||||
reload();
|
||||
}
|
||||
async function revokeSessions() {
|
||||
const payload = await api<{ employee: Employee; revoked: number }>(`/api/v1/employees/${currentEmployee.id}/revoke-sessions/`, { method: "POST" });
|
||||
setCurrentEmployee(payload.employee);
|
||||
setMessage(`Сессии завершены: ${payload.revoked}`);
|
||||
}
|
||||
async function toggleBlocked() {
|
||||
const action = blocked ? "unblock" : "block";
|
||||
const payload = await api<{ employee: Employee }>(`/api/v1/employees/${currentEmployee.id}/${action}/`, { method: "POST" });
|
||||
setCurrentEmployee(payload.employee);
|
||||
setMessage(blocked ? "Сотрудник разблокирован" : "Сотрудник заблокирован");
|
||||
reload();
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<section className="employee-detail-header">
|
||||
<div className="employee-detail-main">
|
||||
<Avatar employee={employee} />
|
||||
<div className="employee-title">
|
||||
<div>
|
||||
<h1>{form.fullName || form.email}</h1>
|
||||
<RoleBadge role={form.role} />
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
<p>{form.email} · {details.departmentLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="employee-header-actions">
|
||||
<button className="secondary-button" type="button" onClick={() => setRoute("employees")}>Отмена</button>
|
||||
<button className="primary-button" type="button" onClick={saveEmployee} disabled={saving}><Icon name="save" size={15} />{saving ? "Сохранение" : "Сохранить"}</button>
|
||||
</div>
|
||||
</section>
|
||||
{message && <div className="employee-action-message">{message}</div>}
|
||||
|
||||
<div className="employee-detail-grid">
|
||||
<div className="employee-detail-left">
|
||||
<section className="employee-detail-card">
|
||||
<h3>Основные данные</h3>
|
||||
<div className="employee-form-grid">
|
||||
<EditableField label="Имя" value={form.fullName} onChange={(value) => updateForm("fullName", value)} />
|
||||
<EditableField label="Телефон" value={form.phone} onChange={(value) => updateForm("phone", value)} />
|
||||
<EditableField label="Email · используется для входа" value={form.email} onChange={(value) => updateForm("email", value)} mono wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="employee-detail-card">
|
||||
<h3>Роль и доступ</h3>
|
||||
<div className="employee-form-grid">
|
||||
<SelectLike label="Роль" value={form.role} onChange={(value) => updateForm("role", value)} options={[["OPERATOR", "OPERATOR"], ["OWNER", "OWNER"]]} />
|
||||
<SelectLike label="Отдел" value={form.department} onChange={(value) => updateForm("department", value)} options={[["sales", "Отдел продаж"]]} />
|
||||
</div>
|
||||
<label className="employee-access-label">Доступные разделы</label>
|
||||
<div className="employee-access-list">
|
||||
<span className="allowed"><Icon name="check" size={12} />Диалоги</span>
|
||||
<span className="allowed"><Icon name="check" size={12} />Клиенты</span>
|
||||
<span className="allowed"><Icon name="check" size={12} />Заказы · чтение</span>
|
||||
<span>Финансы · нет</span>
|
||||
<span>Настройки · нет</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="employee-detail-card">
|
||||
<h3>Безопасность</h3>
|
||||
<div className="security-row">
|
||||
<div><strong>Пароль</strong><span>Последняя смена: {details.security.passwordChangedAt}</span></div>
|
||||
<button className="secondary-button" type="button" onClick={resetPassword}>Сбросить пароль</button>
|
||||
</div>
|
||||
<div className="security-row">
|
||||
<div><strong>Двухфакторная аутентификация (TOTP)</strong><span>{form.totpEnabled ? "Включена · требуется при каждом входе" : "Отключена для этого сотрудника"}</span></div>
|
||||
<button className={`detail-totp-switch ${form.totpEnabled ? "on" : ""}`} type="button" aria-label="Переключить TOTP" onClick={() => updateForm("totpEnabled", !form.totpEnabled)}><i /></button>
|
||||
</div>
|
||||
<div className="security-row last">
|
||||
<div><strong>Активные сессии</strong><span>{details.security.sessions}</span></div>
|
||||
<button className="secondary-button" type="button" onClick={revokeSessions}>Завершить все</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="employee-danger-card">
|
||||
<h3>Опасная зона</h3>
|
||||
<div>
|
||||
<p>{blocked ? "Сотрудник заблокирован и не может войти. Разблокировка восстановит доступ к разделам отдела." : "Блокировка немедленно завершит все сессии и закроет доступ. Активные диалоги вернутся в очередь. Действие требует подтверждения."}</p>
|
||||
<button type="button" onClick={toggleBlocked} disabled={currentEmployee.role === "OWNER"}>{blocked ? "Разблокировать сотрудника" : "Заблокировать сотрудника"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="employee-detail-rail">
|
||||
<section className="employee-detail-card compact">
|
||||
<h3>Статус аккаунта</h3>
|
||||
<KeyValue label="Статус" value={<StatusPill status={status} />} />
|
||||
<KeyValue label="Создан" value={details.account.createdAt} />
|
||||
<KeyValue label="Приглашение принято" value={details.account.inviteAcceptedAt} />
|
||||
<KeyValue label="Последний вход" value={details.account.lastLogin} />
|
||||
</section>
|
||||
|
||||
<section className="employee-detail-card compact">
|
||||
<h3>Текущая нагрузка</h3>
|
||||
<div className="workload-grid">
|
||||
<MetricBox label="Активные диалоги" value={details.workload.activeDialogs} />
|
||||
<MetricBox label="В очереди" value={details.workload.queue} />
|
||||
<MetricBox label="Продажи · сегодня" value={details.workload.salesToday} />
|
||||
<MetricBox label="Ср. ответ" value={details.workload.avgReply} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="employee-detail-card compact">
|
||||
<h3>Последние действия</h3>
|
||||
<div className="employee-activity-list">
|
||||
{details.activity.map((item) => (
|
||||
<div key={item.text}>
|
||||
<span style={{ background: item.dot }} />
|
||||
<p><strong>{item.text}</strong><small>{item.time}</small></p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableField({ label, value, onChange, mono = false, wide = false }: { label: string; value: string; onChange: (value: string) => void; mono?: boolean; wide?: boolean }) {
|
||||
return <label className={`readonly-field ${wide ? "wide" : ""}`}><span>{label}</span><input className={mono ? "mono" : ""} value={value} onChange={(event) => onChange(event.target.value)} /></label>;
|
||||
}
|
||||
|
||||
function SelectLike({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<[string, string]> }) {
|
||||
return (
|
||||
<label className="readonly-field select-like">
|
||||
<span>{label}</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map(([optionValue, labelText]) => <option value={optionValue} key={optionValue}>{labelText}</option>)}
|
||||
</select>
|
||||
<Icon name="chevron" size={14} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyValue({ label, value }: { label: string; value: ReactNode }) {
|
||||
return <div className="key-value"><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
function MetricBox({ label, value }: { label: string; value: ReactNode }) {
|
||||
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
function employeeDetails(employee: Employee) {
|
||||
if (employee.email === "a.kotova@edevs.tech") {
|
||||
return {
|
||||
phone: employee.phone || "+7 916 245 14 02",
|
||||
departmentLabel: employee.department === "sales" ? "Отдел продаж" : "—",
|
||||
account: {
|
||||
createdAt: "12 мая 2026",
|
||||
inviteAcceptedAt: "12 мая 2026",
|
||||
lastLogin: "5 мин назад",
|
||||
},
|
||||
security: {
|
||||
passwordChangedAt: "28 мая 2026",
|
||||
sessions: "2 устройства · Chrome (Москва), Safari (Москва)",
|
||||
},
|
||||
workload: {
|
||||
activeDialogs: 3,
|
||||
queue: 0,
|
||||
salesToday: 5,
|
||||
avgReply: "1м 40с",
|
||||
},
|
||||
activity: [
|
||||
{ dot: "#52c41a", text: "Закрыла диалог · продажа Foxray", time: "8 мин назад" },
|
||||
{ dot: "#1677ff", text: "Забрала диалог из очереди", time: "22 мин назад" },
|
||||
{ dot: "#bfbfbf", text: "Вход в систему · Chrome", time: "сегодня 09:02" },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
phone: "",
|
||||
departmentLabel: employee.department === "sales" ? "Отдел продаж" : "—",
|
||||
account: {
|
||||
createdAt: "—",
|
||||
inviteAcceptedAt: employee.mustChangePassword ? "—" : "—",
|
||||
lastLogin: employee.role === "OWNER" ? "сейчас · онлайн" : "не входил",
|
||||
},
|
||||
security: {
|
||||
passwordChangedAt: "—",
|
||||
sessions: employee.role === "OWNER" ? "1 устройство · текущий браузер" : "—",
|
||||
},
|
||||
workload: {
|
||||
activeDialogs: 0,
|
||||
queue: 0,
|
||||
salesToday: 0,
|
||||
avgReply: "—",
|
||||
},
|
||||
activity: [
|
||||
{ dot: "#bfbfbf", text: "Активность не зафиксирована", time: "—" },
|
||||
],
|
||||
};
|
||||
}
|
||||
export { EmployeeDetailPage } from "./EmployeeDetailPage";
|
||||
export { EmployeesPage } from "./EmployeesListPage";
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Employee, Role } from "../../types";
|
||||
|
||||
export type EmployeeStatus = "active" | "blocked" | "invited";
|
||||
export type EmployeeRoleFilter = "all" | Role;
|
||||
export type EmployeeStatusFilter = "all" | EmployeeStatus;
|
||||
|
||||
export type EmployeeForm = {
|
||||
department: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
role: Role;
|
||||
totpEnabled: boolean;
|
||||
};
|
||||
|
||||
export function employeeStatusKey(employee: Employee): EmployeeStatus {
|
||||
if (employee.isBlocked) return "blocked";
|
||||
if (employee.mustChangePassword) return "invited";
|
||||
return "active";
|
||||
}
|
||||
|
||||
export function filterEmployees(employees: Employee[], role: EmployeeRoleFilter, status: EmployeeStatusFilter, query: string) {
|
||||
const q = query.trim().toLowerCase();
|
||||
return employees.filter((employee) => {
|
||||
const employeeStatus = employeeStatusKey(employee);
|
||||
return (
|
||||
(role === "all" || employee.role === role) &&
|
||||
(status === "all" || employeeStatus === status) &&
|
||||
(!q || employee.fullName.toLowerCase().includes(q) || employee.email.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function employeeForm(employee: Employee): EmployeeForm {
|
||||
return {
|
||||
fullName: employee.fullName || employee.email,
|
||||
phone: employee.phone || employeeDetails(employee).phone,
|
||||
email: employee.email,
|
||||
role: employee.role,
|
||||
department: employee.department ?? "sales",
|
||||
totpEnabled: employee.totpEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function employeeDetails(employee: Employee) {
|
||||
if (employee.email === "a.kotova@edevs.tech") {
|
||||
return {
|
||||
phone: employee.phone || "+7 916 245 14 02",
|
||||
departmentLabel: employee.department === "sales" ? "Отдел продаж" : "—",
|
||||
account: {
|
||||
createdAt: "12 мая 2026",
|
||||
inviteAcceptedAt: "12 мая 2026",
|
||||
lastLogin: "5 мин назад",
|
||||
},
|
||||
security: {
|
||||
passwordChangedAt: "28 мая 2026",
|
||||
sessions: "2 устройства · Chrome (Москва), Safari (Москва)",
|
||||
},
|
||||
workload: {
|
||||
activeDialogs: 3,
|
||||
queue: 0,
|
||||
salesToday: 5,
|
||||
avgReply: "1м 40с",
|
||||
},
|
||||
activity: [
|
||||
{ dot: "#52c41a", text: "Закрыла диалог · продажа Foxray", time: "8 мин назад" },
|
||||
{ dot: "#1677ff", text: "Забрала диалог из очереди", time: "22 мин назад" },
|
||||
{ dot: "#bfbfbf", text: "Вход в систему · Chrome", time: "сегодня 09:02" },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
phone: "",
|
||||
departmentLabel: employee.department === "sales" ? "Отдел продаж" : "—",
|
||||
account: {
|
||||
createdAt: "—",
|
||||
inviteAcceptedAt: "—",
|
||||
lastLogin: employee.role === "OWNER" ? "сейчас · онлайн" : "не входил",
|
||||
},
|
||||
security: {
|
||||
passwordChangedAt: "—",
|
||||
sessions: employee.role === "OWNER" ? "1 устройство · текущий браузер" : "—",
|
||||
},
|
||||
workload: {
|
||||
activeDialogs: 0,
|
||||
queue: 0,
|
||||
salesToday: 0,
|
||||
avgReply: "—",
|
||||
},
|
||||
activity: [
|
||||
{ dot: "#bfbfbf", text: "Активность не зафиксирована", time: "—" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { SalesComposer } from "./dialogs/SalesComposer";
|
||||
import { SalesContextPanel } from "./dialogs/SalesContextPanel";
|
||||
import { SalesConversation } from "./dialogs/SalesConversation";
|
||||
import { SalesDialogList } from "./dialogs/SalesDialogList";
|
||||
import { dialogs } from "./dialogs/data";
|
||||
import type { ControlMode, ListTab, RightTab } from "./dialogs/types";
|
||||
|
||||
export function SalesDialogsPage() {
|
||||
const [listTab, setListTab] = useState<ListTab>("all");
|
||||
const [rightTab, setRightTab] = useState<RightTab>("client");
|
||||
const [selectedId, setSelectedId] = useState(1);
|
||||
const [controlMode, setControlMode] = useState<ControlMode>("waiting");
|
||||
|
||||
const selected = dialogs.find((dialog) => dialog.id === selectedId) ?? dialogs[0];
|
||||
const filtered = useMemo(() => dialogs.filter((dialog) => {
|
||||
if (listTab === "wait") return dialog.mode === "wait";
|
||||
if (listTab === "ai") return dialog.mode === "ai";
|
||||
if (listTab === "operator") return dialog.mode === "operator";
|
||||
if (listTab === "unread") return dialog.unread > 0;
|
||||
return true;
|
||||
}), [listTab]);
|
||||
|
||||
return (
|
||||
<div className="sales-dialogs">
|
||||
<SalesDialogList
|
||||
dialogs={dialogs}
|
||||
filtered={filtered}
|
||||
listTab={listTab}
|
||||
selectedId={selectedId}
|
||||
setListTab={setListTab}
|
||||
setSelectedId={setSelectedId}
|
||||
/>
|
||||
<section className="sales-conversation">
|
||||
<SalesConversation controlMode={controlMode} selected={selected} setControlMode={setControlMode} />
|
||||
<SalesComposer mode={controlMode} setMode={setControlMode} />
|
||||
</section>
|
||||
<SalesContextPanel rightTab={rightTab} setRightTab={setRightTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import type { ControlMode } from "./types";
|
||||
|
||||
export function SalesComposer({ mode, setMode }: { mode: ControlMode; setMode: (mode: ControlMode) => void }) {
|
||||
if (mode === "waiting") {
|
||||
return (
|
||||
<div className="sales-composer"><div className="sales-waiting-composer">
|
||||
<span><Icon name="lock" size={19} /></span>
|
||||
<div><strong>Диалог ждёт оператора</strong><p>Возьмите диалог, чтобы ответить клиенту. AI поставлен на паузу.</p></div>
|
||||
<button className="sales-secondary-action">Вернуть в очередь</button>
|
||||
<button className="sales-claim-button" onClick={() => setMode("human")}><Icon name="check" size={15} />Забрать диалог</button>
|
||||
</div></div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "ai") {
|
||||
return (
|
||||
<div className="sales-composer"><div className="sales-ai-composer">
|
||||
<span><Icon name="robot" size={19} /></span>
|
||||
<div><strong>AI ведёт диалог</strong><p>Перехватите, чтобы ответить вручную. Контроль перейдёт к вам.</p></div>
|
||||
<button className="sales-ai-button" onClick={() => setMode("human")}>Перехватить AI</button>
|
||||
</div></div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sales-composer">
|
||||
<div className="sales-human-tools">
|
||||
<button>Шаблоны ответов</button>
|
||||
<button>Создать checkout</button>
|
||||
<span />
|
||||
<button className="ai" onClick={() => setMode("ai")}>Вернуть AI</button>
|
||||
<button>Закрыть</button>
|
||||
</div>
|
||||
<div className="sales-message-input">
|
||||
<button aria-label="Прикрепить файл"><Icon name="paperclip" size={19} /></button>
|
||||
<textarea rows={1} placeholder="Введите сообщение…" />
|
||||
<button>Отправить<Icon name="send" size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import type { RightTab } from "./types";
|
||||
|
||||
export function SalesContextPanel({ rightTab, setRightTab }: { rightTab: RightTab; setRightTab: (tab: RightTab) => void }) {
|
||||
return (
|
||||
<section className="sales-context">
|
||||
<div className="sales-context-tabs">
|
||||
<RightTabButton active={rightTab === "client"} onClick={() => setRightTab("client")}>Клиент</RightTabButton>
|
||||
<RightTabButton active={rightTab === "product"} onClick={() => setRightTab("product")}>Продукт</RightTabButton>
|
||||
<RightTabButton active={rightTab === "history"} onClick={() => setRightTab("history")}>История</RightTabButton>
|
||||
</div>
|
||||
<div className="sales-context-body">
|
||||
{rightTab === "client" && <ClientContext />}
|
||||
{rightTab === "product" && <ProductContext />}
|
||||
{rightTab === "history" && <HistoryContext />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RightTabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return <button className={active ? "active" : ""} onClick={onClick}>{children}</button>;
|
||||
}
|
||||
|
||||
function ClientContext() {
|
||||
return (
|
||||
<div className="sales-client-context">
|
||||
<div className="sales-client-hero"><span>МС</span><strong>Мария Соколова</strong><em><Icon name="check" size={11} />Согласие получено</em></div>
|
||||
<ContextSection title="КАНАЛЫ И КОНТАКТЫ">
|
||||
<ContactRow dot="#6b5be0" title="MAX" text="@maria.s" note="основной" />
|
||||
<ContactRow icon="mail" title="m.sokolova@workmail.ru" text="Email · подтверждён" mono />
|
||||
<ContactRow icon="phone" title="+7 ··· ·· 14" text="Телефон · скрыт" muted />
|
||||
</ContextSection>
|
||||
<ContextSection title="РАБОЧАЯ ЗАМЕТКА" action="Изменить">
|
||||
<div className="sales-note">Команда ~8 человек, интересует <b>FirePage Business</b> помесячно. Открытый вопрос — интеграция с их CRM (amoCRM).</div>
|
||||
</ContextSection>
|
||||
<ContextSection title="СВОДКА">
|
||||
<div className="sales-summary-grid"><div><span>Заказы</span><b>0</b></div><div><span>Диалоги</span><b>2</b></div><div><span>Покупки</span><b>₽0</b></div></div>
|
||||
<p className="sales-context-muted">Первый контакт: сегодня, 14:02</p>
|
||||
</ContextSection>
|
||||
<ContextSection title="ДЕЙСТВИЯ OWNER">
|
||||
<button className="sales-context-action">Объединить контакты</button>
|
||||
<button className="sales-context-action danger">Обезличить данные</button>
|
||||
</ContextSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductContext() {
|
||||
return (
|
||||
<div className="sales-product-context">
|
||||
<div className="sales-product-head"><span><Icon name="box" size={20} /></span><div><strong>FirePage</strong><small>Конструктор лендингов · активен</small></div></div>
|
||||
<ContextSection title="ПОДХОДЯЩИЕ OFFER">
|
||||
<div className="sales-offer active"><div><strong>Business</strong><em>AI может предлагать</em></div><p><b>₽2 490</b><span>/ мес · помесячно</span></p><small>До 10 пользователей · общие проекты · amoCRM-интеграция · приоритетная поддержка.</small></div>
|
||||
<div className="sales-offer"><div><strong>Pro</strong><em>для одного</em></div><p><b>₽990</b><span>/ мес</span></p><small>1 пользователь · все шаблоны · базовая поддержка.</small></div>
|
||||
</ContextSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryContext() {
|
||||
return (
|
||||
<div className="sales-history-context">
|
||||
<ContextSection title="ПРЕДЫДУЩИЕ ДИАЛОГИ">
|
||||
<div className="sales-history-card"><div><strong>Консультация по тарифам</strong><span>3 дня назад</span></div><p>FirePage · MAX · закрыт. Клиент уточнял лимиты тарифов, продажа не оформлена.</p></div>
|
||||
<div className="sales-history-card current"><div><strong>Текущий диалог</strong><span>сейчас</span></div><p>FirePage · MAX · ждёт оператора. Вопрос по amoCRM-интеграции для команды.</p></div>
|
||||
</ContextSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextSection({ title, action, children }: { title: string; action?: string; children: ReactNode }) {
|
||||
return <section className="sales-context-section"><div className="sales-context-section-head"><h4>{title}</h4>{action && <button>{action}</button>}</div>{children}</section>;
|
||||
}
|
||||
|
||||
function ContactRow({ dot, icon, title, text, note, mono = false, muted = false }: { dot?: string; icon?: "mail" | "phone"; title: string; text: string; note?: string; mono?: boolean; muted?: boolean }) {
|
||||
return (
|
||||
<div className="sales-contact-row">
|
||||
<span>{dot && <i style={{ background: dot }} />}{icon && <Icon name={icon} size={15} />}</span>
|
||||
<div><strong className={`${mono ? "mono" : ""} ${muted ? "muted" : ""}`}>{title}</strong><small>{text}</small></div>
|
||||
{note && <em>{note}</em>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { channelMeta, statusFor } from "./data";
|
||||
import type { ControlMode, SalesDialog, StatusInfo } from "./types";
|
||||
|
||||
export function SalesConversation({ controlMode, selected, setControlMode }: { controlMode: ControlMode; selected: SalesDialog; setControlMode: (mode: ControlMode) => void }) {
|
||||
const status = statusFor(controlMode);
|
||||
const channel = channelMeta[selected.channel];
|
||||
return (
|
||||
<>
|
||||
<div className="sales-conversation-head">
|
||||
<div className="sales-conversation-person">
|
||||
<span style={{ background: selected.avatarBg }}>{selected.initials}</span>
|
||||
<div>
|
||||
<div><strong>{selected.name}</strong><StatusBadge status={status} /></div>
|
||||
<p>{selected.product}<i /> <em style={{ background: channel.color }} />{channel.label}<i />{channel.handle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sales-conversation-actions">
|
||||
{controlMode === "waiting" && <button className="sales-claim-button" onClick={() => setControlMode("human")}><Icon name="check" size={15} />Забрать</button>}
|
||||
{controlMode === "ai" && <button className="sales-ai-button" onClick={() => setControlMode("human")}>Перехватить AI</button>}
|
||||
<button className="sales-more-button" aria-label="Действия диалога"><Icon name="more" size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sales-timeline">
|
||||
<div className="sales-timeline-inner">
|
||||
<div className="sales-day-divider"><span />Сегодня<span /></div>
|
||||
<div className="sales-event-chip"><Icon name="clock" size={12} />Диалог начат · канал MAX · 14:02</div>
|
||||
<Message side="client" initials="МС" avatarBg="#eb6f4b" time="14:02">Здравствуйте! Смотрю FirePage для лендингов. Есть тариф на команду?</Message>
|
||||
<Message side="ai" actor="AI-агент FirePage" time="14:03">Да! Для команд есть тариф <b>Business</b>: до 10 пользователей, общие проекты и приоритетная поддержка. Помесячная оплата доступна.</Message>
|
||||
<Message side="client" initials="МС" avatarBg="#eb6f4b" time="14:06">А интеграция с amoCRM есть? Лучше бы поговорить с менеджером.</Message>
|
||||
<div className="sales-event-chip warning"><Icon name="team" size={12} />AI передал диалог оператору · причина: запрос человека (CRM-интеграция)</div>
|
||||
{controlMode === "waiting" && <div className="sales-wait-note">Ожидает оператора · 4 мин</div>}
|
||||
{controlMode === "ai" && <TypingMessage />}
|
||||
{controlMode === "human" && <div className="sales-event-chip claimed"><Icon name="check" size={13} />Иван Петров забрал диалог · 14:09</div>}
|
||||
{controlMode === "human" && <Message side="operator" initials="ИП" avatarBg="#1677ff" actor="Иван · оператор" time="14:10">Здравствуйте, Мария! Я подключился. По amoCRM — у FirePage Business есть готовая двусторонняя интеграция, лиды и сделки синхронизируются автоматически. Расскажу подробнее?</Message>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: StatusInfo }) {
|
||||
return <span className="sales-status-badge" style={{ background: status.bg, borderColor: status.border, color: status.color }}><i style={{ background: status.dot }} />{status.label}</span>;
|
||||
}
|
||||
|
||||
function Message({ side, initials, avatarBg, actor, time, children }: { side: "ai" | "client" | "operator"; initials?: string; avatarBg?: string; actor?: string; time: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className={`sales-message ${side}`}>
|
||||
<div className="sales-message-avatar">{side === "ai" ? <Icon name="robot" size={16} /> : <span style={{ background: avatarBg }}>{initials}</span>}</div>
|
||||
<div className="sales-message-content">
|
||||
{actor && <strong>{actor}</strong>}
|
||||
<div>{children}</div>
|
||||
<small>{time}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypingMessage() {
|
||||
return (
|
||||
<div className="sales-message ai typing">
|
||||
<div className="sales-message-avatar"><Icon name="robot" size={16} /></div>
|
||||
<div className="sales-typing-bubble"><span /><span /><span /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ListTab, SalesDialog } from "./types";
|
||||
import { channelMeta, modeDots } from "./data";
|
||||
import { Icon } from "../../../shared/icons";
|
||||
|
||||
export function SalesDialogList({ dialogs, filtered, listTab, selectedId, setListTab, setSelectedId }: {
|
||||
dialogs: SalesDialog[];
|
||||
filtered: SalesDialog[];
|
||||
listTab: ListTab;
|
||||
selectedId: number;
|
||||
setListTab: (tab: ListTab) => void;
|
||||
setSelectedId: (id: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="sales-dialog-list">
|
||||
<div className="sales-dialog-list-head">
|
||||
<div><h2>Диалоги</h2><span>{dialogs.length} всего</span></div>
|
||||
<label className="sales-dialog-search"><Icon name="search" size={15} /><input placeholder="Поиск по клиенту, продукту…" /></label>
|
||||
</div>
|
||||
<div className="sales-dialog-tabs">
|
||||
<DialogTab active={listTab === "all"} onClick={() => setListTab("all")}>Все</DialogTab>
|
||||
<DialogTab active={listTab === "wait"} onClick={() => setListTab("wait")}>Ждут оператора <b>2</b></DialogTab>
|
||||
<DialogTab active={listTab === "ai"} onClick={() => setListTab("ai")}>AI</DialogTab>
|
||||
<DialogTab active={listTab === "operator"} onClick={() => setListTab("operator")}>Оператор</DialogTab>
|
||||
<DialogTab active={listTab === "unread"} onClick={() => setListTab("unread")}>Непрочитанные</DialogTab>
|
||||
</div>
|
||||
<div className="sales-dialog-list-body">
|
||||
{filtered.map((dialog) => <DialogListItem dialog={dialog} active={dialog.id === selectedId} setSelectedId={setSelectedId} key={dialog.id} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return <button className={active ? "active" : ""} onClick={onClick}>{children}</button>;
|
||||
}
|
||||
|
||||
function DialogListItem({ dialog, active, setSelectedId }: { dialog: SalesDialog; active: boolean; setSelectedId: (id: number) => void }) {
|
||||
const channel = channelMeta[dialog.channel];
|
||||
return (
|
||||
<button className={`sales-dialog-row ${active ? "active" : ""}`} onClick={() => setSelectedId(dialog.id)}>
|
||||
<span className="sales-dialog-row-bar" />
|
||||
<span className="sales-dialog-avatar-wrap">
|
||||
<span className="sales-dialog-avatar" style={{ background: dialog.avatarBg }}>{dialog.initials}</span>
|
||||
<i style={{ background: modeDots[dialog.mode] }} />
|
||||
</span>
|
||||
<span className="sales-dialog-row-main">
|
||||
<span className="sales-dialog-row-title"><strong>{dialog.name}</strong><em>{dialog.time}</em></span>
|
||||
<span className="sales-dialog-row-meta"><small>{dialog.product}</small><b style={{ background: channel.bg, color: channel.color }}><i style={{ background: channel.color }} />{channel.label}</b></span>
|
||||
<span className="sales-dialog-row-preview"><small className={dialog.unread ? "unread" : ""}>{dialog.preview}</small>{dialog.unread > 0 && <b>{dialog.unread}</b>}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ChannelKey, ControlMode, DialogMode, SalesDialog, StatusInfo } from "./types";
|
||||
|
||||
export const dialogs: SalesDialog[] = [
|
||||
{ id: 1, name: "Мария Соколова", initials: "МС", avatarBg: "#eb6f4b", product: "FirePage", channel: "MAX", mode: "wait", preview: "Вопрос по интеграции с CRM — можно с менеджером?", time: "14:08", unread: 2 },
|
||||
{ id: 2, name: "Дмитрий Орлов", initials: "ДО", avatarBg: "#3b82c4", product: "Foxray", channel: "TG", mode: "ai", preview: "AI: Подскажу по тарифам Foxray Pro…", time: "14:07", unread: 0 },
|
||||
{ id: 3, name: "Гость 8842", initials: "Г8", avatarBg: "#8c8c8c", product: "FirePage", channel: "WEB", mode: "wait", preview: "Не приходит чек на почту после оплаты", time: "13:55", unread: 1 },
|
||||
{ id: 4, name: "Елена Кузнецова", initials: "ЕК", avatarBg: "#9254de", product: "Foxray", channel: "MAX", mode: "operator", preview: "Вы: Отправила счёт, жду подтверждения", time: "13:50", unread: 0 },
|
||||
{ id: 5, name: "Сергей Волков", initials: "СВ", avatarBg: "#13a8a8", product: "FirePage", channel: "TG", mode: "ai", preview: "Клиент: А пробный период есть?", time: "13:42", unread: 1 },
|
||||
{ id: 6, name: "Ольга Зайцева", initials: "ОЗ", avatarBg: "#d4860b", product: "Foxray", channel: "WEB", mode: "operator", preview: "Вы: Помогу с настройкой", time: "13:30", unread: 0 },
|
||||
{ id: 7, name: "Павел Новиков", initials: "ПН", avatarBg: "#52a838", product: "FirePage", channel: "MAX", mode: "closed", preview: "Диалог закрыт · продажа Business", time: "12:58", unread: 0 },
|
||||
{ id: 8, name: "Гость 5510", initials: "Г5", avatarBg: "#8c8c8c", product: "Foxray", channel: "TG", mode: "ai", preview: "AI: Чем могу помочь?", time: "11:20", unread: 0 },
|
||||
];
|
||||
|
||||
export const channelMeta: Record<ChannelKey, { label: string; color: string; bg: string; handle: string }> = {
|
||||
MAX: { label: "MAX", color: "#6b5be0", bg: "#f2f0ff", handle: "@maria.s" },
|
||||
TG: { label: "Telegram", color: "#2f8fd0", bg: "#eaf6fd", handle: "@maria.s" },
|
||||
WEB: { label: "Web", color: "#0f9b8e", bg: "#e8f7f4", handle: "web-chat" },
|
||||
};
|
||||
|
||||
export const modeDots: Record<DialogMode, string> = {
|
||||
wait: "#faad14",
|
||||
ai: "#722ed1",
|
||||
operator: "#1677ff",
|
||||
closed: "#bfbfbf",
|
||||
};
|
||||
|
||||
export function statusFor(mode: ControlMode): StatusInfo {
|
||||
if (mode === "ai") return { label: "AI отвечает", color: "#722ed1", bg: "#f9f0ff", border: "#efdbff", dot: "#722ed1" };
|
||||
if (mode === "human") return { label: "Вы ведёте диалог", color: "#0958d9", bg: "#e6f4ff", border: "#91caff", dot: "#1677ff" };
|
||||
return { label: "Ждёт оператора", color: "#d48806", bg: "#fffbe6", border: "#ffe58f", dot: "#faad14" };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type DialogMode = "ai" | "closed" | "operator" | "wait";
|
||||
export type ControlMode = "ai" | "human" | "waiting";
|
||||
export type ListTab = "ai" | "all" | "operator" | "unread" | "wait";
|
||||
export type RightTab = "client" | "history" | "product";
|
||||
export type ChannelKey = "MAX" | "TG" | "WEB";
|
||||
|
||||
export type SalesDialog = {
|
||||
id: number;
|
||||
name: string;
|
||||
initials: string;
|
||||
avatarBg: string;
|
||||
product: string;
|
||||
channel: ChannelKey;
|
||||
mode: DialogMode;
|
||||
preview: string;
|
||||
time: string;
|
||||
unread: number;
|
||||
};
|
||||
|
||||
export type StatusInfo = {
|
||||
label: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
border: string;
|
||||
dot: string;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { DepartmentsPage } from "../features/departments/DepartmentsPage";
|
||||
import { EmployeeDetailPage, EmployeesPage } from "../features/employees/EmployeesPage";
|
||||
import { ProductsPage } from "../features/products/ProductsPage";
|
||||
import { ProfilePage } from "../features/profile/ProfilePage";
|
||||
import { SalesDialogsPage } from "../features/sales/SalesDialogsPage";
|
||||
import { SalesOverviewPage } from "../features/sales/SalesOverviewPage";
|
||||
import { Avatar } from "../shared/ui";
|
||||
import { Icon, PulseIcon } from "../shared/icons";
|
||||
@@ -51,10 +52,10 @@ function Sidebar({ route, user, setRoute }: { route: RouteKey; user: SessionUser
|
||||
);
|
||||
}
|
||||
|
||||
function SalesSidebar({ user, setRoute }: { user: SessionUser; setRoute: (route: RouteKey) => void }) {
|
||||
function SalesSidebar({ route, user, setRoute }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void }) {
|
||||
const nav = [
|
||||
{ label: "Обзор", icon: "grid" as const, active: true },
|
||||
{ label: "Диалоги", icon: "bell" as const, badge: "2" },
|
||||
{ key: "salesOverview" as const, label: "Обзор", icon: "grid" as const },
|
||||
{ key: "salesDialogs" as const, label: "Диалоги", icon: "bell" as const, badge: "2" },
|
||||
{ label: "Клиенты", icon: "team" as const },
|
||||
{ label: "Продажи", icon: "box" as const },
|
||||
];
|
||||
@@ -68,14 +69,18 @@ function SalesSidebar({ user, setRoute }: { user: SessionUser; setRoute: (route:
|
||||
<div><strong>Продажи</strong><span>Рабочее пространство</span></div>
|
||||
</div>
|
||||
<nav className="hub-nav sales-workspace-nav">
|
||||
{nav.map((item) => (
|
||||
<button className={`hub-nav-item ${item.active ? "is-active" : ""}`} type="button" onClick={() => item.active && setRoute("salesOverview")} key={item.label}>
|
||||
{item.active && <span className="active-bar" />}
|
||||
{nav.map((item) => {
|
||||
const nextRoute = "key" in item ? item.key : null;
|
||||
const active = nextRoute === route;
|
||||
return (
|
||||
<button className={`hub-nav-item ${active ? "is-active" : ""}`} type="button" onClick={() => nextRoute && setRoute(nextRoute)} key={item.label}>
|
||||
{active && <span className="active-bar" />}
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
{item.badge && <b>{item.badge}</b>}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button className="profile-link" onClick={() => setRoute("profile")}>
|
||||
<Avatar user={user} />
|
||||
@@ -88,15 +93,15 @@ function SalesSidebar({ user, setRoute }: { user: SessionUser; setRoute: (route:
|
||||
function TopBar({ route, user, currentEmployee, setRoute }: { route: RouteKey; user: SessionUser; currentEmployee?: Employee | null; setRoute: (route: RouteKey) => void }) {
|
||||
const st = commandCenterModel("today").st;
|
||||
const isCommand = route === "command";
|
||||
const isSalesOverview = route === "salesOverview";
|
||||
const isSalesWorkspace = route === "salesOverview" || route === "salesDialogs";
|
||||
return (
|
||||
<header className="hub-topbar">
|
||||
<div className="breadcrumbs">
|
||||
<button type="button" onClick={() => setRoute("command")}>{user.organizationName}</button>
|
||||
<i>/</i>
|
||||
{route === "employeeDetail" && <><button type="button" onClick={() => setRoute("employees")}>Сотрудники</button><i>/</i><strong>{currentEmployee?.fullName || currentEmployee?.email || routes[route]}</strong></>}
|
||||
{isSalesOverview && <><button type="button" onClick={() => setRoute("salesOverview")}>Продажи</button><i>/</i><strong>Обзор</strong></>}
|
||||
{route !== "employeeDetail" && !isSalesOverview && <strong>{routes[route]}</strong>}
|
||||
{isSalesWorkspace && <><button type="button" onClick={() => setRoute("salesOverview")}>Продажи</button><i>/</i><strong>{routes[route]}</strong></>}
|
||||
{route !== "employeeDetail" && !isSalesWorkspace && <strong>{routes[route]}</strong>}
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
{isCommand && <span className="topbar-status" style={{ background: st.bg, borderColor: st.border, color: st.color }}><span style={{ background: st.dot }} />{st.label}</span>}
|
||||
@@ -113,14 +118,15 @@ export function Shell({ route, setRoute, selectedEmployeeId, openEmployeeRoute,
|
||||
function openEmployee(employee: Employee) {
|
||||
openEmployeeRoute(employee.id);
|
||||
}
|
||||
const isSalesWorkspace = route === "salesOverview";
|
||||
const isSalesWorkspace = route === "salesOverview" || route === "salesDialogs";
|
||||
const isSalesDialogs = route === "salesDialogs";
|
||||
return (
|
||||
<div className="hub-shell">
|
||||
{isSalesWorkspace ? <SalesSidebar user={user} setRoute={setRoute} /> : <Sidebar route={route} user={user} setRoute={setRoute} />}
|
||||
{isSalesWorkspace ? <SalesSidebar route={route} user={user} setRoute={setRoute} /> : <Sidebar route={route} user={user} setRoute={setRoute} />}
|
||||
<div className="hub-main">
|
||||
<TopBar route={route} user={user} currentEmployee={currentEmployee} setRoute={setRoute} />
|
||||
<main className="hub-scroll">
|
||||
<div className={`hub-page ${isSalesWorkspace ? "sales-workspace-page" : ""}`}>
|
||||
<main className={`hub-scroll ${isSalesDialogs ? "sales-dialogs-scroll" : ""}`}>
|
||||
<div className={`hub-page ${isSalesWorkspace ? "sales-workspace-page" : ""} ${isSalesDialogs ? "sales-dialogs-page" : ""}`}>
|
||||
{route === "command" && <CommandCenter data={data} setRoute={setRoute} />}
|
||||
{route === "departments" && <DepartmentsPage data={data} setRoute={setRoute} />}
|
||||
{route === "employees" && <EmployeesPage employees={data.employees} reload={reload} openEmployee={openEmployee} />}
|
||||
@@ -129,6 +135,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, openEmployeeRoute,
|
||||
{route === "products" && <ProductsPage products={data.products} reload={reload} />}
|
||||
{route === "profile" && <ProfilePage user={user} onUserUpdated={onUserUpdated} reload={reload} onLogout={onLogout} />}
|
||||
{route === "salesOverview" && <SalesOverviewPage products={data.products} />}
|
||||
{route === "salesDialogs" && <SalesDialogsPage />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ export function routeFromPath(pathname: string): RouteState {
|
||||
if (path === "/" || path === "/command") return { route: "command", employeeId: null };
|
||||
if (path === "/departments") return { route: "departments", employeeId: null };
|
||||
if (path === "/departments/sales") return { route: "salesOverview", employeeId: null };
|
||||
if (path === "/departments/sales/dialogs") return { route: "salesDialogs", employeeId: null };
|
||||
if (path === "/employees") return { route: "employees", employeeId: null };
|
||||
if (path.startsWith("/employees/")) {
|
||||
const id = Number(path.split("/")[2]);
|
||||
@@ -24,6 +25,7 @@ export function pathFromRoute(route: RouteKey, employeeId: number | null = null)
|
||||
if (route === "command") return "/";
|
||||
if (route === "departments") return "/departments";
|
||||
if (route === "salesOverview") return "/departments/sales";
|
||||
if (route === "salesDialogs") return "/departments/sales/dialogs";
|
||||
if (route === "employees") return "/employees";
|
||||
if (route === "employeeDetail") return employeeId ? `/employees/${employeeId}` : "/employees";
|
||||
if (route === "products") return "/products";
|
||||
|
||||
@@ -7,5 +7,6 @@ export const routes: Record<RouteKey, string> = {
|
||||
employees: "Сотрудники",
|
||||
products: "Продукты",
|
||||
profile: "Профиль",
|
||||
salesDialogs: "Диалоги",
|
||||
salesOverview: "Обзор",
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Icon } from "./icons";
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "",
|
||||
type = "text",
|
||||
mono = false,
|
||||
wide = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
type?: "password" | "text";
|
||||
mono?: boolean;
|
||||
wide?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className={`readonly-field ${wide ? "wide" : ""}`}>
|
||||
<span>{label}</span>
|
||||
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!onChange} onChange={(event) => onChange?.(event.target.value)} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectField({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<[string, string]> }) {
|
||||
return (
|
||||
<label className="readonly-field select-like">
|
||||
<span>{label}</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map(([optionValue, labelText]) => <option value={optionValue} key={optionValue}>{labelText}</option>)}
|
||||
</select>
|
||||
<Icon name="chevron" size={14} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwitchButton({ checked, onClick, className, label, disabled = false }: { checked: boolean; onClick: () => void; className: string; label: string; disabled?: boolean }) {
|
||||
return <button className={`${className}${checked ? " on" : ""}`} type="button" role="switch" aria-checked={checked} aria-label={label} onClick={onClick} disabled={disabled}><i /></button>;
|
||||
}
|
||||
|
||||
export function KeyValue({ label, value }: { label: string; value: ReactNode }) {
|
||||
return <div className="key-value"><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
export function MetricBox({ label, value }: { label: string; value: ReactNode }) {
|
||||
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function ShieldIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "plus" | "arrow" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "warning" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "percent" | "save" | "check" | "copy" | "clock"; size?: number }) {
|
||||
export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "warning" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "percent" | "save" | "check" | "copy" | "clock"; size?: number }) {
|
||||
const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" } as const;
|
||||
const paths: Record<typeof name, ReactNode> = {
|
||||
grid: <><rect x="3" y="3" width="7" height="9" rx="1.4" /><rect x="14" y="3" width="7" height="5" rx="1.4" /><rect x="14" y="12" width="7" height="9" rx="1.4" /><rect x="3" y="16" width="7" height="5" rx="1.4" /></>,
|
||||
@@ -31,6 +31,9 @@ export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" |
|
||||
chevron: <polyline points="6 9 12 15 18 9" />,
|
||||
plus: <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>,
|
||||
arrow: <><line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" /></>,
|
||||
paperclip: <path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />,
|
||||
send: <><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></>,
|
||||
phone: <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92Z" />,
|
||||
refresh: <><path d="M3 12a9 9 0 0 1 15-6.7L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-15 6.7L3 16" /><path d="M3 21v-5h5" /></>,
|
||||
warning: <><path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></>,
|
||||
bolt: <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -58,7 +58,7 @@ export type Product = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RouteKey = "command" | "departments" | "employeeDetail" | "employees" | "products" | "profile" | "salesOverview";
|
||||
export type RouteKey = "command" | "departments" | "employeeDetail" | "employees" | "products" | "profile" | "salesDialogs" | "salesOverview";
|
||||
|
||||
export type AppData = {
|
||||
employees: Employee[];
|
||||
|
||||
Reference in new issue
Block a user