diff --git a/apps/internal-ui/src/features/employees/EmployeeDetailHeader.tsx b/apps/internal-ui/src/features/employees/EmployeeDetailHeader.tsx new file mode 100644 index 0000000..8f7badd --- /dev/null +++ b/apps/internal-ui/src/features/employees/EmployeeDetailHeader.tsx @@ -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 ( +
+
+ +
+
+

{form.fullName || form.email}

+ + +
+

{form.email} · {departmentLabel}

+
+
+
+ + +
+
+ ); +} diff --git a/apps/internal-ui/src/features/employees/EmployeeDetailPage.tsx b/apps/internal-ui/src/features/employees/EmployeeDetailPage.tsx new file mode 100644 index 0000000..3096112 --- /dev/null +++ b/apps/internal-ui/src/features/employees/EmployeeDetailPage.tsx @@ -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(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 ( + <> + + {message &&
{message}
} +
+ + +
+ + ); +} diff --git a/apps/internal-ui/src/features/employees/EmployeeDetailRail.tsx b/apps/internal-ui/src/features/employees/EmployeeDetailRail.tsx new file mode 100644 index 0000000..5dfcfde --- /dev/null +++ b/apps/internal-ui/src/features/employees/EmployeeDetailRail.tsx @@ -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; status: EmployeeStatus }) { + return ( + + ); +} diff --git a/apps/internal-ui/src/features/employees/EmployeeDetailSections.tsx b/apps/internal-ui/src/features/employees/EmployeeDetailSections.tsx new file mode 100644 index 0000000..87623ef --- /dev/null +++ b/apps/internal-ui/src/features/employees/EmployeeDetailSections.tsx @@ -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; + form: EmployeeForm; + resetPassword: () => void; + revokeSessions: () => void; + toggleBlocked: () => void; + updateForm: (field: keyof EmployeeForm, value: string | boolean) => void; +}) { + return ( +
+
+

Основные данные

+
+ updateForm("fullName", value)} /> + updateForm("phone", value)} /> + updateForm("email", value)} mono wide /> +
+
+ +
+

Роль и доступ

+
+ updateForm("role", value)} options={[["OPERATOR", "OPERATOR"], ["OWNER", "OWNER"]]} /> + updateForm("department", value)} options={[["sales", "Отдел продаж"]]} /> +
+ +
+ Диалоги + Клиенты + Заказы · чтение + Финансы · нет + Настройки · нет +
+
+ +
+

Безопасность

+
+
ПарольПоследняя смена: {details.security.passwordChangedAt}
+ +
+
+
Двухфакторная аутентификация (TOTP){form.totpEnabled ? "Включена · требуется при каждом входе" : "Отключена для этого сотрудника"}
+ updateForm("totpEnabled", !form.totpEnabled)} /> +
+
+
Активные сессии{details.security.sessions}
+ +
+
+ +
+

Опасная зона

+
+

{blocked ? "Сотрудник заблокирован и не может войти. Разблокировка восстановит доступ к разделам отдела." : "Блокировка немедленно завершит все сессии и закроет доступ. Активные диалоги вернутся в очередь. Действие требует подтверждения."}

+ +
+
+
+ ); +} diff --git a/apps/internal-ui/src/features/employees/EmployeeTable.tsx b/apps/internal-ui/src/features/employees/EmployeeTable.tsx new file mode 100644 index 0000000..e4dd83c --- /dev/null +++ b/apps/internal-ui/src/features/employees/EmployeeTable.tsx @@ -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 ( +
+
+ + + + {employees.map((employee) => )} + +
СОТРУДНИКРОЛЬОТДЕЛСТАТУСПОСЛЕДНИЙ ВХОДДИАЛОГИ
+
+ {!employees.length && } +
+ Показано {employees.length} из {total} + Сессии и пароли управляются в карточке сотрудника +
+
+ ); +} + +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 ( + +
+ + {employee.department === "sales" ? "Продажи" : "—"} + + {details.account.lastLogin} + {dialogs} + + + {menuOpen && ( +
+ + event.preventDefault()}>Завершить сессии + event.preventDefault()}>Сбросить пароль + + +
+ )} + + + ); +} diff --git a/apps/internal-ui/src/features/employees/EmployeesFilters.tsx b/apps/internal-ui/src/features/employees/EmployeesFilters.tsx new file mode 100644 index 0000000..220d18d --- /dev/null +++ b/apps/internal-ui/src/features/employees/EmployeesFilters.tsx @@ -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 ( +
+ +
+ Роль + +
+
+ Статус + +
+ +
+ ); +} diff --git a/apps/internal-ui/src/features/employees/EmployeesListPage.tsx b/apps/internal-ui/src/features/employees/EmployeesListPage.tsx new file mode 100644 index 0000000..34a6ea7 --- /dev/null +++ b/apps/internal-ui/src/features/employees/EmployeesListPage.tsx @@ -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("all"); + const [query, setQuery] = useState(""); + const [menuId, setMenuId] = useState(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 && } + /> + { setQuery(nextQuery); setMenuId(null); }} + setRole={(nextRole) => { setRole(nextRole); setMenuId(null); }} + setStatus={(nextStatus) => { setStatus(nextStatus); setMenuId(null); }} + /> + + + ); +} diff --git a/apps/internal-ui/src/features/employees/EmployeesPage.tsx b/apps/internal-ui/src/features/employees/EmployeesPage.tsx index 7f89c8b..9ec1bf3 100644 --- a/apps/internal-ui/src/features/employees/EmployeesPage.tsx +++ b/apps/internal-ui/src/features/employees/EmployeesPage.tsx @@ -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(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 && } - /> -
- -
- Роль - { setRole(nextRole); setMenuId(null); }} items={[["all", "Все"], ["OWNER", "OWNER"], ["OPERATOR", "OPERATOR"]]} /> -
-
- Статус - { setStatus(nextStatus); setMenuId(null); }} items={[["all", "Все"], ["active", "Активные"], ["invited", "Приглашённые"], ["blocked", "Заблокированные"]]} /> -
- -
-
-
- - - - {filtered.map((employee) => )} - -
СОТРУДНИКРОЛЬОТДЕЛСТАТУСПОСЛЕДНИЙ ВХОДДИАЛОГИ
-
- {!filtered.length && } -
- Показано {filtered.length} из {employees.length} - Сессии и пароли управляются в карточке сотрудника -
-
- - ); -} - -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 ( - -
- - {employee.department === "sales" ? "Продажи" : "—"} - - {details.account.lastLogin} - {dialogs} - - - {menuOpen && ( -
- - event.preventDefault()}>Завершить сессии - event.preventDefault()}>Сбросить пароль - - -
- )} - - - ); -} - -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 ( - <> -
-
- -
-
-

{form.fullName || form.email}

- - -
-

{form.email} · {details.departmentLabel}

-
-
-
- - -
-
- {message &&
{message}
} - -
-
-
-

Основные данные

-
- updateForm("fullName", value)} /> - updateForm("phone", value)} /> - updateForm("email", value)} mono wide /> -
-
- -
-

Роль и доступ

-
- updateForm("role", value)} options={[["OPERATOR", "OPERATOR"], ["OWNER", "OWNER"]]} /> - updateForm("department", value)} options={[["sales", "Отдел продаж"]]} /> -
- -
- Диалоги - Клиенты - Заказы · чтение - Финансы · нет - Настройки · нет -
-
- -
-

Безопасность

-
-
ПарольПоследняя смена: {details.security.passwordChangedAt}
- -
-
-
Двухфакторная аутентификация (TOTP){form.totpEnabled ? "Включена · требуется при каждом входе" : "Отключена для этого сотрудника"}
- -
-
-
Активные сессии{details.security.sessions}
- -
-
- -
-

Опасная зона

-
-

{blocked ? "Сотрудник заблокирован и не может войти. Разблокировка восстановит доступ к разделам отдела." : "Блокировка немедленно завершит все сессии и закроет доступ. Активные диалоги вернутся в очередь. Действие требует подтверждения."}

- -
-
-
- - -
- - ); -} - -function EditableField({ label, value, onChange, mono = false, wide = false }: { label: string; value: string; onChange: (value: string) => void; mono?: boolean; wide?: boolean }) { - return ; -} - -function SelectLike({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<[string, string]> }) { - return ( - - ); -} - -function KeyValue({ label, value }: { label: string; value: ReactNode }) { - return
{label}{value}
; -} - -function MetricBox({ label, value }: { label: string; value: ReactNode }) { - return
{label}{value}
; -} - -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"; diff --git a/apps/internal-ui/src/features/employees/model.ts b/apps/internal-ui/src/features/employees/model.ts new file mode 100644 index 0000000..70e005e --- /dev/null +++ b/apps/internal-ui/src/features/employees/model.ts @@ -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: "—" }, + ], + }; +} diff --git a/apps/internal-ui/src/features/sales/SalesDialogsPage.tsx b/apps/internal-ui/src/features/sales/SalesDialogsPage.tsx new file mode 100644 index 0000000..6ad356f --- /dev/null +++ b/apps/internal-ui/src/features/sales/SalesDialogsPage.tsx @@ -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("all"); + const [rightTab, setRightTab] = useState("client"); + const [selectedId, setSelectedId] = useState(1); + const [controlMode, setControlMode] = useState("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 ( +
+ +
+ + +
+ +
+ ); +} diff --git a/apps/internal-ui/src/features/sales/dialogs/SalesComposer.tsx b/apps/internal-ui/src/features/sales/dialogs/SalesComposer.tsx new file mode 100644 index 0000000..dcc8a0d --- /dev/null +++ b/apps/internal-ui/src/features/sales/dialogs/SalesComposer.tsx @@ -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 ( +
+ +
Диалог ждёт оператора

Возьмите диалог, чтобы ответить клиенту. AI поставлен на паузу.

+ + +
+ ); + } + + if (mode === "ai") { + return ( +
+ +
AI ведёт диалог

Перехватите, чтобы ответить вручную. Контроль перейдёт к вам.

+ +
+ ); + } + + return ( +
+
+ + + + + +
+
+ +