From ae7e1daf89f06e8345e832a1b678d4195a39684e Mon Sep 17 00:00:00 2001 From: lizerk Date: Sun, 12 Apr 2026 18:02:47 +0300 Subject: [PATCH] refactor(frontend): align profile, notifications and procurement flows --- backend/procurement/notifications/config.py | 2 +- backend/procurement/notifications/handlers.py | 14 +- docs/frontend-style-guide.md | 25 + frontend/src/app/employees/page.tsx | 8 +- frontend/src/app/notifications/page.tsx | 6 +- frontend/src/app/page.tsx | 6 +- frontend/src/app/procurement/page.tsx | 467 ++++++++---- frontend/src/app/profile/page.tsx | 701 ++++++------------ frontend/src/app/users/[id]/page.tsx | 563 +++++--------- .../src/components/NotificationCenter.tsx | 11 +- .../ProcurementRequestDetailContent.tsx | 124 ++++ .../users/EmployeeActionsTimeline.tsx | 82 +- .../src/components/users/ProfileSections.tsx | 321 ++++++++ frontend/src/hooks/useProcurementPage.ts | 37 +- frontend/src/lib/notifications/actionUrl.ts | 24 + frontend/src/lib/users/userDetailUtils.ts | 56 ++ 16 files changed, 1424 insertions(+), 1023 deletions(-) create mode 100644 frontend/src/components/procurement/ProcurementRequestDetailContent.tsx create mode 100644 frontend/src/components/users/ProfileSections.tsx create mode 100644 frontend/src/lib/notifications/actionUrl.ts diff --git a/backend/procurement/notifications/config.py b/backend/procurement/notifications/config.py index e6847892..11e48594 100644 --- a/backend/procurement/notifications/config.py +++ b/backend/procurement/notifications/config.py @@ -208,4 +208,4 @@ class ActionURLs: @staticmethod def request_detail(request_id: int) -> str: """URL детальной страницы заявки.""" - return f'/procurement/requests/{request_id}' + return f'/procurement?request={request_id}' diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index 01e29c1a..ce46c4ec 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -38,7 +38,7 @@ def notify_new_request(request): verb=NotificationVerbs.NEW_REQUEST, action_object=request, description=description, - action_url=ActionURLs.PROCUREMENT_LIST, + action_url=ActionURLs.request_detail(request.id), data={ 'title': notification_title, 'request_id': request.id, @@ -93,7 +93,7 @@ def notify_approver(approval): verb=NotificationVerbs.PENDING_APPROVAL, action_object=approval.request, description=description, - action_url=ActionURLs.PROCUREMENT_LIST, + action_url=ActionURLs.request_detail(approval.request.id), data={ 'title': notification_title, 'request_id': approval.request.id, @@ -124,7 +124,7 @@ def notify_requestor(request, verb, title, message): verb=verb, action_object=request, description=message, - action_url=ActionURLs.PROCUREMENT_LIST, + action_url=ActionURLs.request_detail(request.id), data={ 'title': title, 'request_id': request.id, @@ -203,7 +203,7 @@ def notify_request_completed(request): verb=NotificationVerbs.COMPLETED, action_object=request, description=description, - action_url=ActionURLs.PROCUREMENT_LIST, + action_url=ActionURLs.request_detail(request.id), data={ 'title': notification_title, 'request_id': request.id, @@ -241,7 +241,7 @@ def notify_request_in_progress(request, executor): verb=NotificationVerbs.IN_PROGRESS, action_object=request, description=description, - action_url=ActionURLs.PROCUREMENT_LIST, + action_url=ActionURLs.request_detail(request.id), data={ 'title': notification_title, 'request_id': request.id, @@ -261,7 +261,7 @@ def notify_request_in_progress(request, executor): verb=NotificationVerbs.IN_PROGRESS, action_object=request, description=description, - action_url=ActionURLs.PROCUREMENT_LIST, + action_url=ActionURLs.request_detail(request.id), data={ 'title': notification_title, 'request_id': request.id, @@ -291,7 +291,7 @@ def notify_request_cancelled(request): verb=NotificationVerbs.CANCELLED, action_object=request, description=description, - action_url=ActionURLs.PROCUREMENT_LIST, + action_url=ActionURLs.request_detail(request.id), data={ 'title': notification_title, 'request_id': request.id, diff --git a/docs/frontend-style-guide.md b/docs/frontend-style-guide.md index 782c0a0b..989df9e5 100644 --- a/docs/frontend-style-guide.md +++ b/docs/frontend-style-guide.md @@ -56,6 +56,31 @@ - Read-only экран: - минимум inline editing - одно явное действие лучше, чем несколько карандашей по карточкам +- Общий контракт профильно-подобных страниц: + - `ProfileHeroCard` + - `ProfileContactsPanel` + - `ProfileInfoCard` + - `ProfileSkillsCard` + - `EmployeeActionsTimeline` +- Hero: + - `app-card-caption` в шапке карточки + - status pill справа сверху + - аватар слева, имя и secondary meta справа + - внутри hero только один `app-surface-muted` блок контактов +- `Контакты`: + - строки с иконкой, заголовком и значением + - опциональные действия справа живут в строке, а не отдельной карточкой +- `Информация`: + - отдельный полноширинный `app-surface` + - внутри одна `app-surface-muted` сетка 2x2 +- `Навыки`: + - одинаковая add-row и chip-scale и на self, и на foreign profile +- `/profile`: + - self-oriented hero + - без chat/call actions +- `/users/[id]`: + - тот же hero-contract + - chat/call/edit/manage actions допустимы только как добавочные props, без отдельной layout-ветки ## Таймлайны diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index d58dce59..2ea81e2f 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -141,8 +141,8 @@ export default function EmployeesPage() { if (error) { return ( -
-

{error}

+
+

{error}

); @@ -185,11 +185,11 @@ export default function EmployeesPage() { + ) : null} + {isDraft && isAuthor ? ( + + ) : null} + {isDraft && (isAuthor || canManage) ? ( + + ) : null} + {isPending && !isAuthor ? ( + <> + + + + ) : null} + {isApproved && !request.executor ? ( + + ) : null} + {isInProgress && isExecutor ? ( + + ) : null} + {isAuthor && !isFinal(status) && status !== "draft" ? ( + + ) : null} +
+ ); +} + /* ══════════════════════════════════════════════════════ Main page component ══════════════════════════════════════════════════════ */ @@ -103,6 +255,7 @@ export default function ProcurementPage() { departments, detailsCache, displayUserName, + ensureRequestDetail, error, expandedIds, filteredRequests, @@ -132,6 +285,7 @@ export default function ProcurementPage() { periodFilter, removeItemRow, resolveUserId, + requests, searchQuery, setActiveSection, setDepartmentFilter, @@ -152,6 +306,70 @@ export default function ProcurementPage() { scopeCounts, } = useProcurementPage(user); + const [detailModalId, setDetailModalId] = useState(null); + const [detailModalLoading, setDetailModalLoading] = useState(false); + const [detailModalError, setDetailModalError] = useState(null); + + const syncRequestUrl = useCallback((requestId: number | null) => { + if (typeof window === "undefined") return; + + const url = new URL(window.location.href); + if (requestId) { + url.searchParams.set("request", String(requestId)); + } else { + url.searchParams.delete("request"); + } + + window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); + }, []); + + const selectedRequest = useMemo(() => { + if (!detailModalId) return null; + return detailsCache[detailModalId] || requests.find((request) => request.id === detailModalId) || null; + }, [detailModalId, detailsCache, requests]); + + const closeDetailModal = useCallback(() => { + setDetailModalId(null); + setDetailModalError(null); + syncRequestUrl(null); + }, [syncRequestUrl]); + + const openDetailModal = useCallback(async (requestId: number) => { + setDetailModalLoading(true); + setDetailModalError(null); + + try { + await ensureRequestDetail(requestId); + setDetailModalId(requestId); + syncRequestUrl(requestId); + } catch (detailError) { + setDetailModalError(getReadableError(detailError, "Не удалось открыть заявку")); + } finally { + setDetailModalLoading(false); + } + }, [ensureRequestDetail, syncRequestUrl]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const requestParam = new URLSearchParams(window.location.search).get("request"); + if (!requestParam) return; + + const requestId = Number(requestParam); + if (!Number.isFinite(requestId) || requestId <= 0) { + syncRequestUrl(null); + return; + } + + void openDetailModal(requestId); + }, [openDetailModal, syncRequestUrl]); + + useEffect(() => { + if (!detailModalId) return; + if (selectedRequest) return; + closeDetailModal(); + }, [closeDetailModal, detailModalId, selectedRequest]); + /* ══════════════════════════════════════════════════════ RENDER ══════════════════════════════════════════════════════ */ @@ -337,7 +555,14 @@ export default function ProcurementPage() {
-

{req.title || "Без названия"}

+
{getDeptName(req)} @@ -390,132 +615,28 @@ export default function ProcurementPage() { {expanded && (
-
-

Описание

-

{resolvedDetail.description || "—"}

-
-
-

Отправлена

-

{fmt(resolvedDetail.submitted_at) || "—"}

-
-
-

Взята в работу

-

{fmt(resolvedDetail.started_at) || "—"}

-
-
-

Завершена

-

{fmt(resolvedDetail.completed_at) || "—"}

-
-
- {resolvedDetail.actual_cost ? ( -
- Фактическая сумма: {money(resolvedDetail.actual_cost)} -
- ) : null} -
- - {detail?.items && detail.items.length > 0 && ( -
-

Позиции

-
- {detail.items.map((it, idx) => ( -
-
-
-

{it.name}

- {it.description &&

{it.description}

} - {it.supplier_info &&

Поставщик: {it.supplier_info}

} -
-
-

{money(it.total_price)}

-

{it.quantity} {it.unit}

-
-
-
- Цена/ед.: {money(it.estimated_unit_price)} -
-
- ))} -
-
- )} - - {detail?.approvals && detail.approvals.length > 0 && ( -
-

Согласования

-
- {detail.approvals.map((a) => { - const aSt = String(a.status).toLowerCase(); - const icon = aSt === "approved" ? : aSt === "rejected" ? : ; - return ( -
- {icon} - {displayUserName(a.approver, a.approver_name)} - ({a.step_label || `Этап ${a.priority}`}) - {a.comment && «{a.comment}»} -
- ); - })} -
-
- )} - -
- {isDraft && isAuthor && ( - + )} - {isDraft && isAuthor && ( - - )} - {isDraft && (isAuthor || canManage) && ( - - )} - {isPending && !isAuthor && ( - <> - - - - )} - {isApproved && !req.executor && ( - - )} - {isInProgress && isExecutor && ( - - )} - {isAuthor && !isFinal(st) && st !== "draft" && ( - - )} -
+ />
)} @@ -536,6 +657,96 @@ export default function ProcurementPage() { )} + + {detailModalLoading ? ( +
+ +

Загружаем карточку заявки...

+
+ ) : detailModalError ? ( +
+ {detailModalError} +
+ ) : selectedRequest ? ( +
+
+
+
+ + {statusMeta[String(selectedRequest.status || "").toLowerCase()]?.label ?? defaultStatusMeta.label} + + + {(urgencyMeta[selectedRequest.urgency]?.label ?? urgencyMeta.medium.label)} срочность + +
+
+ {getDeptName(selectedRequest)} + {fmt(selectedRequest.created_at)} + {getRequestAmount(selectedRequest) ? ( + {money(getRequestAmount(selectedRequest))} + ) : null} +
+
+
+ Заявитель:{" "} + + {displayUserName(selectedRequest.requestor, selectedRequest.requestor_name, selectedRequest.requestor_email)} + +
+
+ Исполнитель:{" "} + + {selectedRequest.executor || selectedRequest.executor_name + ? displayUserName(selectedRequest.executor, selectedRequest.executor_name || undefined) + : "не назначен"} + +
+
+
+
+ + {actionError ? ( +
+ {actionError} +
+ ) : null} + + + )} + /> +
+ ) : ( +
+

Заявка не найдена.

+
+ )} +
+ {/* ══════════ Create / Edit modal ══════════ */} diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 38eace31..f1ebf6b8 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -6,111 +6,44 @@ import { Mail, MessageCircle, Phone, - Plus, RefreshCw, - X, } from "lucide-react"; import { AppShell } from "@/components/AppShell"; +import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline"; +import { + ProfileContactsPanel, + ProfileDepartmentBadge, + ProfileHeroCard, + ProfileInfoCard, + ProfileSkillsCard, + type ProfileContactRow, + type ProfileInfoItem, +} from "@/components/users/ProfileSections"; import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/url"; import { + formatBirthdayWithYear, + formatProfileDate, + formatProfileDateTime, getEmployeeActionTone, + getLatestEmployeeAction, getWorkDuration, + getUserFullName, + normalizeTelegramLink, + normalizeWhatsAppLink, + sortEmployeeActions, } from "@/lib/users/userDetailUtils"; import type { DirectoryLoginResult } from "@/types/api"; -function formatDate(value?: string): string { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleDateString("ru-RU", { - day: "2-digit", - month: "2-digit", - year: "numeric", - }); -} - -function formatDateTime(value?: string): string { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString("ru-RU", { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -function formatBirthdayWithYear(value?: string): string | null { - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleDateString("ru-RU", { - day: "numeric", - month: "long", - year: "numeric", - }); -} - -function getActionTimestamp(action: { - date?: string; - created_at?: string; -}): number { - return new Date(action.date || action.created_at || 0).getTime(); -} - function initials(firstName?: string, lastName?: string) { return `${lastName?.[0] || ""}${firstName?.[0] || ""}`.trim() || "П"; } -function normalizeTelegram(value: string) { - const trimmed = value.trim(); - if (!trimmed) return ""; - if (/^https?:\/\//i.test(trimmed)) return trimmed; - return `https://t.me/${trimmed.replace(/^@/, "")}`; -} - -function normalizeWhatsApp(value: string) { - const trimmed = value.trim(); - if (!trimmed) return ""; - if (/^https?:\/\//i.test(trimmed)) return trimmed; - const digits = trimmed.replace(/[^\d]/g, ""); - return digits ? `https://wa.me/${digits}` : ""; -} - -function truncateText(value?: string, maxLength = 120) { - if (!value) return ""; - if (value.length <= maxLength) return value; - return `${value.slice(0, maxLength).trimEnd()}…`; -} - -function SectionTitle({ - title, - icon, - action, -}: { - title: string; - icon?: React.ReactNode; - action?: React.ReactNode; -}) { - return ( -
-
- {icon} -

{title}

-
- {action} -
- ); -} - export default function ProfilePage() { const { user, loading } = useUser(); - const [showAllActions, setShowAllActions] = useState(false); + const [copiedContactKey, setCopiedContactKey] = useState(null); const [directoryLogin, setDirectoryLogin] = useState(null); const [directoryLoginLoading, setDirectoryLoginLoading] = useState(false); const [directoryLoginRefreshing, setDirectoryLoginRefreshing] = @@ -130,37 +63,17 @@ export default function ProfilePage() { const [skillsError, setSkillsError] = useState(null); const fullName = useMemo(() => { - if (!user) return "Пользователь"; - return `${user.last_name || ""} ${user.first_name || ""} ${ - user.patronymic || "" - }`.trim() || "Пользователь"; + return getUserFullName(user) || "Пользователь"; }, [user]); const currentAction = useMemo(() => { - if (!user?.actions?.length) return null; - const now = Date.now(); - return [...user.actions] - .filter((action) => getActionTimestamp(action) <= now) - .sort((left, right) => getActionTimestamp(right) - getActionTimestamp(left))[0] || null; + return getLatestEmployeeAction(user?.actions); }, [user?.actions]); const sortedActions = useMemo(() => { - if (!user?.actions?.length) return []; - return [...user.actions].sort( - (left, right) => getActionTimestamp(right) - getActionTimestamp(left), - ); + return sortEmployeeActions(user?.actions); }, [user?.actions]); - const currentActionId = useMemo( - () => currentAction?.id ?? null, - [currentAction?.id], - ); - - const visibleActions = useMemo( - () => (showAllActions ? sortedActions : sortedActions.slice(0, 3)), - [showAllActions, sortedActions], - ); - useEffect(() => { setDirectoryLogin(user?.username?.trim() || null); setDirectoryLoginError(null); @@ -252,6 +165,16 @@ export default function ProfilePage() { } }; + const handleCopyContact = async (value: string, key: string) => { + try { + await navigator.clipboard.writeText(value); + setCopiedContactKey(key); + window.setTimeout(() => setCopiedContactKey((current) => (current === key ? null : current)), 2000); + } catch (error) { + console.error("Ошибка копирования:", error); + } + }; + const saveSkills = async (skillIds: number[]) => { try { setSkillsSaving(true); @@ -329,6 +252,145 @@ export default function ProfilePage() { } }; + const departmentBadge = useMemo(() => { + if (!user?.departments?.length) return null; + const label = user.departments.length === 1 + ? user.departments[0].name + : `${user.departments.length} отделов`; + + return ( + + ); + }, [user?.departments]); + + const contactRows = useMemo(() => { + if (!user) return []; + + const rows: ProfileContactRow[] = []; + + const email = user.email; + if (email) { + rows.push({ + key: "email", + label: "Почта", + value: email, + icon: , + onClick: () => void handleCopyContact(email, "email"), + copied: copiedContactKey === "email", + }); + } + + const phone = user.phone_number; + if (phone) { + rows.push({ + key: "phone", + label: "Телефон", + value: phone, + icon: , + onClick: () => void handleCopyContact(phone, "phone"), + copied: copiedContactKey === "phone", + }); + } + + const telegram = user.telegram; + if (telegram) { + rows.push({ + key: "telegram", + label: "Telegram", + value: telegram, + icon: , + onClick: () => void handleCopyContact(normalizeTelegramLink(telegram), "telegram"), + copied: copiedContactKey === "telegram", + }); + } + + const whatsapp = user.whatsapp; + if (whatsapp) { + rows.push({ + key: "whatsapp", + label: "WhatsApp", + value: whatsapp, + icon: , + onClick: () => void handleCopyContact(normalizeWhatsAppLink(whatsapp), "whatsapp"), + copied: copiedContactKey === "whatsapp", + }); + } + + const wechat = user.wechat; + if (wechat) { + rows.push({ + key: "wechat", + label: "WeChat", + value: wechat, + icon: , + onClick: () => void handleCopyContact(wechat, "wechat"), + copied: copiedContactKey === "wechat", + }); + } + + if (user.is_ldap_managed || directoryLoginLoading || directoryLoginError || directoryLogin) { + const directoryLoginValue = directoryLoginLoading + ? "Загружаем..." + : directoryLogin || "Не найден"; + rows.push({ + key: "directory-login", + label: "Логин в каталоге", + value: directoryLoginValue, + icon: ( + + ), + onClick: directoryLogin && !directoryLoginLoading + ? () => void handleCopyContact(directoryLogin, "directory-login") + : undefined, + copied: copiedContactKey === "directory-login", + action: ( + + ), + }); + } + + return rows; + }, [ + copiedContactKey, + directoryLogin, + directoryLoginLoading, + directoryLoginRefreshing, + directoryLoginError, + user, + ]); + + const infoItems = useMemo(() => ([ + { + label: "В компании", + value: getWorkDuration(user?.date_joined) || "—", + }, + { + label: "Дата найма", + value: formatProfileDate(user?.date_joined), + }, + { + label: "Профиль создан", + value: formatProfileDate(user?.created_at), + }, + { + label: "Последний вход", + value: formatProfileDateTime(user?.last_login), + }, + ]), [user?.created_at, user?.date_joined, user?.last_login]); + if (loading) { return ( @@ -349,387 +411,70 @@ export default function ProfilePage() { return (
-
-
-

Мой профиль

- {currentAction ? ( + {currentAction.action_display || currentAction.action} - ) : null} -
-
-
-
- {user.avatar ? ( - {fullName} - ) : ( -
- {initials(user.first_name, user.last_name)} -
- )} -
- -
-

- {fullName} -

-

- {formatBirthdayWithYear(user.birth_date) || "Не указана"} -

-
- - {user.position?.name || "Должность не указана"} - - {!!user.departments?.length && ( - - {user.departments.length === 1 - ? user.departments[0].name - : `${user.departments.length} отделов`} - - )} -
-
-
- -
-
-
- - - -
-

- Почта -

- {user.email ? ( - - {user.email} - - ) : ( -

Не указана

- )} -
-
-
-
- - - -
-

- Телефон -

- {user.phone_number ? ( - - {user.phone_number} - - ) : ( -

Не указан

- )} -
-
- {!!user.telegram && ( - <> -
-
- - - -
-

- Telegram -

- - {user.telegram} - -
-
- - )} - {!!user.whatsapp && ( - <> -
-
- - - -
-

- WhatsApp -

- - {user.whatsapp} - -
-
- - )} - {!!user.wechat && ( - <> -
-
- - - -
-

- WeChat -

-

- {user.wechat} -

-
-
- - )} - {(user.is_ldap_managed || - directoryLoginLoading || - directoryLoginError || - directoryLogin) && ( - <> -
-
- - - -
-
-
-

- Логин в каталоге -

-

- {directoryLoginLoading - ? "Загружаем..." - : directoryLogin || "Не найден"} -

-
- -
-
-
- - )} -
-
-
-
- -
- -
-
-
- setSkillName(event.target.value)} - placeholder="Добавить навык" - className="app-input w-full rounded-xl px-4 py-2.5 text-sm" - disabled={skillsSaving} + ) : null + } + avatar={ +
+ {user.avatar ? ( + {fullName} - - {availableSkills.map((skill) => ( - -
- -
- - {skillsError ? ( -

{skillsError}

- ) : null} - -
- {profileSkills.length ? ( -
- {profileSkills.map((skill) => ( - - ))} -
) : ( -

Навыки пока не указаны

+
+ {initials(user.first_name, user.last_name)} +
)}
+ } + fullName={fullName} + secondaryLine={formatBirthdayWithYear(user.birth_date) || "Не указана"} + roleText={user.position?.name || "Должность не указана"} + departmentBadge={departmentBadge} + bottomPanel={} + /> - {skillsLoading ? ( -

Загружаем навыки...

- ) : null} -
-
+ void handleAddSkill()} + submitDisabled={!skillName.trim() || skillsSaving} + inputDisabled={skillsSaving} + availableSkills={availableSkills} + skills={profileSkills} + error={skillsError} + loading={skillsLoading} + onRemoveSkill={(skillId) => void handleRemoveSkill(skillId)} + removeDisabled={skillsSaving} + emptyText="Навыки пока не указаны" + /> -
- -
-
-
-

- В компании -

-

- {getWorkDuration(user.date_joined) || "—"} -

-
-
-

- Дата найма -

-

- {formatDate(user.date_joined)} -

-
-
-
-
-
-

- Профиль создан -

-

- {formatDate(user.created_at)} -

-
-
-

- Последний вход -

-

- {formatDateTime(user.last_login)} -

-
-
-
-
+ -
- 3 ? ( - - ) : undefined - } - /> - {sortedActions.length ? ( -
- {!showAllActions && sortedActions.length > 3 ? ( -

- Последние {visibleActions.length} из {sortedActions.length} -

- ) : null} - {visibleActions.map((action) => { - const tone = getEmployeeActionTone(action.action); - const isCurrentAction = action.id === currentActionId; - return ( -
- - {isCurrentAction ? ( - - ) : null} -
-
-
- - {action.action_display || action.action} - - {isCurrentAction ? ( - - текущий - - ) : null} - {action.comment ? ( -

- {truncateText( - action.comment, - showAllActions ? 240 : 120, - )} -

- ) : null} -
-

- {formatDateTime(action.date || action.created_at)} -

-
-
-
- ); - })} -
- ) : ( -

Событий нет

- )} -
+
); diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index cd4528f0..928b65a5 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -5,86 +5,40 @@ import Image from "next/image"; import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { - Building2, - CalendarDays, - Check, - Copy, Mail, MessageCircle, Pencil, Phone, - Plus, } from "lucide-react"; import { AppShell } from "../../../components/AppShell"; import EditUserProfileModal from "@/components/users/EditUserProfileModal"; import EmployeeActionModal from "@/components/users/EmployeeActionModal"; import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline"; +import { + ProfileContactsPanel, + ProfileDepartmentBadge, + ProfileHeroCard, + ProfileInfoCard, + ProfileSkillsCard, + type ProfileContactRow, + type ProfileInfoItem, +} from "@/components/users/ProfileSections"; import { useUser } from "@/contexts/UserContext"; import { useUserDetailPage } from "@/hooks/useUserDetailPage"; import { apiClient } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/url"; import { - formatBirthday, + formatBirthdayWithYear, + formatProfileDate, + formatProfileDateTime, formatPhoneForLink, getEmployeeActionTone, getWorkDuration, + normalizeTelegramLink, + normalizeWhatsAppLink, } from "@/lib/users/userDetailUtils"; -function formatDate(value?: string): string { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleDateString("ru-RU", { - day: "2-digit", - month: "2-digit", - year: "numeric", - }); -} - -function formatDateTime(value?: string): string { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString("ru-RU", { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -function normalizeTelegram(value: string) { - const trimmed = value.trim(); - if (!trimmed) return ""; - if (/^https?:\/\//i.test(trimmed)) return trimmed; - return `https://t.me/${trimmed.replace(/^@/, "")}`; -} - -function normalizeWhatsApp(value: string) { - const trimmed = value.trim(); - if (!trimmed) return ""; - if (/^https?:\/\//i.test(trimmed)) return trimmed; - const digits = trimmed.replace(/[^\d]/g, ""); - return digits ? `https://wa.me/${digits}` : ""; -} - -function SectionTitle({ - title, - action, -}: { - title: string; - action?: React.ReactNode; -}) { - return ( -
-

{title}

- {action} -
- ); -} - export default function UserDetailPage() { const params = useParams<{ id: string }>(); const router = useRouter(); @@ -185,65 +139,92 @@ export default function UserDetailPage() { }; }, []); - const contactRows = useMemo( - () => - person - ? [ - person.email - ? { - key: "email", - label: "Почта", - value: person.email, - href: `mailto:${person.email}`, - icon: , - } - : null, - person.phone_number - ? { - key: "phone", - label: "Телефон", - value: person.phone_number, - href: `tel:${formatPhoneForLink(person.phone_number)}`, - icon: , - } - : null, - person.telegram - ? { - key: "telegram", - label: "Telegram", - value: person.telegram, - href: normalizeTelegram(person.telegram), - icon: , - } - : null, - person.whatsapp - ? { - key: "whatsapp", - label: "WhatsApp", - value: person.whatsapp, - href: normalizeWhatsApp(person.whatsapp), - icon: , - } - : null, - person.wechat - ? { - key: "wechat", - label: "WeChat", - value: person.wechat, - href: null, - icon: , - } - : null, - ].filter(Boolean) as Array<{ - key: string; - label: string; - value: string; - href: string | null; - icon: React.ReactNode; - }> - : [], - [person], - ); + const contactRows = useMemo(() => { + if (!person) return []; + + const rows: ProfileContactRow[] = []; + + const email = person.email; + if (email) { + rows.push({ + key: "email", + label: "Почта", + value: email, + icon: , + onClick: () => void handleCopyToClipboard(email, "email"), + copied: copySuccess === "email", + }); + } + + const phone = person.phone_number; + if (phone) { + rows.push({ + key: "phone", + label: "Телефон", + value: phone, + icon: , + onClick: () => void handleCopyToClipboard(phone, "phone"), + copied: copySuccess === "phone", + }); + } + + const telegram = person.telegram; + if (telegram) { + rows.push({ + key: "telegram", + label: "Telegram", + value: telegram, + icon: , + onClick: () => void handleCopyToClipboard(normalizeTelegramLink(telegram), "telegram"), + copied: copySuccess === "telegram", + }); + } + + const whatsapp = person.whatsapp; + if (whatsapp) { + rows.push({ + key: "whatsapp", + label: "WhatsApp", + value: whatsapp, + icon: , + onClick: () => void handleCopyToClipboard(normalizeWhatsAppLink(whatsapp), "whatsapp"), + copied: copySuccess === "whatsapp", + }); + } + + const wechat = person.wechat; + if (wechat) { + rows.push({ + key: "wechat", + label: "WeChat", + value: wechat, + icon: , + onClick: () => void handleCopyToClipboard(wechat, "wechat"), + copied: copySuccess === "wechat", + }); + } + + return rows; + }, [copySuccess, handleCopyToClipboard, person]); + + const infoItems = useMemo(() => ([ + { + label: "В компании", + value: getWorkDuration(person?.date_joined) || "—", + }, + { + label: "Дата найма", + value: formatProfileDate(person?.date_joined), + }, + { + label: "Профиль создан", + value: formatProfileDate(person?.created_at), + }, + { + label: "Последний вход", + value: formatProfileDateTime(person?.last_login), + }, + ]), [person?.created_at, person?.date_joined, person?.last_login]); const handleAddSkill = async (forcedSkill?: { id: number; name: string }) => { if (!person || skillsSaving) return; @@ -326,271 +307,111 @@ export default function UserDetailPage() { ) : person ? ( <> -
-
-

Профиль сотрудника

- {latestAction ? ( + {latestAction.action_display || latestAction.action} - ) : null} -
- -
-
-
- {avatarUrl && !avatarFailed ? ( - {fullName} setAvatarFailed(true)} - unoptimized - /> - ) : ( -
- {initials} -
- )} -
- -
-
-
-

- {fullName} -

-
- - {person.position?.name || "Должность не указана"} - - {primaryDepartment ? ( - - {primaryDepartment.name} - - ) : null} -
-
- -
- {canEdit ? ( - - ) : null} -
-
- - {currentUser && currentUser.id !== person.id ? ( -
- - {person.phone_number ? ( - - - Позвонить - - ) : null} -
- ) : null} -
-
- -
-
- {contactRows.length ? ( - contactRows.map((contact, index) => ( -
- {index > 0 ? ( -
- ) : null} -
- - {contact.icon} - -
-

- {contact.label} -

- {contact.href ? ( - - {contact.value} - - ) : ( -

- {contact.value} -

- )} -
- -
-
- )) - ) : ( -

- Контакты не указаны -

- )} -
-
-
-
- -
- -
-
-
- setSkillName(event.target.value)} - placeholder="Добавить навык" - className="app-input w-full rounded-xl px-4 py-3 text-sm" - disabled={skillsSaving} + ) : null + } + avatar={ +
+ {avatarUrl && !avatarFailed ? ( + {fullName} setAvatarFailed(true)} + unoptimized /> - - {availableSkills.map((skill) => ( - -
- -
- - {skillsError ? ( -

{skillsError}

- ) : null} - -
- {personSkills.length ? ( -
- {personSkills.map((skill) => ( - - {skill.name} - - ))} -
) : ( -

Навыки не указаны

+
+ {initials} +
)}
+ } + fullName={fullName} + secondaryLine={formatBirthdayWithYear(person.birth_date) || "Не указана"} + roleText={person.position?.name || "Должность не указана"} + departmentBadge={ + primaryDepartment ? ( + + ) : undefined + } + headerActions={ + canEdit ? ( + + ) : undefined + } + actionRow={ + currentUser && currentUser.id !== person.id ? ( + <> + + {person.phone_number ? ( + + + Позвонить + + ) : null} + + ) : undefined + } + bottomPanel={ + + } + /> - {skillsLoading ? ( -

- Загружаем навыки... -

- ) : null} -
-
+ void handleAddSkill()} + submitDisabled={!skillName.trim() || skillsSaving} + inputDisabled={skillsSaving} + availableSkills={availableSkills} + skills={personSkills} + error={skillsError} + loading={skillsLoading} + emptyText="Навыки не указаны" + /> -
- -
-
-
-

- В компании -

-

- {getWorkDuration(person.date_joined) || "—"} -

-
-
-

- Дата найма -

-

- {formatDate(person.date_joined)} -

-
-
-
-
-
-

- День рождения -

-

- {formatBirthday(person.birth_date) || "—"} -

-
-
-

- Последний вход -

-

- {formatDateTime(person.last_login)} -

-
-
-
-
+ {person.departments?.length ? (
- +
+

Отделы

+
{person.departments.map((department) => ( @@ -632,6 +453,8 @@ export default function UserDetailPage() { onDeleteAction={handleDeleteAction} onEditAction={handleEditAction} sortedActions={sortedActions} + truncateCommentLength={120} + expandedCommentLength={240} /> ) : null} diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx index d8a733d8..158ec355 100644 --- a/frontend/src/components/NotificationCenter.tsx +++ b/frontend/src/components/NotificationCenter.tsx @@ -6,6 +6,7 @@ import { useNotifications } from '@/hooks/useApi'; import { formatDistanceToNow } from 'date-fns'; import { ru } from 'date-fns/locale/ru'; import { getVerbName } from '@/lib/verbTranslations'; +import { resolveNotificationActionUrl } from '@/lib/notifications/actionUrl'; import Link from 'next/link'; import type { NotificationItem } from '@/contexts/NotificationsContext'; @@ -75,8 +76,9 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen } // Навигация если есть action_url - if (notification.action_url) { - window.location.assign(notification.action_url); + const actionUrl = resolveNotificationActionUrl(notification); + if (actionUrl) { + window.location.assign(actionUrl); } }; @@ -232,8 +234,9 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) { if (!notification.is_read) { await markAsRead(notification.id); } - if (notification.action_url) { - window.location.assign(notification.action_url); + const actionUrl = resolveNotificationActionUrl(notification); + if (actionUrl) { + window.location.assign(actionUrl); } }; diff --git a/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx b/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx new file mode 100644 index 00000000..ae5dfcd3 --- /dev/null +++ b/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { Check, CircleDot, X } from "lucide-react"; + +import { formatDate, formatMoney } from "@/lib/shared"; +import type { ProcurementRequest, User } from "@/types/api"; + +interface ProcurementRequestDetailContentProps { + request: ProcurementRequest; + displayUserName: ( + person?: User | number | null, + fallbackName?: string | null, + fallbackEmail?: string | null, + ) => string; + footer?: React.ReactNode; +} + +const approvalIconByStatus = (status?: string) => { + const normalized = String(status || "").toLowerCase(); + if (normalized === "approved") { + return ; + } + if (normalized === "rejected") { + return ; + } + return ; +}; + +export function ProcurementRequestDetailContent({ + request, + displayUserName, + footer, +}: ProcurementRequestDetailContentProps) { + return ( +
+
+

Описание

+

+ {request.description || "—"} +

+
+
+

Отправлена

+

{formatDate(request.submitted_at) || "—"}

+
+
+

Взята в работу

+

{formatDate(request.started_at) || "—"}

+
+
+

Завершена

+

{formatDate(request.completed_at) || "—"}

+
+
+ {request.actual_cost ? ( +
+ Фактическая сумма: {formatMoney(request.actual_cost)} +
+ ) : null} +
+ + {request.items && request.items.length > 0 ? ( +
+

Позиции

+
+ {request.items.map((item, index) => ( +
+
+
+

{item.name}

+ {item.description ? ( +

{item.description}

+ ) : null} + {item.supplier_info ? ( +

+ Поставщик: {item.supplier_info} +

+ ) : null} +
+
+

{formatMoney(item.total_price)}

+

+ {item.quantity} {item.unit} +

+
+
+
+ Цена/ед.: {formatMoney(item.estimated_unit_price)} +
+
+ ))} +
+
+ ) : null} + + {request.approvals && request.approvals.length > 0 ? ( +
+

Согласования

+
+ {request.approvals.map((approval) => ( +
+ {approvalIconByStatus(approval.status)} + + {displayUserName(approval.approver, approval.approver_name)} + + ({approval.step_label || `Этап ${approval.priority}`}) + {approval.comment ? ( + + «{approval.comment}» + + ) : null} +
+ ))} +
+
+ ) : null} + + {footer} +
+ ); +} diff --git a/frontend/src/components/users/EmployeeActionsTimeline.tsx b/frontend/src/components/users/EmployeeActionsTimeline.tsx index 555a2ef5..174bc52f 100644 --- a/frontend/src/components/users/EmployeeActionsTimeline.tsx +++ b/frontend/src/components/users/EmployeeActionsTimeline.tsx @@ -1,8 +1,10 @@ "use client"; -import { History, Pencil, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Pencil, Plus, Trash2 } from "lucide-react"; import { getEmployeeActionTone, + truncateText, } from "@/lib/users/userDetailUtils"; import type { EmployeeAction } from "@/types/api"; @@ -11,10 +13,14 @@ type EmployeeActionsTimelineProps = { canManageActions: boolean; canViewActions: boolean; latestActionId: number | null; - onAddAction: () => void; - onDeleteAction: (actionId: number) => void; - onEditAction: (action: EmployeeAction) => void; + onAddAction?: () => void; + onDeleteAction?: (actionId: number) => void; + onEditAction?: (action: EmployeeAction) => void; sortedActions: EmployeeAction[]; + initialVisibleCount?: number; + showCountLabel?: boolean; + truncateCommentLength?: number; + expandedCommentLength?: number; }; function formatActionDate(value: string) { @@ -36,43 +42,73 @@ export default function EmployeeActionsTimeline({ onDeleteAction, onEditAction, sortedActions, + initialVisibleCount, + showCountLabel = true, + truncateCommentLength, + expandedCommentLength = truncateCommentLength, }: EmployeeActionsTimelineProps) { + const [showAll, setShowAll] = useState(false); + if (!canViewActions || sortedActions.length === 0) { return null; } + const canCollapse = Boolean(initialVisibleCount && sortedActions.length > initialVisibleCount); + const visibleActions = useMemo( + () => (canCollapse && !showAll ? sortedActions.slice(0, initialVisibleCount) : sortedActions), + [canCollapse, initialVisibleCount, showAll, sortedActions], + ); + return (
+

Кадровые события

- -

Кадровые события

-
-
- - {sortedActions.length}{" "} - {sortedActions.length === 1 - ? "событие" - : sortedActions.length < 5 - ? "события" - : "событий"} - - {canManageActions ? ( + {showCountLabel ? ( + + {sortedActions.length}{" "} + {sortedActions.length === 1 + ? "событие" + : sortedActions.length < 5 + ? "события" + : "событий"} + + ) : null} + {canCollapse ? ( + ) : null} + {canManageActions && onAddAction ? ( + ) : null}
+ {!showAll && canCollapse ? ( +

+ Последние {visibleActions.length} из {sortedActions.length} +

+ ) : null} +
- {sortedActions.map((action) => { + {visibleActions.map((action) => { const isCurrent = latestActionId === action.id; const deleteKey = `delete-${action.id}`; const tone = getEmployeeActionTone(action.action); + const commentText = truncateCommentLength + ? truncateText(action.comment, showAll ? expandedCommentLength : truncateCommentLength) + : action.comment; return (
@@ -101,9 +137,9 @@ export default function EmployeeActionsTimeline({ ) : null}
- {action.comment ? ( + {commentText ? (

- {action.comment} + {commentText}

) : null}
@@ -111,7 +147,7 @@ export default function EmployeeActionsTimeline({ - {canManageActions ? ( + {canManageActions && onEditAction && onDeleteAction ? (
+ ) : ( +
+
+

+ {row.label} +

+ {row.copied ? ( + Скопировано + ) : null} +
+

+ {row.value} +

+
+ )} + {row.action ?
{row.action}
: null} +
+
+ )) + ) : ( +

{emptyText}

+ )} +
+ ); +} + +export function ProfileInfoCard({ + title = "Информация", + items, +}: { + title?: string; + items: ProfileInfoItem[]; +}) { + return ( +
+ +
+
+ {items.map((item, index) => ( +
= 2 ? "border-t border-[var(--border-subtle)]" : "", + index >= 2 && index % 2 === 1 ? "md:border-l" : "", + ].join(" ").trim()} + > +

+ {item.label} +

+

{item.value}

+
+ ))} +
+
+
+ ); +} + +export function ProfileSkillsCard({ + title = "Навыки", + inputValue, + onInputChange, + onSubmit, + submitDisabled, + inputDisabled, + availableSkills, + skills, + error, + loading, + onRemoveSkill, + removeDisabled, + emptyText = "Навыки не указаны", +}: { + title?: string; + inputValue: string; + onInputChange: (value: string) => void; + onSubmit: () => void; + submitDisabled: boolean; + inputDisabled?: boolean; + availableSkills: Skill[]; + skills: Skill[]; + error?: string | null; + loading?: boolean; + onRemoveSkill?: (skillId: number) => void; + removeDisabled?: boolean; + emptyText?: string; +}) { + const listId = `${title.toLowerCase().replace(/\s+/g, "-")}-skills-list`; + + return ( +
+ +
+
+
+ onInputChange(event.target.value)} + placeholder="Добавить навык" + className="app-input w-full rounded-xl px-4 py-2.5 text-sm" + disabled={inputDisabled} + /> + + {availableSkills.map((skill) => ( + +
+ +
+ + {error ?

{error}

: null} + +
+ {skills.length ? ( +
+ {skills.map((skill) => + onRemoveSkill ? ( + + ) : ( + + {skill.name} + + ), + )} +
+ ) : ( +

{emptyText}

+ )} +
+ + {loading ? ( +

Загружаем навыки...

+ ) : null} +
+
+ ); +} + +export function ProfileDepartmentBadge({ + label, + href, +}: { + label: string; + href?: string; +}) { + if (href) { + return ( + + {label} + + ); + } + + return ( + + {label} + + ); +} diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts index 10862124..37a063ae 100644 --- a/frontend/src/hooks/useProcurementPage.ts +++ b/frontend/src/hooks/useProcurementPage.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { apiClient } from "@/lib/api"; import { canManageRequests, canManageSupplier } from "@/lib/permissions"; import { displayUserName, extractNextPage, loadAllPages } from "@/lib/shared"; @@ -156,6 +156,11 @@ export function useProcurementPage(user: User | null) { const [expandedIds, setExpandedIds] = useState>(new Set()); const [detailsCache, setDetailsCache] = useState>({}); + const detailsCacheRef = useRef>({}); + + useEffect(() => { + detailsCacheRef.current = detailsCache; + }, [detailsCache]); const resolveUserId = useCallback((person?: User | number | null) => { if (!person) return null; @@ -381,6 +386,21 @@ export function useProcurementPage(user: User | null) { } }, [loadPage1]); + const ensureRequestDetail = useCallback(async (id: number) => { + const cached = detailsCacheRef.current[id]; + if (cached) { + return cached; + } + + const detail = await apiClient.getProcurementRequest(id); + detailsCacheRef.current = { ...detailsCacheRef.current, [id]: detail }; + setDetailsCache((previous) => ({ ...previous, [id]: detail })); + setRequests((previous) => previous.map((request) => ( + request.id === id ? { ...request, ...detail } : request + ))); + return detail; + }, []); + const handleSave = useCallback(async () => { try { setBusyKey("save"); @@ -501,15 +521,14 @@ export function useProcurementPage(user: User | null) { return next; }); - if (!detailsCache[id]) { + if (!detailsCacheRef.current[id]) { try { - const detail = await apiClient.getProcurementRequest(id); - setDetailsCache((previous) => ({ ...previous, [id]: detail })); + await ensureRequestDetail(id); } catch { // ignore detail loading failures for collapsed rows } } - }, [detailsCache]); + }, [ensureRequestDetail]); const handleSubmit = useCallback((id: number) => doAction( `submit-${id}`, @@ -557,6 +576,12 @@ export function useProcurementPage(user: User | null) { setBusyKey(`delete-${id}`); await apiClient.deleteProcurementRequest(id); setRequests((previous) => previous.filter((request) => request.id !== id)); + setDetailsCache((previous) => { + const next = { ...previous }; + delete next[id]; + detailsCacheRef.current = next; + return next; + }); } catch (deleteError) { setActionError(getReadableError(deleteError, "Не удалось удалить")); } finally { @@ -646,5 +671,7 @@ export function useProcurementPage(user: User | null) { getDeptName, getRequestAmount, loadPage1, + refreshOne, + ensureRequestDetail, }; } diff --git a/frontend/src/lib/notifications/actionUrl.ts b/frontend/src/lib/notifications/actionUrl.ts new file mode 100644 index 00000000..91de1cf9 --- /dev/null +++ b/frontend/src/lib/notifications/actionUrl.ts @@ -0,0 +1,24 @@ +type NotificationNavigationLike = { + action_url?: string | null; + verb?: string | null; + category?: string | null; + data?: Record | null; +}; + +export function resolveNotificationActionUrl( + notification: NotificationNavigationLike, +): string | null { + const rawRequestId = notification.data?.request_id; + const requestId = typeof rawRequestId === "number" + ? rawRequestId + : typeof rawRequestId === "string" && rawRequestId.trim() + ? Number(rawRequestId) + : null; + + const categoryHint = `${notification.verb || ""} ${notification.category || ""}`.toLowerCase(); + if (requestId && Number.isFinite(requestId) && categoryHint.includes("procurement")) { + return `/procurement?request=${requestId}`; + } + + return notification.action_url || null; +} diff --git a/frontend/src/lib/users/userDetailUtils.ts b/frontend/src/lib/users/userDetailUtils.ts index dce145c7..2ae09272 100644 --- a/frontend/src/lib/users/userDetailUtils.ts +++ b/frontend/src/lib/users/userDetailUtils.ts @@ -122,6 +122,62 @@ export function formatBirthday(birthDate?: string): string | null { return new Date(birthDate).toLocaleDateString("ru-RU", { day: "numeric", month: "long" }); } +export function formatBirthdayWithYear(birthDate?: string): string | null { + if (!birthDate) return null; + const date = new Date(birthDate); + if (Number.isNaN(date.getTime())) return birthDate; + return date.toLocaleDateString("ru-RU", { + day: "numeric", + month: "long", + year: "numeric", + }); +} + +export function formatProfileDate(value?: string): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +export function formatProfileDateTime(value?: string): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function normalizeTelegramLink(value: string) { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://t.me/${trimmed.replace(/^@/, "")}`; +} + +export function normalizeWhatsAppLink(value: string) { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + const digits = trimmed.replace(/[^\d]/g, ""); + return digits ? `https://wa.me/${digits}` : ""; +} + +export function truncateText(value?: string, maxLength = 120) { + if (!value) return ""; + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength).trimEnd()}…`; +} + export function getEmployeeActionTone(actionType?: string): { badgeClass: string; lineColor: string;