refactor(frontend): align profile, notifications and procurement flows

This commit is contained in:
lizerk 2026-04-12 18:02:47 +03:00
parent e3ba6ea925
commit ae7e1daf89
16 changed files with 1424 additions and 1023 deletions

View File

@ -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}'

View File

@ -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,

View File

@ -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-ветки
## Таймлайны

View File

@ -141,8 +141,8 @@ export default function EmployeesPage() {
if (error) {
return (
<AppShell>
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
<div className="app-feedback-danger rounded-2xl p-6 text-center">
<p className="text-sm">{error}</p>
</div>
</AppShell>
);
@ -185,11 +185,11 @@ export default function EmployeesPage() {
<button
onClick={(e) => handleStartChat(employee, e)}
disabled={creatingChatFor === employee.id}
className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-100 text-sky-600 transition hover:bg-sky-200 disabled:opacity-50"
className="app-icon-button flex h-10 w-10 items-center justify-center rounded-full disabled:opacity-50"
title="Написать сообщение"
>
{creatingChatFor === employee.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-sky-600 border-t-transparent" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--accent-primary)] border-t-transparent" />
) : (
<MessageCircle size={18} />
)}

View File

@ -7,6 +7,7 @@ import { useState, useMemo, useEffect } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale/ru';
import { getVerbCategory, getVerbName } from '@/lib/verbTranslations';
import { resolveNotificationActionUrl } from '@/lib/notifications/actionUrl';
export default function NotificationsPage() {
// Локальное состояние для ВСЕХ уведомлений (не только непрочитанных)
@ -140,8 +141,9 @@ export default function NotificationsPage() {
if (!notification.is_read) {
await markAsRead(notification.id);
}
if (notification.action_url) {
window.location.href = notification.action_url;
const actionUrl = resolveNotificationActionUrl(notification);
if (actionUrl) {
window.location.href = actionUrl;
}
};

View File

@ -661,7 +661,11 @@ function HomePageContent() {
type="button"
disabled={likeBusyId === post.id}
onClick={() => handleLikeToggle(post)}
className={`flex items-center gap-2 rounded-lg px-3 py-2 transition ${post.is_liked ? "app-selected app-accent-text" : "app-action-ghost"}`}
className={`flex items-center gap-2 rounded-lg px-3 py-2 transition ${
post.is_liked
? "app-action-ghost text-[var(--accent-primary)]"
: "app-action-ghost"
}`}
>
<Heart
size={16}

View File

@ -1,16 +1,18 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { useUser } from "@/contexts/UserContext";
import Link from "next/link";
import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent";
import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel";
import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel";
import type { UrgencyLevel } from "@/types/api";
import type { ProcurementRequest, UrgencyLevel } from "@/types/api";
import {
ArrowUpDown,
Check,
ChevronDown,
CircleDot,
ClipboardCheck,
Filter,
Loader2,
@ -83,6 +85,156 @@ const periodOptions = [
const fmt = formatDate;
const money = formatMoney;
const getReadableError = (error: unknown, fallback: string): string => {
const raw = String((error as Error)?.message || fallback);
const jsonStart = raw.indexOf("{");
const payload = jsonStart >= 0 ? raw.slice(jsonStart) : raw;
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
if (typeof parsed.error === "string" && parsed.error.trim()) {
return parsed.error;
}
if (typeof parsed.detail === "string" && parsed.detail.trim()) {
return parsed.detail;
}
} catch {
return raw;
}
return raw;
};
function ProcurementRequestActionButtons({
request,
busyKey,
canManage,
isAuthor,
isExecutor,
isFinal,
onSubmit,
onEdit,
onDelete,
onApprove,
onReject,
onStart,
onComplete,
onCancel,
}: {
request: ProcurementRequest;
busyKey: string | null;
canManage: boolean;
isAuthor: boolean;
isExecutor: boolean;
isFinal: (status?: string) => boolean;
onSubmit: (id: number) => void | Promise<void>;
onEdit: (request: ProcurementRequest) => void;
onDelete: (id: number) => void | Promise<void>;
onApprove: (id: number) => void | Promise<void>;
onReject: (id: number) => void | Promise<void>;
onStart: (id: number) => void | Promise<void>;
onComplete: (id: number) => void | Promise<void>;
onCancel: (id: number) => void | Promise<void>;
}) {
const status = String(request.status || "").toLowerCase();
const isDraft = status === "draft";
const isPending = status === "pending";
const isApproved = status === "approved";
const isInProgress = status === "in_progress";
return (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{isDraft && isAuthor ? (
<button
type="button"
onClick={() => onSubmit(request.id)}
disabled={busyKey === `submit-${request.id}`}
title="На согласование"
className="app-action-primary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60"
>
<Send size={14} />
</button>
) : null}
{isDraft && isAuthor ? (
<button
type="button"
onClick={() => onEdit(request)}
title="Редактировать"
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg"
>
<Pencil size={14} />
</button>
) : null}
{isDraft && (isAuthor || canManage) ? (
<button
type="button"
onClick={() => onDelete(request.id)}
disabled={busyKey === `delete-${request.id}`}
title="Удалить"
className="app-action-danger inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60"
>
<Trash2 size={14} />
</button>
) : null}
{isPending && !isAuthor ? (
<>
<button
type="button"
onClick={() => onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
title="Одобрить"
className="app-feedback-success inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60"
>
<ThumbsUp size={14} />
</button>
<button
type="button"
onClick={() => onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
title="Отклонить"
className="app-action-danger inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60"
>
<ThumbsDown size={14} />
</button>
</>
) : null}
{isApproved && !request.executor ? (
<button
type="button"
onClick={() => onStart(request.id)}
disabled={busyKey === `start-${request.id}`}
title="Взять в работу"
className="app-action-primary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60"
>
<Play size={14} />
</button>
) : null}
{isInProgress && isExecutor ? (
<button
type="button"
onClick={() => onComplete(request.id)}
disabled={busyKey === `complete-${request.id}`}
title="Завершить"
className="app-action-primary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60"
>
<ClipboardCheck size={14} />
</button>
) : null}
{isAuthor && !isFinal(status) && status !== "draft" ? (
<button
type="button"
onClick={() => onCancel(request.id)}
disabled={busyKey === `cancel-${request.id}`}
title="Отменить"
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60"
>
<XCircle size={14} />
</button>
) : null}
</div>
);
}
/* ══════════════════════════════════════════════════════
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<number | null>(null);
const [detailModalLoading, setDetailModalLoading] = useState(false);
const [detailModalError, setDetailModalError] = useState<string | null>(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() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${st === "completed" ? "bg-teal-500" : st === "approved" ? "bg-emerald-500" : st === "pending" ? "bg-amber-500" : st === "in_progress" ? "bg-sky-500" : st === "rejected" ? "bg-rose-500" : "bg-slate-400"}`} />
<h3 className="truncate text-sm font-semibold text-[var(--foreground)]">{req.title || "Без названия"}</h3>
<button
type="button"
onClick={() => void openDetailModal(req.id)}
className="truncate text-left text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]"
title="Открыть карточку заявки"
>
{req.title || "Без названия"}
</button>
</div>
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="font-medium text-[var(--foreground)]">{getDeptName(req)}</span>
@ -390,132 +615,28 @@ export default function ProcurementPage() {
{expanded && (
<div className="mt-4 space-y-3 px-4 pb-4">
<div className="app-surface rounded-xl p-4">
<p className="text-sm font-semibold text-[var(--foreground)]">Описание</p>
<p className="app-text-wrap mt-2 whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">{resolvedDetail.description || "—"}</p>
<div className="mt-3 grid gap-2 text-xs sm:grid-cols-3">
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-text-muted text-[11px] uppercase tracking-wide">Отправлена</p>
<p className="mt-1 font-medium text-[var(--foreground)]">{fmt(resolvedDetail.submitted_at) || "—"}</p>
</div>
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-text-muted text-[11px] uppercase tracking-wide">Взята в работу</p>
<p className="mt-1 font-medium text-[var(--foreground)]">{fmt(resolvedDetail.started_at) || "—"}</p>
</div>
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-text-muted text-[11px] uppercase tracking-wide">Завершена</p>
<p className="mt-1 font-medium text-[var(--foreground)]">{fmt(resolvedDetail.completed_at) || "—"}</p>
</div>
</div>
{resolvedDetail.actual_cost ? (
<div className="mt-3 inline-flex rounded-full app-badge-accent px-2.5 py-1 text-xs font-medium">
Фактическая сумма: {money(resolvedDetail.actual_cost)}
</div>
) : null}
</div>
{detail?.items && detail.items.length > 0 && (
<div className="app-surface rounded-xl p-4">
<p className="mb-3 text-sm font-semibold text-[var(--foreground)]">Позиции</p>
<div className="space-y-2">
{detail.items.map((it, idx) => (
<div key={idx} className="app-surface-muted rounded-lg px-3 py-3 text-xs">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="app-text-wrap font-medium text-[var(--foreground)]">{it.name}</p>
{it.description && <p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">{it.description}</p>}
{it.supplier_info && <p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">Поставщик: {it.supplier_info}</p>}
</div>
<div className="text-right">
<p className="font-medium text-[var(--foreground)]">{money(it.total_price)}</p>
<p className="app-text-muted mt-1">{it.quantity} {it.unit}</p>
</div>
</div>
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span>Цена/ед.: {money(it.estimated_unit_price)}</span>
</div>
</div>
))}
</div>
</div>
)}
{detail?.approvals && detail.approvals.length > 0 && (
<div className="app-surface rounded-xl p-4">
<p className="mb-3 text-sm font-semibold text-[var(--foreground)]">Согласования</p>
<div className="space-y-2">
{detail.approvals.map((a) => {
const aSt = String(a.status).toLowerCase();
const icon = aSt === "approved" ? <Check size={13} className="text-emerald-500" /> : aSt === "rejected" ? <X size={13} className="text-rose-500" /> : <CircleDot size={13} className="text-amber-500" />;
return (
<div key={a.id} className="app-surface-muted flex items-center gap-2 rounded-lg px-3 py-2 text-xs">
{icon}
<span className="font-medium text-[var(--foreground)]">{displayUserName(a.approver, a.approver_name)}</span>
<span className="app-text-muted">({a.step_label || `Этап ${a.priority}`})</span>
{a.comment && <span className="app-text-wrap app-text-muted ml-auto max-w-full italic">«{a.comment}»</span>}
</div>
);
})}
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{isDraft && isAuthor && (
<button type="button" onClick={() => handleSubmit(req.id)} disabled={busyKey === `submit-${req.id}`}
title="На согласование"
className="app-action-primary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
<Send size={14} />
</button>
<ProcurementRequestDetailContent
request={resolvedDetail}
displayUserName={displayUserName}
footer={(
<ProcurementRequestActionButtons
request={req}
busyKey={busyKey}
canManage={canManage}
isAuthor={isAuthor}
isExecutor={isExecutor}
isFinal={isFinal}
onSubmit={handleSubmit}
onEdit={openEdit}
onDelete={handleDelete}
onApprove={handleApprove}
onReject={handleReject}
onStart={handleStart}
onComplete={handleComplete}
onCancel={handleCancel}
/>
)}
{isDraft && isAuthor && (
<button type="button" onClick={() => openEdit(req)} title="Редактировать" className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg">
<Pencil size={14} />
</button>
)}
{isDraft && (isAuthor || canManage) && (
<button type="button" onClick={() => handleDelete(req.id)} disabled={busyKey === `delete-${req.id}`}
title="Удалить"
className="app-action-danger inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
<Trash2 size={14} />
</button>
)}
{isPending && !isAuthor && (
<>
<button type="button" onClick={() => handleApprove(req.id)} disabled={busyKey === `approve-${req.id}`}
title="Одобрить"
className="app-feedback-success inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
<ThumbsUp size={14} />
</button>
<button type="button" onClick={() => handleReject(req.id)} disabled={busyKey === `reject-${req.id}`}
title="Отклонить"
className="app-action-danger inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
<ThumbsDown size={14} />
</button>
</>
)}
{isApproved && !req.executor && (
<button type="button" onClick={() => handleStart(req.id)} disabled={busyKey === `start-${req.id}`}
title="Взять в работу"
className="app-action-primary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
<Play size={14} />
</button>
)}
{isInProgress && isExecutor && (
<button type="button" onClick={() => handleComplete(req.id)} disabled={busyKey === `complete-${req.id}`}
title="Завершить"
className="app-action-primary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
<ClipboardCheck size={14} />
</button>
)}
{isAuthor && !isFinal(st) && st !== "draft" && (
<button type="button" onClick={() => handleCancel(req.id)} disabled={busyKey === `cancel-${req.id}`}
title="Отменить"
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-60">
<XCircle size={14} />
</button>
)}
</div>
/>
</div>
)}
</article>
@ -536,6 +657,96 @@ export default function ProcurementPage() {
</section>
)}
<Modal
isOpen={detailModalId !== null}
onClose={closeDetailModal}
title={selectedRequest?.title || "Карточка заявки"}
size="lg"
closeOnClickOutside
>
{detailModalLoading ? (
<div className="py-12 text-center">
<Loader2 size={24} className="mx-auto mb-3 animate-spin text-sky-500" />
<p className="app-text-muted text-sm">Загружаем карточку заявки...</p>
</div>
) : detailModalError ? (
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
{detailModalError}
</div>
) : selectedRequest ? (
<div className="space-y-4 pb-1">
<div className="app-surface-muted rounded-xl px-4 py-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`app-status-pill ${statusMeta[String(selectedRequest.status || "").toLowerCase()]?.cls ?? defaultStatusMeta.cls}`}>
{statusMeta[String(selectedRequest.status || "").toLowerCase()]?.label ?? defaultStatusMeta.label}
</span>
<span className={`text-[11px] font-medium ${urgencyMeta[selectedRequest.urgency]?.cls ?? urgencyMeta.medium.cls}`}>
{(urgencyMeta[selectedRequest.urgency]?.label ?? urgencyMeta.medium.label)} срочность
</span>
</div>
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="font-medium text-[var(--foreground)]">{getDeptName(selectedRequest)}</span>
<span>{fmt(selectedRequest.created_at)}</span>
{getRequestAmount(selectedRequest) ? (
<span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(selectedRequest))}</span>
) : null}
</div>
<div className="app-text-muted mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs sm:grid-cols-2">
<div className="min-w-0">
<span>Заявитель:</span>{" "}
<span className="font-medium text-[var(--foreground)]">
{displayUserName(selectedRequest.requestor, selectedRequest.requestor_name, selectedRequest.requestor_email)}
</span>
</div>
<div className="min-w-0">
<span>Исполнитель:</span>{" "}
<span className="font-medium text-[var(--foreground)]">
{selectedRequest.executor || selectedRequest.executor_name
? displayUserName(selectedRequest.executor, selectedRequest.executor_name || undefined)
: "не назначен"}
</span>
</div>
</div>
</div>
</div>
{actionError ? (
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
{actionError}
</div>
) : null}
<ProcurementRequestDetailContent
request={selectedRequest}
displayUserName={displayUserName}
footer={(
<ProcurementRequestActionButtons
request={selectedRequest}
busyKey={busyKey}
canManage={canManage}
isAuthor={Boolean(resolveUserId(selectedRequest.requestor) && user?.id && resolveUserId(selectedRequest.requestor) === user.id)}
isExecutor={Boolean(resolveUserId(selectedRequest.executor) && user?.id && resolveUserId(selectedRequest.executor) === user.id)}
isFinal={isFinal}
onSubmit={handleSubmit}
onEdit={openEdit}
onDelete={handleDelete}
onApprove={handleApprove}
onReject={handleReject}
onStart={handleStart}
onComplete={handleComplete}
onCancel={handleCancel}
/>
)}
/>
</div>
) : (
<div className="app-surface-muted rounded-xl px-4 py-6 text-center">
<p className="app-text-muted text-sm">Заявка не найдена.</p>
</div>
)}
</Modal>
{/* ══════════ Create / Edit modal ══════════ */}
<Modal isOpen={isModalOpen} onClose={closeModal} title={modalMode === "create" ? "Новая заявка на закупку" : "Редактировать заявку"} size="lg" footer={
<div className="flex flex-wrap items-center justify-end gap-2">

View File

@ -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 (
<div className="mb-4 flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
{icon}
<h2 className="app-card-caption">{title}</h2>
</div>
{action}
</div>
);
}
export default function ProfilePage() {
const { user, loading } = useUser();
const [showAllActions, setShowAllActions] = useState(false);
const [copiedContactKey, setCopiedContactKey] = useState<string | null>(null);
const [directoryLogin, setDirectoryLogin] = useState<string | null>(null);
const [directoryLoginLoading, setDirectoryLoginLoading] = useState(false);
const [directoryLoginRefreshing, setDirectoryLoginRefreshing] =
@ -130,37 +63,17 @@ export default function ProfilePage() {
const [skillsError, setSkillsError] = useState<string | null>(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 (
<ProfileDepartmentBadge
label={label}
href={user.departments.length === 1 ? `/departments/${user.departments[0].id}` : undefined}
/>
);
}, [user?.departments]);
const contactRows = useMemo<ProfileContactRow[]>(() => {
if (!user) return [];
const rows: ProfileContactRow[] = [];
const email = user.email;
if (email) {
rows.push({
key: "email",
label: "Почта",
value: email,
icon: <Mail size={18} />,
onClick: () => void handleCopyContact(email, "email"),
copied: copiedContactKey === "email",
});
}
const phone = user.phone_number;
if (phone) {
rows.push({
key: "phone",
label: "Телефон",
value: phone,
icon: <Phone size={18} />,
onClick: () => void handleCopyContact(phone, "phone"),
copied: copiedContactKey === "phone",
});
}
const telegram = user.telegram;
if (telegram) {
rows.push({
key: "telegram",
label: "Telegram",
value: telegram,
icon: <MessageCircle size={18} />,
onClick: () => void handleCopyContact(normalizeTelegramLink(telegram), "telegram"),
copied: copiedContactKey === "telegram",
});
}
const whatsapp = user.whatsapp;
if (whatsapp) {
rows.push({
key: "whatsapp",
label: "WhatsApp",
value: whatsapp,
icon: <MessageCircle size={18} />,
onClick: () => void handleCopyContact(normalizeWhatsAppLink(whatsapp), "whatsapp"),
copied: copiedContactKey === "whatsapp",
});
}
const wechat = user.wechat;
if (wechat) {
rows.push({
key: "wechat",
label: "WeChat",
value: wechat,
icon: <MessageCircle size={18} />,
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: (
<RefreshCw
size={18}
className={directoryLoginRefreshing ? "animate-spin" : ""}
/>
),
onClick: directoryLogin && !directoryLoginLoading
? () => void handleCopyContact(directoryLogin, "directory-login")
: undefined,
copied: copiedContactKey === "directory-login",
action: (
<button
type="button"
onClick={() => void handleRefreshDirectoryLogin()}
disabled={directoryLoginRefreshing}
className="app-action-secondary shrink-0 rounded-xl px-3 py-2 text-sm font-medium disabled:opacity-50"
>
Обновить
</button>
),
});
}
return rows;
}, [
copiedContactKey,
directoryLogin,
directoryLoginLoading,
directoryLoginRefreshing,
directoryLoginError,
user,
]);
const infoItems = useMemo<ProfileInfoItem[]>(() => ([
{
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 (
<AppShell>
@ -349,387 +411,70 @@ export default function ProfilePage() {
return (
<AppShell>
<div className="mx-auto max-w-5xl space-y-4">
<section className="app-surface rounded-[28px] p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<p className="app-card-caption">Мой профиль</p>
{currentAction ? (
<ProfileHeroCard
caption="Мой профиль"
statusBadge={
currentAction ? (
<span
className={`app-status-pill ${getEmployeeActionTone(currentAction.action).badgeClass}`}
>
{currentAction.action_display || currentAction.action}
</span>
) : null}
</div>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-full app-avatar-frame">
{user.avatar ? (
<Image
src={resolveMediaUrl(user.avatar)}
alt={fullName}
width={80}
height={80}
className="h-full w-full object-cover"
unoptimized
/>
) : (
<div className="app-avatar-fallback flex h-full w-full items-center justify-center text-2xl font-semibold">
{initials(user.first_name, user.last_name)}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<h1 className="text-[2rem] font-semibold leading-tight text-[var(--foreground)]">
{fullName}
</h1>
<p className="app-text-muted mt-1.5 text-sm">
{formatBirthdayWithYear(user.birth_date) || "Не указана"}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="app-text-muted text-sm">
{user.position?.name || "Должность не указана"}
</span>
{!!user.departments?.length && (
<span className="app-pill inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
{user.departments.length === 1
? user.departments[0].name
: `${user.departments.length} отделов`}
</span>
)}
</div>
</div>
</div>
<div className="app-surface-muted rounded-2xl p-4">
<div>
<div className="flex items-start gap-3 px-4 py-4">
<span className="app-badge mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<Mail size={18} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--foreground)]">
Почта
</p>
{user.email ? (
<a
href={`mailto:${user.email.trim()}`}
className="app-text-muted mt-1 block truncate text-sm hover:text-[var(--accent-primary)]"
>
{user.email}
</a>
) : (
<p className="app-text-muted mt-1 text-sm">Не указана</p>
)}
</div>
</div>
<div className="mx-4 border-t border-[var(--border-subtle)]" />
<div className="flex items-start gap-3 px-4 py-4">
<span className="app-badge mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<Phone size={18} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--foreground)]">
Телефон
</p>
{user.phone_number ? (
<a
href={`tel:${user.phone_number.trim()}`}
className="app-text-muted mt-1 block truncate text-sm hover:text-[var(--accent-primary)]"
>
{user.phone_number}
</a>
) : (
<p className="app-text-muted mt-1 text-sm">Не указан</p>
)}
</div>
</div>
{!!user.telegram && (
<>
<div className="mx-4 border-t border-[var(--border-subtle)]" />
<div className="flex items-start gap-3 px-4 py-4">
<span className="app-badge mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<MessageCircle size={18} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--foreground)]">
Telegram
</p>
<a
href={normalizeTelegram(user.telegram)}
target="_blank"
rel="noreferrer"
className="app-text-muted mt-1 block truncate text-sm hover:text-[var(--accent-primary)]"
>
{user.telegram}
</a>
</div>
</div>
</>
)}
{!!user.whatsapp && (
<>
<div className="mx-4 border-t border-[var(--border-subtle)]" />
<div className="flex items-start gap-3 px-4 py-4">
<span className="app-badge mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<MessageCircle size={18} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--foreground)]">
WhatsApp
</p>
<a
href={normalizeWhatsApp(user.whatsapp)}
target="_blank"
rel="noreferrer"
className="app-text-muted mt-1 block truncate text-sm hover:text-[var(--accent-primary)]"
>
{user.whatsapp}
</a>
</div>
</div>
</>
)}
{!!user.wechat && (
<>
<div className="mx-4 border-t border-[var(--border-subtle)]" />
<div className="flex items-start gap-3 px-4 py-4">
<span className="app-badge mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<MessageCircle size={18} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--foreground)]">
WeChat
</p>
<p className="app-text-muted mt-1 truncate text-sm">
{user.wechat}
</p>
</div>
</div>
</>
)}
{(user.is_ldap_managed ||
directoryLoginLoading ||
directoryLoginError ||
directoryLogin) && (
<>
<div className="mx-4 border-t border-[var(--border-subtle)]" />
<div className="flex items-start gap-3 px-4 py-4">
<span className="app-badge mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<RefreshCw
size={18}
className={directoryLoginRefreshing ? "animate-spin" : ""}
/>
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
Логин в каталоге
</p>
<p className="app-text-muted mt-1 truncate text-sm">
{directoryLoginLoading
? "Загружаем..."
: directoryLogin || "Не найден"}
</p>
</div>
<button
type="button"
onClick={() => void handleRefreshDirectoryLogin()}
disabled={directoryLoginRefreshing}
className="app-action-secondary shrink-0 rounded-xl px-3 py-2 text-sm font-medium disabled:opacity-50"
>
Обновить
</button>
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
</section>
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title="Навыки" />
<div className="app-surface-muted rounded-2xl p-3.5">
<div className="flex flex-col gap-2.5 md:flex-row">
<div className="min-w-0 flex-1">
<input
list="profile-skills-list"
value={skillName}
onChange={(event) => setSkillName(event.target.value)}
placeholder="Добавить навык"
className="app-input w-full rounded-xl px-4 py-2.5 text-sm"
disabled={skillsSaving}
) : null
}
avatar={
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-full app-avatar-frame">
{user.avatar ? (
<Image
src={resolveMediaUrl(user.avatar)}
alt={fullName}
width={80}
height={80}
className="h-full w-full object-cover"
unoptimized
/>
<datalist id="profile-skills-list">
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.name} />
))}
</datalist>
</div>
<button
type="button"
onClick={() => void handleAddSkill()}
disabled={!skillName.trim() || skillsSaving}
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<Plus size={16} />
Добавить
</button>
</div>
{skillsError ? (
<p className="mt-3 text-sm text-red-400">{skillsError}</p>
) : null}
<div className="mt-3">
{profileSkills.length ? (
<div className="flex flex-wrap gap-2">
{profileSkills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => void handleRemoveSkill(skill.id)}
disabled={skillsSaving}
className="app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] disabled:opacity-50"
title="Удалить навык"
>
<span>{skill.name}</span>
<X size={14} />
</button>
))}
</div>
) : (
<p className="app-text-muted text-sm">Навыки пока не указаны</p>
<div className="app-avatar-fallback flex h-full w-full items-center justify-center text-2xl font-semibold">
{initials(user.first_name, user.last_name)}
</div>
)}
</div>
}
fullName={fullName}
secondaryLine={formatBirthdayWithYear(user.birth_date) || "Не указана"}
roleText={user.position?.name || "Должность не указана"}
departmentBadge={departmentBadge}
bottomPanel={<ProfileContactsPanel rows={contactRows} />}
/>
{skillsLoading ? (
<p className="app-text-muted mt-2.5 text-sm">Загружаем навыки...</p>
) : null}
</div>
</section>
<ProfileSkillsCard
inputValue={skillName}
onInputChange={setSkillName}
onSubmit={() => void handleAddSkill()}
submitDisabled={!skillName.trim() || skillsSaving}
inputDisabled={skillsSaving}
availableSkills={availableSkills}
skills={profileSkills}
error={skillsError}
loading={skillsLoading}
onRemoveSkill={(skillId) => void handleRemoveSkill(skillId)}
removeDisabled={skillsSaving}
emptyText="Навыки пока не указаны"
/>
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title="Информация" />
<div className="app-surface-muted overflow-hidden rounded-2xl">
<div className="grid md:grid-cols-2">
<div className="px-4 py-3.5">
<p className="text-sm font-semibold text-[var(--foreground)]">
В компании
</p>
<p className="app-text-muted mt-1 text-sm">
{getWorkDuration(user.date_joined) || "—"}
</p>
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3.5 md:border-l md:border-t-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
Дата найма
</p>
<p className="app-text-muted mt-1 text-sm">
{formatDate(user.date_joined)}
</p>
</div>
</div>
<div className="border-t border-[var(--border-subtle)]" />
<div className="grid md:grid-cols-2">
<div className="px-4 py-3.5">
<p className="text-sm font-semibold text-[var(--foreground)]">
Профиль создан
</p>
<p className="app-text-muted mt-1 text-sm">
{formatDate(user.created_at)}
</p>
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3.5 md:border-l md:border-t-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
Последний вход
</p>
<p className="app-text-muted mt-1 text-sm">
{formatDateTime(user.last_login)}
</p>
</div>
</div>
</div>
</section>
<ProfileInfoCard items={infoItems} />
<section className="app-surface rounded-[24px] p-5">
<SectionTitle
title="Кадровые события"
action={
sortedActions.length > 3 ? (
<button
type="button"
onClick={() => setShowAllActions((current) => !current)}
className="app-action-secondary rounded-xl px-3 py-2 text-sm font-medium"
>
{showAllActions
? "Свернуть"
: `Показать все (${sortedActions.length})`}
</button>
) : undefined
}
/>
{sortedActions.length ? (
<div className="space-y-2.5">
{!showAllActions && sortedActions.length > 3 ? (
<p className="app-text-muted text-sm">
Последние {visibleActions.length} из {sortedActions.length}
</p>
) : null}
{visibleActions.map((action) => {
const tone = getEmployeeActionTone(action.action);
const isCurrentAction = action.id === currentActionId;
return (
<div key={action.id} className="relative pl-5">
<span
className="absolute bottom-0 left-1.5 top-0 w-px"
style={{ backgroundColor: tone.lineColor }}
/>
{isCurrentAction ? (
<span
className="absolute left-0 top-2 h-3.5 w-3.5 rounded-full border-4 border-[var(--surface-primary)]"
style={{ backgroundColor: tone.lineColor }}
/>
) : null}
<div className="app-surface-muted rounded-2xl px-4 py-2.5">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<span
className={`app-status-pill ${tone.badgeClass}`}
>
{action.action_display || action.action}
</span>
{isCurrentAction ? (
<span className="app-text-muted ml-2 inline-flex text-xs font-medium">
текущий
</span>
) : null}
{action.comment ? (
<p className="app-text-wrap app-text-muted mt-2 text-sm leading-6">
{truncateText(
action.comment,
showAllActions ? 240 : 120,
)}
</p>
) : null}
</div>
<p className="app-text-muted shrink-0 text-sm md:pt-1">
{formatDateTime(action.date || action.created_at)}
</p>
</div>
</div>
</div>
);
})}
</div>
) : (
<p className="app-text-muted text-sm">Событий нет</p>
)}
</section>
<EmployeeActionsTimeline
actionLoading={null}
canManageActions={false}
canViewActions
latestActionId={currentAction?.id ?? null}
sortedActions={sortedActions}
initialVisibleCount={3}
showCountLabel={false}
truncateCommentLength={120}
expandedCommentLength={240}
/>
</div>
</AppShell>
);

View File

@ -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 (
<div className="mb-4 flex items-start justify-between gap-4">
<h2 className="app-card-caption">{title}</h2>
{action}
</div>
);
}
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: <Mail size={18} />,
}
: null,
person.phone_number
? {
key: "phone",
label: "Телефон",
value: person.phone_number,
href: `tel:${formatPhoneForLink(person.phone_number)}`,
icon: <Phone size={18} />,
}
: null,
person.telegram
? {
key: "telegram",
label: "Telegram",
value: person.telegram,
href: normalizeTelegram(person.telegram),
icon: <MessageCircle size={18} />,
}
: null,
person.whatsapp
? {
key: "whatsapp",
label: "WhatsApp",
value: person.whatsapp,
href: normalizeWhatsApp(person.whatsapp),
icon: <MessageCircle size={18} />,
}
: null,
person.wechat
? {
key: "wechat",
label: "WeChat",
value: person.wechat,
href: null,
icon: <MessageCircle size={18} />,
}
: null,
].filter(Boolean) as Array<{
key: string;
label: string;
value: string;
href: string | null;
icon: React.ReactNode;
}>
: [],
[person],
);
const contactRows = useMemo<ProfileContactRow[]>(() => {
if (!person) return [];
const rows: ProfileContactRow[] = [];
const email = person.email;
if (email) {
rows.push({
key: "email",
label: "Почта",
value: email,
icon: <Mail size={18} />,
onClick: () => void handleCopyToClipboard(email, "email"),
copied: copySuccess === "email",
});
}
const phone = person.phone_number;
if (phone) {
rows.push({
key: "phone",
label: "Телефон",
value: phone,
icon: <Phone size={18} />,
onClick: () => void handleCopyToClipboard(phone, "phone"),
copied: copySuccess === "phone",
});
}
const telegram = person.telegram;
if (telegram) {
rows.push({
key: "telegram",
label: "Telegram",
value: telegram,
icon: <MessageCircle size={18} />,
onClick: () => void handleCopyToClipboard(normalizeTelegramLink(telegram), "telegram"),
copied: copySuccess === "telegram",
});
}
const whatsapp = person.whatsapp;
if (whatsapp) {
rows.push({
key: "whatsapp",
label: "WhatsApp",
value: whatsapp,
icon: <MessageCircle size={18} />,
onClick: () => void handleCopyToClipboard(normalizeWhatsAppLink(whatsapp), "whatsapp"),
copied: copySuccess === "whatsapp",
});
}
const wechat = person.wechat;
if (wechat) {
rows.push({
key: "wechat",
label: "WeChat",
value: wechat,
icon: <MessageCircle size={18} />,
onClick: () => void handleCopyToClipboard(wechat, "wechat"),
copied: copySuccess === "wechat",
});
}
return rows;
}, [copySuccess, handleCopyToClipboard, person]);
const infoItems = useMemo<ProfileInfoItem[]>(() => ([
{
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() {
</section>
) : person ? (
<>
<section className="app-surface rounded-[28px] p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<p className="app-card-caption">Профиль сотрудника</p>
{latestAction ? (
<ProfileHeroCard
caption="Профиль сотрудника"
statusBadge={
latestAction ? (
<span
className={`app-status-pill ${getEmployeeActionTone(latestAction.action).badgeClass}`}
>
{latestAction.action_display || latestAction.action}
</span>
) : null}
</div>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-full app-avatar-frame">
{avatarUrl && !avatarFailed ? (
<Image
src={avatarUrl}
alt={fullName}
width={80}
height={80}
className="h-full w-full object-cover"
onError={() => setAvatarFailed(true)}
unoptimized
/>
) : (
<div className="app-avatar-fallback flex h-full w-full items-center justify-center text-2xl font-semibold">
{initials}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className="app-text-wrap text-[2rem] font-semibold leading-tight text-[var(--foreground)]">
{fullName}
</h1>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className="app-text-muted text-sm">
{person.position?.name || "Должность не указана"}
</span>
{primaryDepartment ? (
<Link
href={`/departments/${primaryDepartment.id}`}
className="app-pill inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]"
>
{primaryDepartment.name}
</Link>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{canEdit ? (
<button
type="button"
onClick={handleOpenEditModal}
className="app-action-secondary inline-flex h-10 w-10 items-center justify-center rounded-xl"
aria-label="Редактировать профиль"
title="Редактировать профиль"
>
<Pencil size={16} />
</button>
) : null}
</div>
</div>
{currentUser && currentUser.id !== person.id ? (
<div className="mt-3 flex flex-wrap gap-2">
<button
onClick={handleStartChat}
disabled={creatingChat}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<MessageCircle size={16} />
{creatingChat ? "Загрузка..." : "Написать"}
</button>
{person.phone_number ? (
<a
href={`tel:${formatPhoneForLink(person.phone_number)}`}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Phone size={16} />
Позвонить
</a>
) : null}
</div>
) : null}
</div>
</div>
<div className="app-surface-muted rounded-2xl p-4">
<div>
{contactRows.length ? (
contactRows.map((contact, index) => (
<div key={contact.key}>
{index > 0 ? (
<div className="mx-4 border-t border-[var(--border-subtle)]" />
) : null}
<div className="flex items-start gap-3 px-4 py-4">
<span className="app-badge mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
{contact.icon}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--foreground)]">
{contact.label}
</p>
{contact.href ? (
<a
href={contact.href}
target={
contact.key === "telegram" ||
contact.key === "whatsapp"
? "_blank"
: undefined
}
rel={
contact.key === "telegram" ||
contact.key === "whatsapp"
? "noreferrer"
: undefined
}
className="app-text-wrap app-text-muted mt-1 block text-sm hover:text-[var(--accent-primary)]"
>
{contact.value}
</a>
) : (
<p className="app-text-wrap app-text-muted mt-1 text-sm">
{contact.value}
</p>
)}
</div>
<button
type="button"
onClick={() =>
handleCopyToClipboard(contact.value, contact.key)
}
className="app-action-secondary inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
title="Скопировать"
>
{copySuccess === contact.key ? (
<Check size={14} />
) : (
<Copy size={14} />
)}
</button>
</div>
</div>
))
) : (
<p className="app-text-muted px-4 py-2 text-sm">
Контакты не указаны
</p>
)}
</div>
</div>
</div>
</section>
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title="Навыки" />
<div className="app-surface-muted rounded-2xl p-4">
<div className="flex flex-col gap-3 md:flex-row">
<div className="min-w-0 flex-1">
<input
list="employee-skills-list"
value={skillName}
onChange={(event) => setSkillName(event.target.value)}
placeholder="Добавить навык"
className="app-input w-full rounded-xl px-4 py-3 text-sm"
disabled={skillsSaving}
) : null
}
avatar={
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-full app-avatar-frame">
{avatarUrl && !avatarFailed ? (
<Image
src={avatarUrl}
alt={fullName}
width={80}
height={80}
className="h-full w-full object-cover"
onError={() => setAvatarFailed(true)}
unoptimized
/>
<datalist id="employee-skills-list">
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.name} />
))}
</datalist>
</div>
<button
type="button"
onClick={() => void handleAddSkill()}
disabled={!skillName.trim() || skillsSaving}
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-medium disabled:opacity-50"
>
<Plus size={16} />
Добавить
</button>
</div>
{skillsError ? (
<p className="mt-3 text-sm text-red-400">{skillsError}</p>
) : null}
<div className="mt-4">
{personSkills.length ? (
<div className="flex flex-wrap gap-2">
{personSkills.map((skill) => (
<span
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-3 py-2 text-sm font-medium"
>
{skill.name}
</span>
))}
</div>
) : (
<p className="app-text-muted text-sm">Навыки не указаны</p>
<div className="app-avatar-fallback flex h-full w-full items-center justify-center text-2xl font-semibold">
{initials}
</div>
)}
</div>
}
fullName={fullName}
secondaryLine={formatBirthdayWithYear(person.birth_date) || "Не указана"}
roleText={person.position?.name || "Должность не указана"}
departmentBadge={
primaryDepartment ? (
<ProfileDepartmentBadge
label={primaryDepartment.name}
href={`/departments/${primaryDepartment.id}`}
/>
) : undefined
}
headerActions={
canEdit ? (
<button
type="button"
onClick={handleOpenEditModal}
className="app-action-secondary inline-flex h-10 w-10 items-center justify-center rounded-xl"
aria-label="Редактировать профиль"
title="Редактировать профиль"
>
<Pencil size={16} />
</button>
) : undefined
}
actionRow={
currentUser && currentUser.id !== person.id ? (
<>
<button
onClick={handleStartChat}
disabled={creatingChat}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<MessageCircle size={16} />
{creatingChat ? "Загрузка..." : "Написать"}
</button>
{person.phone_number ? (
<a
href={`tel:${formatPhoneForLink(person.phone_number)}`}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Phone size={16} />
Позвонить
</a>
) : null}
</>
) : undefined
}
bottomPanel={
<ProfileContactsPanel
rows={contactRows}
emptyText="Контакты не указаны"
/>
}
/>
{skillsLoading ? (
<p className="app-text-muted mt-3 text-sm">
Загружаем навыки...
</p>
) : null}
</div>
</section>
<ProfileSkillsCard
inputValue={skillName}
onInputChange={setSkillName}
onSubmit={() => void handleAddSkill()}
submitDisabled={!skillName.trim() || skillsSaving}
inputDisabled={skillsSaving}
availableSkills={availableSkills}
skills={personSkills}
error={skillsError}
loading={skillsLoading}
emptyText="Навыки не указаны"
/>
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title="Информация" />
<div className="app-surface-muted overflow-hidden rounded-2xl">
<div className="grid md:grid-cols-2">
<div className="px-4 py-4">
<p className="text-sm font-semibold text-[var(--foreground)]">
В компании
</p>
<p className="app-text-muted mt-1 text-sm">
{getWorkDuration(person.date_joined) || "—"}
</p>
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-4 md:border-l md:border-t-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
Дата найма
</p>
<p className="app-text-muted mt-1 text-sm">
{formatDate(person.date_joined)}
</p>
</div>
</div>
<div className="border-t border-[var(--border-subtle)]" />
<div className="grid md:grid-cols-2">
<div className="px-4 py-4">
<p className="text-sm font-semibold text-[var(--foreground)]">
День рождения
</p>
<p className="app-text-muted mt-1 text-sm">
{formatBirthday(person.birth_date) || "—"}
</p>
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-4 md:border-l md:border-t-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
Последний вход
</p>
<p className="app-text-muted mt-1 text-sm">
{formatDateTime(person.last_login)}
</p>
</div>
</div>
</div>
</section>
<ProfileInfoCard items={infoItems} />
{person.departments?.length ? (
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title="Отделы" />
<div className="mb-4">
<h2 className="app-card-caption">Отделы</h2>
</div>
<div className="app-surface-muted rounded-2xl p-4">
<div className="space-y-3">
{person.departments.map((department) => (
@ -632,6 +453,8 @@ export default function UserDetailPage() {
onDeleteAction={handleDeleteAction}
onEditAction={handleEditAction}
sortedActions={sortedActions}
truncateCommentLength={120}
expandedCommentLength={240}
/>
</>
) : null}

View File

@ -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);
}
};

View File

@ -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 <Check size={13} className="text-emerald-500" />;
}
if (normalized === "rejected") {
return <X size={13} className="text-rose-500" />;
}
return <CircleDot size={13} className="text-amber-500" />;
};
export function ProcurementRequestDetailContent({
request,
displayUserName,
footer,
}: ProcurementRequestDetailContentProps) {
return (
<div className="space-y-3">
<div className="app-surface rounded-xl p-4">
<p className="text-sm font-semibold text-[var(--foreground)]">Описание</p>
<p className="app-text-wrap mt-2 whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">
{request.description || "—"}
</p>
<div className="mt-3 grid gap-2 text-xs sm:grid-cols-3">
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-text-muted text-[11px] uppercase tracking-wide">Отправлена</p>
<p className="mt-1 font-medium text-[var(--foreground)]">{formatDate(request.submitted_at) || "—"}</p>
</div>
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-text-muted text-[11px] uppercase tracking-wide">Взята в работу</p>
<p className="mt-1 font-medium text-[var(--foreground)]">{formatDate(request.started_at) || "—"}</p>
</div>
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-text-muted text-[11px] uppercase tracking-wide">Завершена</p>
<p className="mt-1 font-medium text-[var(--foreground)]">{formatDate(request.completed_at) || "—"}</p>
</div>
</div>
{request.actual_cost ? (
<div className="mt-3 inline-flex rounded-full app-badge-accent px-2.5 py-1 text-xs font-medium">
Фактическая сумма: {formatMoney(request.actual_cost)}
</div>
) : null}
</div>
{request.items && request.items.length > 0 ? (
<div className="app-surface rounded-xl p-4">
<p className="mb-3 text-sm font-semibold text-[var(--foreground)]">Позиции</p>
<div className="space-y-2">
{request.items.map((item, index) => (
<div key={index} className="app-surface-muted rounded-lg px-3 py-3 text-xs">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="app-text-wrap font-medium text-[var(--foreground)]">{item.name}</p>
{item.description ? (
<p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">{item.description}</p>
) : null}
{item.supplier_info ? (
<p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">
Поставщик: {item.supplier_info}
</p>
) : null}
</div>
<div className="text-right">
<p className="font-medium text-[var(--foreground)]">{formatMoney(item.total_price)}</p>
<p className="app-text-muted mt-1">
{item.quantity} {item.unit}
</p>
</div>
</div>
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span>Цена/ед.: {formatMoney(item.estimated_unit_price)}</span>
</div>
</div>
))}
</div>
</div>
) : null}
{request.approvals && request.approvals.length > 0 ? (
<div className="app-surface rounded-xl p-4">
<p className="mb-3 text-sm font-semibold text-[var(--foreground)]">Согласования</p>
<div className="space-y-2">
{request.approvals.map((approval) => (
<div
key={approval.id}
className="app-surface-muted flex items-center gap-2 rounded-lg px-3 py-2 text-xs"
>
{approvalIconByStatus(approval.status)}
<span className="font-medium text-[var(--foreground)]">
{displayUserName(approval.approver, approval.approver_name)}
</span>
<span className="app-text-muted">({approval.step_label || `Этап ${approval.priority}`})</span>
{approval.comment ? (
<span className="app-text-wrap app-text-muted ml-auto max-w-full italic">
«{approval.comment}»
</span>
) : null}
</div>
))}
</div>
</div>
) : null}
{footer}
</div>
);
}

View File

@ -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 (
<section className="app-surface rounded-[24px] p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<h2 className="app-card-caption">Кадровые события</h2>
<div className="flex items-center gap-2">
<History size={18} className="app-text-muted" />
<h2 className="app-card-caption">Кадровые события</h2>
</div>
<div className="flex items-center gap-2">
<span className="app-text-muted text-sm">
{sortedActions.length}{" "}
{sortedActions.length === 1
? "событие"
: sortedActions.length < 5
? "события"
: "событий"}
</span>
{canManageActions ? (
{showCountLabel ? (
<span className="app-text-muted text-sm">
{sortedActions.length}{" "}
{sortedActions.length === 1
? "событие"
: sortedActions.length < 5
? "события"
: "событий"}
</span>
) : null}
{canCollapse ? (
<button
onClick={onAddAction}
type="button"
onClick={() => setShowAll((current) => !current)}
className="app-action-secondary rounded-xl px-3 py-2 text-sm font-medium"
>
+ Добавить
{showAll ? "Свернуть" : `Показать все (${sortedActions.length})`}
</button>
) : null}
{canManageActions && onAddAction ? (
<button
onClick={onAddAction}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium"
>
<Plus size={14} />
Добавить
</button>
) : null}
</div>
</div>
{!showAll && canCollapse ? (
<p className="app-text-muted mb-3 text-sm">
Последние {visibleActions.length} из {sortedActions.length}
</p>
) : null}
<div className="space-y-3">
{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 (
<div key={action.id} className="relative pl-5">
@ -101,9 +137,9 @@ export default function EmployeeActionsTimeline({
</span>
) : null}
</div>
{action.comment ? (
{commentText ? (
<p className="app-text-wrap app-text-muted mt-2 text-sm leading-6">
{action.comment}
{commentText}
</p>
) : null}
</div>
@ -111,7 +147,7 @@ export default function EmployeeActionsTimeline({
<time className="app-text-muted pt-1 text-sm">
{formatActionDate(action.date)}
</time>
{canManageActions ? (
{canManageActions && onEditAction && onDeleteAction ? (
<div className="flex gap-1">
<button
onClick={() => onEditAction(action)}

View File

@ -0,0 +1,321 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { Plus, X } from "lucide-react";
import type { Skill } from "@/types/api";
export type ProfileContactRow = {
key: string;
label: string;
value: string;
icon: ReactNode;
onClick?: () => void;
copied?: boolean;
action?: ReactNode;
};
export type ProfileInfoItem = {
label: string;
value: string;
};
function SectionTitle({
title,
action,
}: {
title: string;
action?: ReactNode;
}) {
return (
<div className="mb-4 flex items-start justify-between gap-4">
<h2 className="app-card-caption">{title}</h2>
{action}
</div>
);
}
export function ProfileHeroCard({
caption,
statusBadge,
avatar,
fullName,
secondaryLine,
roleText,
departmentBadge,
headerActions,
actionRow,
bottomPanel,
}: {
caption: string;
statusBadge?: ReactNode;
avatar: ReactNode;
fullName: string;
secondaryLine?: string | null;
roleText: string;
departmentBadge?: ReactNode;
headerActions?: ReactNode;
actionRow?: ReactNode;
bottomPanel?: ReactNode;
}) {
return (
<section className="app-surface rounded-[28px] p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<p className="app-card-caption">{caption}</p>
{statusBadge}
</div>
<div className="space-y-4">
<div className="flex items-start gap-4">
{avatar}
<div className="min-w-0 flex-1">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className="app-text-wrap text-[2rem] font-semibold leading-tight text-[var(--foreground)]">
{fullName}
</h1>
{secondaryLine ? (
<p className="app-text-muted mt-1.5 text-sm">{secondaryLine}</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="app-text-muted text-sm">{roleText}</span>
{departmentBadge}
</div>
</div>
{headerActions ? <div className="flex shrink-0 items-center gap-2">{headerActions}</div> : null}
</div>
{actionRow ? <div className="mt-3 flex flex-wrap gap-2">{actionRow}</div> : null}
</div>
</div>
{bottomPanel}
</div>
</section>
);
}
export function ProfileContactsPanel({
rows,
emptyText = "Контакты не указаны",
}: {
rows: ProfileContactRow[];
emptyText?: string;
}) {
return (
<div className="app-surface-muted rounded-2xl p-4">
{rows.length ? (
rows.map((row, index) => (
<div key={row.key}>
{index > 0 ? <div className="mx-4 border-t border-[var(--border-subtle)]" /> : null}
<div className="flex items-start gap-3 px-4 py-4">
<span className="app-badge mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
{row.icon}
</span>
{row.onClick ? (
<button
type="button"
onClick={row.onClick}
className="min-w-0 flex-1 text-left"
title="Скопировать"
>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-[var(--foreground)]">
{row.label}
</p>
{row.copied ? (
<span className="app-text-muted text-xs">Скопировано</span>
) : null}
</div>
<p className="app-text-wrap app-text-muted mt-1 text-sm transition hover:text-[var(--accent-primary)]">
{row.value}
</p>
</button>
) : (
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-[var(--foreground)]">
{row.label}
</p>
{row.copied ? (
<span className="app-text-muted text-xs">Скопировано</span>
) : null}
</div>
<p className="app-text-wrap app-text-muted mt-1 text-sm">
{row.value}
</p>
</div>
)}
{row.action ? <div className="shrink-0">{row.action}</div> : null}
</div>
</div>
))
) : (
<p className="app-text-muted px-4 py-2 text-sm">{emptyText}</p>
)}
</div>
);
}
export function ProfileInfoCard({
title = "Информация",
items,
}: {
title?: string;
items: ProfileInfoItem[];
}) {
return (
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title={title} />
<div className="app-surface-muted overflow-hidden rounded-2xl">
<div className="grid md:grid-cols-2">
{items.map((item, index) => (
<div
key={item.label}
className={[
"px-4 py-3.5",
index % 2 === 1 ? "border-t border-[var(--border-subtle)] md:border-l md:border-t-0" : "",
index >= 2 ? "border-t border-[var(--border-subtle)]" : "",
index >= 2 && index % 2 === 1 ? "md:border-l" : "",
].join(" ").trim()}
>
<p className="text-sm font-semibold text-[var(--foreground)]">
{item.label}
</p>
<p className="app-text-muted mt-1 text-sm">{item.value}</p>
</div>
))}
</div>
</div>
</section>
);
}
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 (
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title={title} />
<div className="app-surface-muted rounded-2xl p-3.5">
<div className="flex flex-col gap-2.5 md:flex-row">
<div className="min-w-0 flex-1">
<input
list={listId}
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
placeholder="Добавить навык"
className="app-input w-full rounded-xl px-4 py-2.5 text-sm"
disabled={inputDisabled}
/>
<datalist id={listId}>
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.name} />
))}
</datalist>
</div>
<button
type="button"
onClick={onSubmit}
disabled={submitDisabled}
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<Plus size={16} />
Добавить
</button>
</div>
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : null}
<div className="mt-3">
{skills.length ? (
<div className="flex flex-wrap gap-2">
{skills.map((skill) =>
onRemoveSkill ? (
<button
key={skill.id}
type="button"
onClick={() => onRemoveSkill(skill.id)}
disabled={removeDisabled}
className="app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] disabled:opacity-50"
title="Удалить навык"
>
<span>{skill.name}</span>
<X size={14} />
</button>
) : (
<span
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
>
{skill.name}
</span>
),
)}
</div>
) : (
<p className="app-text-muted text-sm">{emptyText}</p>
)}
</div>
{loading ? (
<p className="app-text-muted mt-2.5 text-sm">Загружаем навыки...</p>
) : null}
</div>
</section>
);
}
export function ProfileDepartmentBadge({
label,
href,
}: {
label: string;
href?: string;
}) {
if (href) {
return (
<Link
href={href}
className="app-pill inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]"
>
{label}
</Link>
);
}
return (
<span className="app-pill inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
{label}
</span>
);
}

View File

@ -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<Set<number>>(new Set());
const [detailsCache, setDetailsCache] = useState<Record<number, ProcurementRequest>>({});
const detailsCacheRef = useRef<Record<number, ProcurementRequest>>({});
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,
};
}

View File

@ -0,0 +1,24 @@
type NotificationNavigationLike = {
action_url?: string | null;
verb?: string | null;
category?: string | null;
data?: Record<string, unknown> | 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;
}

View File

@ -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;