refactor(frontend): align profile, notifications and procurement flows
This commit is contained in:
parent
e3ba6ea925
commit
ae7e1daf89
@ -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}'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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-ветки
|
||||
|
||||
## Таймлайны
|
||||
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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)}
|
||||
|
||||
321
frontend/src/components/users/ProfileSections.tsx
Normal file
321
frontend/src/components/users/ProfileSections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
24
frontend/src/lib/notifications/actionUrl.ts
Normal file
24
frontend/src/lib/notifications/actionUrl.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user