1247 lines
39 KiB
TypeScript
1247 lines
39 KiB
TypeScript
import { Component, HostListener, Inject, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
|
|
import { RouterLink, Router, NavigationEnd } from '@angular/router';
|
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
|
import { PLATFORM_ID } from '@angular/core';
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { filter } from 'rxjs/operators';
|
|
import { AuthService } from '../../services/auth.service';
|
|
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
|
|
import { UsersService, CreateUserPayload, ApiFieldError } from '../../services/users.service';
|
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors, FormsModule } from '@angular/forms';
|
|
import { HttpErrorResponse } from '@angular/common/http';
|
|
import { CustomSelectComponent } from '../custom-select/custom-select';
|
|
import { Subscription } from 'rxjs';
|
|
import { environment } from '../../../environments/environment';
|
|
import { confirmActionModal, confirmDeletionWithTyping, showDeletionWarning } from '../../utils/destructive-confirmation';
|
|
import { buildApiBaseUrl } from '../../utils/api-base.util';
|
|
|
|
@Component({
|
|
selector: 'app-header',
|
|
standalone: true,
|
|
imports: [RouterLink, CommonModule, ReactiveFormsModule, FormsModule, CustomSelectComponent],
|
|
templateUrl: './header.html',
|
|
styleUrls: ['./header.scss'],
|
|
})
|
|
export class Header implements AfterViewInit, OnDestroy {
|
|
isScrolled = false;
|
|
private headerResizeObserver?: ResizeObserver;
|
|
private headerTransitionEndCleanup?: (() => void) | null = null;
|
|
|
|
menuOpen = false;
|
|
optionsOpen = false;
|
|
notificationsOpen = false;
|
|
createUserOpen = false;
|
|
manageUsersOpen = false;
|
|
isLoggedHeader = false;
|
|
isHome = false;
|
|
isSysAdmin = false;
|
|
isGestor = false;
|
|
isFinanceiro = false;
|
|
canViewAll = false;
|
|
canViewFinancialPages = false;
|
|
clientTenantDisplayName = '';
|
|
private clientTenantNameTenantId: string | null = null;
|
|
private readonly baseApi: string;
|
|
notifications: NotificationDto[] = [];
|
|
notificationsLoading = false;
|
|
notificationsError = false;
|
|
notificationsView: 'pendentes' | 'lidas' = 'pendentes';
|
|
notificationsBulkReadLoading = false;
|
|
notificationsBulkUnreadLoading = false;
|
|
private notificationsLoaded = false;
|
|
private pendingToastCheck = false;
|
|
private lastNotificationsLoadAt = 0;
|
|
private readonly notificationsRefreshMs = 60_000;
|
|
readonly notificationsPreviewLimit = 40;
|
|
@ViewChild('notifToast') notifToast?: ElementRef;
|
|
private readonly subs = new Subscription();
|
|
|
|
createUserForm: FormGroup;
|
|
createUserSubmitting = false;
|
|
createUserErrors: ApiFieldError[] = [];
|
|
createUserForbidden = false;
|
|
createUserSuccess = '';
|
|
readonly permissionOptions = [
|
|
{ value: 'sysadmin', label: 'SysAdmin' },
|
|
{ value: 'gestor', label: 'Gestor' },
|
|
{ value: 'financeiro', label: 'Financeiro' },
|
|
{ value: 'cliente', label: 'Cliente' },
|
|
];
|
|
|
|
manageUsersLoading = false;
|
|
manageUsersErrors: ApiFieldError[] = [];
|
|
manageUsersSuccess = '';
|
|
private manageUsersFeedbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
manageMode: 'users' | 'clients' = 'users';
|
|
manageUsers: any[] = [];
|
|
manageSearch = '';
|
|
managePage = 1;
|
|
managePageSize = 10;
|
|
manageTotal = 0;
|
|
|
|
editUserForm: FormGroup;
|
|
editUserSubmitting = false;
|
|
editUserErrors: ApiFieldError[] = [];
|
|
editUserSuccess = '';
|
|
editUserTarget: any | null = null;
|
|
|
|
private readonly loggedPrefixes = [
|
|
'/geral',
|
|
'/mureg',
|
|
'/faturamento',
|
|
'/dadosusuarios',
|
|
'/vigencia',
|
|
'/trocanumero',
|
|
'/dashboard',
|
|
'/notificacoes',
|
|
'/chips-controle-recebidos',
|
|
'/resumo',
|
|
'/parcelamentos',
|
|
'/historico',
|
|
'/historico-linhas',
|
|
'/solicitacoes',
|
|
'/perfil',
|
|
'/system',
|
|
];
|
|
|
|
constructor(
|
|
private router: Router,
|
|
private authService: AuthService,
|
|
private notificationsService: NotificationsService,
|
|
private usersService: UsersService,
|
|
private http: HttpClient,
|
|
private fb: FormBuilder,
|
|
private hostElement: ElementRef<HTMLElement>,
|
|
@Inject(PLATFORM_ID) private platformId: object
|
|
) {
|
|
this.baseApi = buildApiBaseUrl(environment.apiUrl);
|
|
|
|
this.createUserForm = this.fb.group(
|
|
{
|
|
nome: ['', [Validators.required, Validators.minLength(2)]],
|
|
email: ['', [Validators.required, Validators.email]],
|
|
senha: ['', [Validators.required, Validators.minLength(6)]],
|
|
confirmarSenha: ['', [Validators.required, Validators.minLength(6)]],
|
|
permissao: ['', [Validators.required]],
|
|
},
|
|
{ validators: this.passwordsMatchValidator }
|
|
);
|
|
|
|
this.editUserForm = this.fb.group({
|
|
nome: [''],
|
|
email: [''],
|
|
senha: [''],
|
|
confirmarSenha: [''],
|
|
permissao: [''],
|
|
ativo: [true],
|
|
});
|
|
|
|
this.syncHeaderState(this.router.url);
|
|
this.syncPermissions();
|
|
|
|
this.router.events
|
|
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
|
|
.subscribe((event) => {
|
|
const rawUrl = event.urlAfterRedirects || event.url;
|
|
this.syncHeaderState(rawUrl);
|
|
this.syncPermissions();
|
|
this.menuOpen = false;
|
|
this.optionsOpen = false;
|
|
this.notificationsOpen = false;
|
|
if (this.isLoggedHeader) {
|
|
this.ensureNotificationsLoaded();
|
|
}
|
|
});
|
|
if (this.isLoggedHeader) {
|
|
this.ensureNotificationsLoaded();
|
|
}
|
|
|
|
this.subs.add(
|
|
this.notificationsService.events$.subscribe((ev) => {
|
|
if (ev.type === 'read') {
|
|
const byId = new Set(ev.ids);
|
|
this.notifications.forEach((n) => {
|
|
if (byId.has(n.id)) {
|
|
n.lida = true;
|
|
n.lidaEm = ev.readAtIso;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (ev.type === 'unread') {
|
|
const byId = new Set(ev.ids);
|
|
this.notifications.forEach((n) => {
|
|
if (byId.has(n.id)) {
|
|
n.lida = false;
|
|
n.lidaEm = null;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (ev.type === 'readAll') {
|
|
this.notifications.forEach((n) => {
|
|
if (!n.lida) {
|
|
n.lida = true;
|
|
n.lidaEm = ev.readAtIso;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (ev.type === 'unreadAll') {
|
|
this.notifications.forEach((n) => {
|
|
if (n.lida) {
|
|
n.lida = false;
|
|
n.lidaEm = null;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (ev.type === 'reload') {
|
|
// Para mudanças de escopo desconhecido, recarrega em background.
|
|
if (this.isLoggedHeader) setTimeout(() => this.loadNotifications(false), 0);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
private syncHeaderState(rawUrl: string) {
|
|
let url = (rawUrl || '').split('?')[0].split('#')[0];
|
|
if (url && !url.startsWith('/')) url = `/${url}`;
|
|
url = url.replace(/\/+$/, '');
|
|
|
|
this.isHome = (url === '/' || url === '');
|
|
|
|
this.isLoggedHeader = this.loggedPrefixes.some((p) =>
|
|
url === p || url.startsWith(p + '/')
|
|
);
|
|
}
|
|
|
|
private syncPermissions() {
|
|
if (!isPlatformBrowser(this.platformId)) {
|
|
this.isSysAdmin = false;
|
|
this.isGestor = false;
|
|
this.isFinanceiro = false;
|
|
this.canViewAll = false;
|
|
this.canViewFinancialPages = false;
|
|
this.clientTenantDisplayName = '';
|
|
this.clientTenantNameTenantId = null;
|
|
return;
|
|
}
|
|
const isSysAdmin = this.authService.hasRole('sysadmin');
|
|
const isGestor = this.authService.hasRole('gestor');
|
|
const isFinanceiro = this.authService.hasRole('financeiro');
|
|
this.isSysAdmin = isSysAdmin;
|
|
this.isGestor = isGestor;
|
|
this.isFinanceiro = isFinanceiro;
|
|
this.canViewAll = isSysAdmin || isGestor || isFinanceiro;
|
|
this.canViewFinancialPages = isSysAdmin || isFinanceiro;
|
|
|
|
if (!this.isClientHeader) {
|
|
this.clientTenantDisplayName = '';
|
|
this.clientTenantNameTenantId = null;
|
|
return;
|
|
}
|
|
|
|
this.ensureClientTenantName();
|
|
}
|
|
|
|
toggleMenu() {
|
|
this.menuOpen = !this.menuOpen;
|
|
}
|
|
|
|
closeMenu() {
|
|
this.menuOpen = false;
|
|
}
|
|
|
|
toggleOptions() {
|
|
this.optionsOpen = !this.optionsOpen;
|
|
if (this.optionsOpen) this.notificationsOpen = false;
|
|
}
|
|
|
|
closeOptions() {
|
|
this.optionsOpen = false;
|
|
}
|
|
|
|
goToProfile() {
|
|
this.closeOptions();
|
|
this.router.navigate(['/perfil']);
|
|
}
|
|
|
|
goToSystemProvisionUser() {
|
|
if (!this.isSysAdmin) return;
|
|
this.closeOptions();
|
|
this.router.navigate(['/system/fornecer-usuario']);
|
|
}
|
|
|
|
openCreateUserModal() {
|
|
if (!this.isSysAdmin) return;
|
|
this.createUserOpen = true;
|
|
this.closeOptions();
|
|
this.resetCreateUserState();
|
|
}
|
|
|
|
closeCreateUserModal() {
|
|
this.createUserOpen = false;
|
|
this.resetCreateUserState();
|
|
}
|
|
|
|
openManageUsersModal() {
|
|
if (!this.isSysAdmin) return;
|
|
this.manageMode = 'users';
|
|
this.manageUsersOpen = true;
|
|
this.closeOptions();
|
|
this.resetManageUsersState();
|
|
this.fetchManageUsers(1);
|
|
}
|
|
|
|
openManageClientCredentialsModal() {
|
|
if (!this.isSysAdmin) return;
|
|
this.manageMode = 'clients';
|
|
this.manageUsersOpen = true;
|
|
this.closeOptions();
|
|
this.resetManageUsersState();
|
|
this.fetchManageUsers(1);
|
|
}
|
|
|
|
closeManageUsersModal() {
|
|
this.manageUsersOpen = false;
|
|
this.resetManageUsersState();
|
|
this.manageMode = 'users';
|
|
}
|
|
|
|
toggleNotifications() {
|
|
this.notificationsOpen = !this.notificationsOpen;
|
|
if (this.notificationsOpen) {
|
|
this.notificationsView = 'pendentes';
|
|
this.optionsOpen = false;
|
|
if (!this.notificationsLoaded) {
|
|
this.loadNotifications(false);
|
|
return;
|
|
}
|
|
|
|
if (Date.now() - this.lastNotificationsLoadAt > this.notificationsRefreshMs) {
|
|
// Atualiza em background para não bloquear a abertura visual do dropdown.
|
|
setTimeout(() => this.loadNotifications(false), 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
closeNotifications() {
|
|
this.notificationsOpen = false;
|
|
}
|
|
|
|
markNotificationRead(notification: NotificationDto) {
|
|
if (notification.lida) return;
|
|
this.notificationsService.markAsRead(notification.id).subscribe({
|
|
next: () => {
|
|
notification.lida = true;
|
|
notification.lidaEm = new Date().toISOString();
|
|
// Evita que o toast volte a aparecer para a mesma notificação.
|
|
this.acknowledgeNotification(notification);
|
|
},
|
|
});
|
|
}
|
|
|
|
markNotificationUnread(notification: NotificationDto) {
|
|
if (!notification.lida) return;
|
|
this.notificationsService.markAsUnread(notification.id).subscribe({
|
|
next: () => {
|
|
notification.lida = false;
|
|
notification.lidaEm = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
setNotificationsView(view: 'pendentes' | 'lidas') {
|
|
this.notificationsView = view;
|
|
}
|
|
|
|
markAllNotificationsRead() {
|
|
if (this.notificationsView === 'lidas') return;
|
|
if (this.unreadCount === 0 || this.notificationsBulkReadLoading) return;
|
|
this.notificationsBulkReadLoading = true;
|
|
this.notificationsService.markAllAsRead().subscribe({
|
|
next: () => {
|
|
// Evento do service já sincroniza o estado; aqui só finalizamos loading e acknowledge.
|
|
const unreadIds = this.notifications.filter(n => !n.lida).map(n => n.id);
|
|
if (unreadIds.length && isPlatformBrowser(this.platformId)) {
|
|
const acknowledged = this.getAcknowledgedIds();
|
|
unreadIds.forEach(id => acknowledged.add(id));
|
|
localStorage.setItem('vigenciaAcknowledgedIds', JSON.stringify(Array.from(acknowledged)));
|
|
}
|
|
this.notificationsBulkReadLoading = false;
|
|
},
|
|
error: () => {
|
|
this.notificationsBulkReadLoading = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
markAllNotificationsUnread() {
|
|
if (this.notificationsView !== 'lidas') return;
|
|
if (this.notificationsVisibleCount === 0 || this.notificationsBulkUnreadLoading) return;
|
|
this.notificationsBulkUnreadLoading = true;
|
|
this.notificationsService.markAllAsUnread().subscribe({
|
|
next: () => {
|
|
this.notificationsBulkUnreadLoading = false;
|
|
},
|
|
error: () => {
|
|
this.notificationsBulkUnreadLoading = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
onNotificationItemClick(notification: NotificationDto) {
|
|
if (this.notificationsView === 'lidas') {
|
|
this.markNotificationUnread(notification);
|
|
return;
|
|
}
|
|
this.markNotificationRead(notification);
|
|
}
|
|
|
|
getVigenciaLabel(notification: NotificationDto): string {
|
|
const tipo = this.getNotificationTipo(notification);
|
|
if (tipo === 'Vencido') return 'Venceu em';
|
|
if (tipo === 'AVencer') return 'Vence em';
|
|
return 'Atualizado em';
|
|
}
|
|
|
|
getVigenciaDate(notification: NotificationDto): string {
|
|
const raw =
|
|
notification.dtTerminoFidelizacao ??
|
|
notification.referenciaData ??
|
|
notification.data;
|
|
if (!raw) return '-';
|
|
const parsed = this.parseDateOnly(raw);
|
|
if (!parsed) return '-';
|
|
return parsed.toLocaleDateString('pt-BR');
|
|
}
|
|
|
|
getNotificationTipo(notification: NotificationDto): string {
|
|
if (notification.tipo === 'RenovacaoAutomatica') {
|
|
return 'RenovacaoAutomatica';
|
|
}
|
|
|
|
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
|
|
const parsed = this.parseDateOnly(reference);
|
|
if (!parsed) return notification.tipo;
|
|
|
|
const today = this.startOfDay(new Date());
|
|
return parsed < today ? 'Vencido' : 'AVencer';
|
|
}
|
|
|
|
getNotificationDaysToExpire(notification: NotificationDto): number | null {
|
|
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
|
|
const parsed = this.parseDateOnly(reference);
|
|
if (!parsed) {
|
|
return notification.diasParaVencer ?? null;
|
|
}
|
|
|
|
const today = this.startOfDay(new Date());
|
|
const msPerDay = 24 * 60 * 60 * 1000;
|
|
return Math.round((parsed.getTime() - today.getTime()) / msPerDay);
|
|
}
|
|
|
|
abbreviateName(value?: string | null): string {
|
|
const name = (value ?? '').trim();
|
|
if (!name) return '-';
|
|
const parts = name.split(/\s+/).filter(Boolean);
|
|
if (parts.length === 1) return parts[0];
|
|
|
|
const maxLen = 18;
|
|
const full = parts.join(' ');
|
|
if (full.length <= maxLen) return full;
|
|
|
|
if (parts.length >= 3) {
|
|
const candidate = `${parts[0]} ${parts[1]} ${parts[2][0]}.`;
|
|
if (candidate.length <= maxLen) return candidate;
|
|
return `${parts[0]} ${parts[1][0]}.`;
|
|
}
|
|
|
|
const two = `${parts[0]} ${parts[1]}`;
|
|
if (two.length <= maxLen) return two;
|
|
return `${parts[0]} ${parts[1][0]}.`;
|
|
}
|
|
|
|
get unreadCount() {
|
|
return this.notifications.filter(n => !n.lida).length;
|
|
}
|
|
|
|
get isClientHeader(): boolean {
|
|
return this.isLoggedHeader && !this.canViewAll;
|
|
}
|
|
|
|
get headerLogoSubtitle(): string {
|
|
return this.isClientHeader ? 'Gestão Empresas' : 'Gestão';
|
|
}
|
|
|
|
get headerLogoAriaLabel(): string {
|
|
return `Line ${this.headerLogoSubtitle}`;
|
|
}
|
|
|
|
get clientTenantDisplayNameAbbrev(): string {
|
|
return this.abbreviateClientTenantName(this.clientTenantDisplayName);
|
|
}
|
|
|
|
get notificationsVisible() {
|
|
return this.notificationsView === 'lidas'
|
|
? this.notifications.filter(n => n.lida)
|
|
: this.notifications.filter(n => !n.lida);
|
|
}
|
|
|
|
get notificationsVisibleCount() {
|
|
return this.notificationsVisible.length;
|
|
}
|
|
|
|
get notificationsPreview() {
|
|
return this.notificationsVisible.slice(0, this.notificationsPreviewLimit);
|
|
}
|
|
|
|
get hasNotificationsTruncated() {
|
|
return this.notificationsVisibleCount > this.notificationsPreviewLimit;
|
|
}
|
|
|
|
trackByNotificationId(_: number, notification: NotificationDto) {
|
|
return notification.id;
|
|
}
|
|
|
|
logout() {
|
|
this.authService.logout();
|
|
this.optionsOpen = false;
|
|
this.notificationsOpen = false;
|
|
this.isSysAdmin = false;
|
|
this.isGestor = false;
|
|
this.isFinanceiro = false;
|
|
this.canViewAll = false;
|
|
this.canViewFinancialPages = false;
|
|
this.router.navigate(['/']);
|
|
}
|
|
|
|
@HostListener('window:scroll', [])
|
|
onWindowScroll() {
|
|
if (!isPlatformBrowser(this.platformId)) return;
|
|
this.isScrolled = window.scrollY > 10;
|
|
this.scheduleHeaderOffsetSync();
|
|
}
|
|
|
|
@HostListener('window:resize', [])
|
|
onWindowResize() {
|
|
if (!isPlatformBrowser(this.platformId)) return;
|
|
this.scheduleHeaderOffsetSync();
|
|
}
|
|
|
|
@HostListener('document:click', [])
|
|
onDocumentClick() {
|
|
this.optionsOpen = false;
|
|
this.notificationsOpen = false;
|
|
}
|
|
|
|
@HostListener('document:keydown.escape', [])
|
|
onEsc() {
|
|
if (!isPlatformBrowser(this.platformId)) return;
|
|
this.closeMenu();
|
|
this.closeOptions();
|
|
this.closeNotifications();
|
|
this.closeCreateUserModal();
|
|
this.closeManageUsersModal();
|
|
}
|
|
|
|
acknowledgeNotification(notification: NotificationDto) {
|
|
if (!isPlatformBrowser(this.platformId)) return;
|
|
const acknowledged = this.getAcknowledgedIds();
|
|
acknowledged.add(notification.id);
|
|
localStorage.setItem('vigenciaAcknowledgedIds', JSON.stringify(Array.from(acknowledged)));
|
|
}
|
|
|
|
acknowledgeCurrentToast() {
|
|
const current = this.toastNotification;
|
|
if (!current) return;
|
|
this.acknowledgeNotification(current);
|
|
}
|
|
|
|
ngAfterViewInit() {
|
|
this.setupHeaderOffsetTracking();
|
|
if (this.pendingToastCheck) {
|
|
this.pendingToastCheck = false;
|
|
void this.maybeShowVigenciaToast();
|
|
}
|
|
}
|
|
|
|
private ensureNotificationsLoaded() {
|
|
if (this.notificationsLoaded || this.notificationsLoading) return;
|
|
this.loadNotifications(true);
|
|
}
|
|
|
|
private loadNotifications(showToast = true) {
|
|
if (!isPlatformBrowser(this.platformId)) return;
|
|
if (this.notificationsLoading) return;
|
|
this.notificationsLoading = true;
|
|
this.notificationsError = false;
|
|
this.notificationsService.list().subscribe({
|
|
next: (data) => {
|
|
this.notifications = data || [];
|
|
this.notificationsLoaded = true;
|
|
this.notificationsLoading = false;
|
|
this.lastNotificationsLoadAt = Date.now();
|
|
if (showToast) {
|
|
void this.maybeShowVigenciaToast();
|
|
}
|
|
},
|
|
error: () => {
|
|
this.notificationsError = true;
|
|
this.notificationsLoading = false;
|
|
},
|
|
});
|
|
}
|
|
|
|
private async maybeShowVigenciaToast() {
|
|
if (!isPlatformBrowser(this.platformId)) return;
|
|
if (!this.notifToast) {
|
|
this.pendingToastCheck = true;
|
|
return;
|
|
}
|
|
|
|
const pending = this.getPendingVigenciaToast();
|
|
if (!pending) return;
|
|
|
|
const bs = await import('bootstrap');
|
|
const toast = bs.Toast.getOrCreateInstance(this.notifToast.nativeElement, { autohide: false });
|
|
toast.show();
|
|
}
|
|
|
|
get toastNotification() {
|
|
return this.getPendingVigenciaToast();
|
|
}
|
|
|
|
private getPendingVigenciaToast() {
|
|
const acknowledged = this.getAcknowledgedIds();
|
|
return this.notifications.find(
|
|
n =>
|
|
!n.lida &&
|
|
this.getNotificationTipo(n) === 'AVencer' &&
|
|
this.getNotificationDaysToExpire(n) === 5 &&
|
|
!acknowledged.has(n.id)
|
|
);
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.headerResizeObserver?.disconnect();
|
|
this.headerResizeObserver = undefined;
|
|
this.headerTransitionEndCleanup?.();
|
|
this.headerTransitionEndCleanup = null;
|
|
if (this.manageUsersFeedbackTimer) {
|
|
clearTimeout(this.manageUsersFeedbackTimer);
|
|
this.manageUsersFeedbackTimer = null;
|
|
}
|
|
this.subs.unsubscribe();
|
|
}
|
|
|
|
private parseDateOnly(raw?: string | null): Date | null {
|
|
if (!raw) return null;
|
|
const datePart = raw.split('T')[0];
|
|
const parts = datePart.split('-');
|
|
if (parts.length === 3) {
|
|
const year = Number(parts[0]);
|
|
const month = Number(parts[1]);
|
|
const day = Number(parts[2]);
|
|
if (Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)) {
|
|
return new Date(year, month - 1, day);
|
|
}
|
|
}
|
|
|
|
const fallback = new Date(raw);
|
|
if (Number.isNaN(fallback.getTime())) return null;
|
|
return this.startOfDay(fallback);
|
|
}
|
|
|
|
private startOfDay(date: Date): Date {
|
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
}
|
|
|
|
private getAcknowledgedIds() {
|
|
if (!isPlatformBrowser(this.platformId)) return new Set<string>();
|
|
try {
|
|
const raw = localStorage.getItem('vigenciaAcknowledgedIds');
|
|
const ids = raw ? (JSON.parse(raw) as string[]) : [];
|
|
return new Set(ids);
|
|
} catch {
|
|
return new Set<string>();
|
|
}
|
|
}
|
|
|
|
submitCreateUser() {
|
|
if (this.createUserSubmitting) return;
|
|
if (this.createUserForm.invalid) {
|
|
this.createUserForm.markAllAsTouched();
|
|
return;
|
|
}
|
|
if (!this.isSysAdmin) {
|
|
this.createUserForbidden = true;
|
|
return;
|
|
}
|
|
|
|
this.createUserSubmitting = true;
|
|
this.setCreateFormDisabled(true);
|
|
this.createUserErrors = [];
|
|
this.createUserForbidden = false;
|
|
this.createUserSuccess = '';
|
|
|
|
const payload = this.createUserForm.value as CreateUserPayload;
|
|
this.usersService.create(payload).subscribe({
|
|
next: (created) => {
|
|
this.createUserSubmitting = false;
|
|
this.setCreateFormDisabled(false);
|
|
this.createUserSuccess = `Usuario ${created.nome} criado com sucesso.`;
|
|
this.createUserForm.reset({ permissao: '' });
|
|
},
|
|
error: (err: HttpErrorResponse) => {
|
|
this.createUserSubmitting = false;
|
|
this.setCreateFormDisabled(false);
|
|
if (err.status === 401 || err.status === 403) {
|
|
this.createUserForbidden = true;
|
|
return;
|
|
}
|
|
const apiErrors = err?.error?.errors;
|
|
if (Array.isArray(apiErrors)) {
|
|
this.createUserErrors = apiErrors.map((e: any) => ({
|
|
field: e?.field,
|
|
message: e?.message || 'Erro ao criar usuario.',
|
|
}));
|
|
} else {
|
|
this.createUserErrors = [{ message: 'Erro ao criar usuario.' }];
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
fetchManageUsers(goToPage?: number) {
|
|
if (goToPage) this.managePage = goToPage;
|
|
this.manageUsersLoading = true;
|
|
this.manageUsersErrors = [];
|
|
this.manageUsersSuccess = '';
|
|
|
|
this.usersService
|
|
.list({
|
|
search: this.manageSearch?.trim() || undefined,
|
|
permissao: this.isManageClientsMode ? 'cliente' : undefined,
|
|
page: this.managePage,
|
|
pageSize: this.managePageSize,
|
|
})
|
|
.subscribe({
|
|
next: (res) => {
|
|
this.manageUsers = res.items || [];
|
|
this.manageTotal = res.total || 0;
|
|
this.manageUsersLoading = false;
|
|
},
|
|
error: () => {
|
|
this.manageUsers = [];
|
|
this.manageTotal = 0;
|
|
this.manageUsersLoading = false;
|
|
},
|
|
});
|
|
}
|
|
|
|
onManageSearch() {
|
|
this.managePage = 1;
|
|
this.fetchManageUsers();
|
|
}
|
|
|
|
clearManageSearch() {
|
|
this.manageSearch = '';
|
|
this.managePage = 1;
|
|
this.fetchManageUsers();
|
|
}
|
|
|
|
manageGoToPage(p: number) {
|
|
this.managePage = p;
|
|
this.fetchManageUsers();
|
|
}
|
|
|
|
get manageTotalPages(): number {
|
|
return Math.max(1, Math.ceil((this.manageTotal || 0) / (this.managePageSize || 10)));
|
|
}
|
|
|
|
get managePageNumbers(): number[] {
|
|
const total = this.manageTotalPages;
|
|
const current = this.managePage;
|
|
const max = 5;
|
|
let start = Math.max(1, current - 2);
|
|
let end = Math.min(total, start + (max - 1));
|
|
start = Math.max(1, end - (max - 1));
|
|
const pages: number[] = [];
|
|
for (let i = start; i <= end; i++) pages.push(i);
|
|
return pages;
|
|
}
|
|
|
|
openEditUser(user: any) {
|
|
this.editUserTarget = null;
|
|
this.editUserErrors = [];
|
|
this.editUserSuccess = '';
|
|
this.editUserSubmitting = false;
|
|
this.setEditFormDisabled(false);
|
|
this.editUserForm.reset({ nome: '', email: '', senha: '', confirmarSenha: '', permissao: '', ativo: true });
|
|
|
|
this.usersService.getById(user.id).subscribe({
|
|
next: (full) => {
|
|
this.editUserTarget = full;
|
|
const permissao = this.isManageClientsMode ? 'cliente' : (full.permissao ?? '');
|
|
this.editUserForm.reset({
|
|
nome: full.nome ?? '',
|
|
email: full.email ?? '',
|
|
senha: '',
|
|
confirmarSenha: '',
|
|
permissao,
|
|
ativo: full.ativo ?? true,
|
|
});
|
|
if (this.isManageClientsMode) {
|
|
this.editUserForm.get('permissao')?.disable({ emitEvent: false });
|
|
} else {
|
|
this.editUserForm.get('permissao')?.enable({ emitEvent: false });
|
|
}
|
|
},
|
|
error: () => {
|
|
this.editUserErrors = [{ message: this.isManageClientsMode ? 'Erro ao carregar credencial do cliente.' : 'Erro ao carregar usuário.' }];
|
|
},
|
|
});
|
|
}
|
|
|
|
cancelEditUser() {
|
|
this.editUserTarget = null;
|
|
this.editUserErrors = [];
|
|
this.editUserSuccess = '';
|
|
this.editUserSubmitting = false;
|
|
this.setEditFormDisabled(false);
|
|
this.editUserForm.reset({ nome: '', email: '', senha: '', confirmarSenha: '', permissao: '', ativo: true });
|
|
}
|
|
|
|
submitEditUser() {
|
|
if (this.editUserSubmitting || !this.editUserTarget) return;
|
|
|
|
const payload: any = {};
|
|
const nome = (this.editUserForm.get('nome')?.value || '').toString().trim();
|
|
const email = (this.editUserForm.get('email')?.value || '').toString().trim();
|
|
const permissao = this.isManageClientsMode
|
|
? 'cliente'
|
|
: (this.editUserForm.get('permissao')?.value || '').toString().trim();
|
|
const ativo = !!this.editUserForm.get('ativo')?.value;
|
|
|
|
if (nome && nome !== (this.editUserTarget.nome || '').trim()) payload.nome = nome;
|
|
if (email && email !== (this.editUserTarget.email || '').trim()) payload.email = email;
|
|
if (this.isManageClientsMode) {
|
|
const targetPermissao = String(this.editUserTarget.permissao || '').trim().toLowerCase();
|
|
if (targetPermissao !== 'cliente') {
|
|
payload.permissao = 'cliente';
|
|
}
|
|
} else if (permissao && permissao !== (this.editUserTarget.permissao || '').trim()) {
|
|
payload.permissao = permissao;
|
|
}
|
|
if ((this.editUserTarget.ativo ?? true) !== ativo) payload.ativo = ativo;
|
|
|
|
const senha = (this.editUserForm.get('senha')?.value || '').toString();
|
|
const confirmar = (this.editUserForm.get('confirmarSenha')?.value || '').toString();
|
|
if (senha || confirmar) {
|
|
if (!senha || !confirmar) {
|
|
this.editUserErrors = [{ message: 'Para alterar a senha, preencha senha e confirmacao.' }];
|
|
return;
|
|
}
|
|
if (senha.length < 6) {
|
|
this.editUserErrors = [{ message: 'Senha deve ter no minimo 6 caracteres.' }];
|
|
return;
|
|
}
|
|
if (senha !== confirmar) {
|
|
this.editUserErrors = [{ message: 'As senhas nao conferem.' }];
|
|
return;
|
|
}
|
|
payload.senha = senha;
|
|
payload.confirmarSenha = confirmar;
|
|
}
|
|
|
|
if (Object.keys(payload).length === 0) {
|
|
this.editUserErrors = [{ message: 'Nenhuma alteracao detectada.' }];
|
|
return;
|
|
}
|
|
|
|
this.editUserSubmitting = true;
|
|
this.setEditFormDisabled(true);
|
|
this.editUserErrors = [];
|
|
this.editUserSuccess = '';
|
|
|
|
const currentTarget = { ...this.editUserTarget };
|
|
|
|
this.usersService.update(this.editUserTarget.id, payload).subscribe({
|
|
next: () => {
|
|
const merged = this.mergeUserUpdate(currentTarget, payload);
|
|
this.editUserSubmitting = false;
|
|
this.setEditFormDisabled(false);
|
|
this.editUserSuccess = this.isManageClientsMode
|
|
? `Credencial de ${merged.nome} atualizada com sucesso.`
|
|
: `Usuario ${merged.nome} atualizado com sucesso.`;
|
|
this.editUserTarget = merged;
|
|
this.editUserForm.patchValue({
|
|
nome: merged.nome ?? '',
|
|
email: merged.email ?? '',
|
|
permissao: this.isManageClientsMode ? 'cliente' : (merged.permissao ?? ''),
|
|
ativo: merged.ativo ?? true,
|
|
senha: '',
|
|
confirmarSenha: '',
|
|
});
|
|
this.upsertManageUser(merged);
|
|
this.showManageUsersFeedback(
|
|
this.isManageClientsMode
|
|
? `Credencial de ${merged.nome} atualizada com sucesso.`
|
|
: `Usuario ${merged.nome} atualizado com sucesso.`,
|
|
'success'
|
|
);
|
|
},
|
|
error: (err: HttpErrorResponse) => {
|
|
this.editUserSubmitting = false;
|
|
this.setEditFormDisabled(false);
|
|
const apiErrors = err?.error?.errors;
|
|
if (Array.isArray(apiErrors)) {
|
|
this.editUserErrors = apiErrors.map((e: any) => ({
|
|
field: e?.field,
|
|
message: e?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.'),
|
|
}));
|
|
} else {
|
|
this.editUserErrors = [{
|
|
message: err?.error?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.')
|
|
}];
|
|
}
|
|
this.showManageUsersFeedback(
|
|
this.editUserErrors[0]?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.'),
|
|
'error'
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
async confirmToggleUserStatus(user: any) {
|
|
const nextActive = user.ativo === false;
|
|
const actionLabel = nextActive ? 'reativar' : 'inativar';
|
|
const entity = this.isManageClientsMode ? 'Credencial do Cliente' : 'Usuário';
|
|
const entityLower = this.isManageClientsMode ? 'credencial do cliente' : 'usuário';
|
|
const confirmed = await confirmActionModal({
|
|
title: nextActive ? `Reativar ${entity}` : `Inativar ${entity}`,
|
|
message: nextActive
|
|
? `Deseja reativar ${entityLower} ${user.nome}? O acesso ao sistema será liberado novamente.`
|
|
: `Deseja inativar ${entityLower} ${user.nome}? O acesso ao sistema ficará bloqueado até reativação.`,
|
|
confirmLabel: nextActive ? 'Reativar' : 'Inativar',
|
|
tone: nextActive ? 'neutral' : 'warning',
|
|
});
|
|
if (!confirmed) return;
|
|
|
|
this.usersService.update(user.id, { ativo: nextActive }).subscribe({
|
|
next: () => {
|
|
const updated = this.mergeUserUpdate(user, { ativo: nextActive });
|
|
this.upsertManageUser(updated);
|
|
if (this.editUserTarget?.id === user.id) {
|
|
this.editUserTarget = { ...this.editUserTarget, ativo: nextActive };
|
|
this.editUserForm.patchValue({ ativo: nextActive, senha: '', confirmarSenha: '' });
|
|
this.editUserErrors = [];
|
|
this.editUserSuccess = this.isManageClientsMode
|
|
? `Credencial de ${user.nome} ${nextActive ? 'reativada' : 'inativada'} com sucesso.`
|
|
: `Usuario ${user.nome} ${nextActive ? 'reativado' : 'inativado'} com sucesso.`;
|
|
}
|
|
},
|
|
error: (err: HttpErrorResponse) => {
|
|
const message = err?.error?.message || `Erro ao ${actionLabel} ${this.isManageClientsMode ? 'credencial do cliente' : 'usuario'}.`;
|
|
if (this.editUserTarget?.id === user.id) {
|
|
this.editUserSuccess = '';
|
|
this.editUserErrors = [{ message }];
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
async confirmPermanentDeleteUser(user: any) {
|
|
if (user?.ativo !== false) {
|
|
const message = this.isManageClientsMode
|
|
? 'Inative a credencial antes de excluir permanentemente.'
|
|
: 'Inative a conta antes de excluir permanentemente.';
|
|
if (this.editUserTarget?.id === user?.id) {
|
|
this.editUserSuccess = '';
|
|
this.editUserErrors = [{ message }];
|
|
} else {
|
|
await showDeletionWarning(message, 'Exclusão não permitida');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const confirmed = await confirmDeletionWithTyping(
|
|
this.isManageClientsMode ? `a credencial do cliente ${user.nome}` : `o usuário ${user.nome}`
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
this.usersService.delete(user.id).subscribe({
|
|
next: () => {
|
|
this.removeManageUser(user.id);
|
|
if (this.editUserTarget?.id === user.id) {
|
|
this.cancelEditUser();
|
|
}
|
|
},
|
|
error: (err: HttpErrorResponse) => {
|
|
const apiErrors = err?.error?.errors;
|
|
const message = Array.isArray(apiErrors)
|
|
? (apiErrors[0]?.message || (this.isManageClientsMode ? 'Erro ao excluir credencial do cliente.' : 'Erro ao excluir usuario.'))
|
|
: (err?.error?.message || (this.isManageClientsMode ? 'Erro ao excluir credencial do cliente.' : 'Erro ao excluir usuario.'));
|
|
|
|
if (this.editUserTarget?.id === user.id) {
|
|
this.editUserSuccess = '';
|
|
this.editUserErrors = [{ message }];
|
|
return;
|
|
}
|
|
|
|
void showDeletionWarning(message, 'Falha na exclusão');
|
|
},
|
|
});
|
|
}
|
|
|
|
hasFieldError(field: string): boolean {
|
|
return this.getFieldErrors(field).length > 0;
|
|
}
|
|
|
|
getFieldErrors(field: string): string[] {
|
|
const key = this.normalizeField(field);
|
|
return this.createUserErrors
|
|
.filter((e) => this.normalizeField(e.field) === key)
|
|
.map((e) => e.message);
|
|
}
|
|
|
|
get passwordMismatch(): boolean {
|
|
return !!this.createUserForm.errors?.['passwordsMismatch'];
|
|
}
|
|
|
|
private resetCreateUserState() {
|
|
this.createUserErrors = [];
|
|
this.createUserForbidden = false;
|
|
this.createUserSuccess = '';
|
|
this.createUserSubmitting = false;
|
|
this.setCreateFormDisabled(false);
|
|
this.createUserForm.reset({ permissao: '' });
|
|
}
|
|
|
|
private resetManageUsersState() {
|
|
if (this.manageUsersFeedbackTimer) {
|
|
clearTimeout(this.manageUsersFeedbackTimer);
|
|
this.manageUsersFeedbackTimer = null;
|
|
}
|
|
this.manageUsersErrors = [];
|
|
this.manageUsersSuccess = '';
|
|
this.manageUsersLoading = false;
|
|
this.manageUsers = [];
|
|
this.manageSearch = '';
|
|
this.managePage = 1;
|
|
this.manageTotal = 0;
|
|
this.cancelEditUser();
|
|
}
|
|
|
|
get isManageClientsMode(): boolean {
|
|
return this.manageMode === 'clients';
|
|
}
|
|
|
|
get manageModalTitle(): string {
|
|
return this.isManageClientsMode ? 'Credenciais de Clientes' : 'Gestão de Usuários';
|
|
}
|
|
|
|
get manageListTitle(): string {
|
|
return this.isManageClientsMode ? 'Credenciais de Cliente' : 'Usuários';
|
|
}
|
|
|
|
get manageSearchPlaceholder(): string {
|
|
return this.isManageClientsMode
|
|
? 'Buscar por cliente, nome ou email...'
|
|
: 'Buscar por nome ou email...';
|
|
}
|
|
|
|
get editPermissionOptions() {
|
|
return this.isManageClientsMode
|
|
? [{ value: 'cliente', label: 'Cliente' }]
|
|
: this.permissionOptions;
|
|
}
|
|
|
|
private normalizeField(field?: string | null): string {
|
|
return (field || '').trim().toLowerCase();
|
|
}
|
|
|
|
private setCreateFormDisabled(disabled: boolean) {
|
|
if (disabled) this.createUserForm.disable({ emitEvent: false });
|
|
else this.createUserForm.enable({ emitEvent: false });
|
|
}
|
|
|
|
private setEditFormDisabled(disabled: boolean) {
|
|
if (disabled) this.editUserForm.disable({ emitEvent: false });
|
|
else {
|
|
this.editUserForm.enable({ emitEvent: false });
|
|
if (this.isManageClientsMode) {
|
|
this.editUserForm.get('permissao')?.disable({ emitEvent: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
private upsertManageUser(user: any) {
|
|
this.manageUsers = this.manageUsers.map((item) =>
|
|
item.id === user.id ? { ...item, ...user } : item
|
|
);
|
|
}
|
|
|
|
private removeManageUser(userId: string) {
|
|
const prevLength = this.manageUsers.length;
|
|
this.manageUsers = this.manageUsers.filter((item) => item.id !== userId);
|
|
|
|
if (this.manageUsers.length !== prevLength && this.manageTotal > 0) {
|
|
this.manageTotal = Math.max(0, this.manageTotal - 1);
|
|
}
|
|
|
|
if (!this.manageUsers.length && this.managePage > 1) {
|
|
this.fetchManageUsers(this.managePage - 1);
|
|
}
|
|
}
|
|
|
|
private mergeUserUpdate(current: any, payload: any) {
|
|
return {
|
|
...current,
|
|
nome: payload.nome ?? current.nome,
|
|
email: payload.email ? String(payload.email).trim().toLowerCase() : current.email,
|
|
permissao: payload.permissao ?? current.permissao,
|
|
ativo: payload.ativo ?? current.ativo,
|
|
};
|
|
}
|
|
|
|
private showManageUsersFeedback(message: string, type: 'success' | 'error') {
|
|
if (this.manageUsersFeedbackTimer) {
|
|
clearTimeout(this.manageUsersFeedbackTimer);
|
|
this.manageUsersFeedbackTimer = null;
|
|
}
|
|
|
|
if (type === 'success') {
|
|
this.manageUsersErrors = [];
|
|
this.manageUsersSuccess = message;
|
|
} else {
|
|
this.manageUsersSuccess = '';
|
|
this.manageUsersErrors = [{ message }];
|
|
}
|
|
|
|
this.manageUsersFeedbackTimer = setTimeout(() => {
|
|
this.manageUsersSuccess = '';
|
|
this.manageUsersErrors = [];
|
|
this.manageUsersFeedbackTimer = null;
|
|
}, 3000);
|
|
}
|
|
|
|
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
|
|
const senha = group.get('senha')?.value;
|
|
const confirmar = group.get('confirmarSenha')?.value;
|
|
if (!senha || !confirmar) return null;
|
|
return senha === confirmar ? null : { passwordsMismatch: true };
|
|
}
|
|
|
|
private setupHeaderOffsetTracking() {
|
|
if (!isPlatformBrowser(this.platformId)) return;
|
|
|
|
this.syncHeaderOffsetCssVar();
|
|
|
|
const headerEl = this.getHeaderElement();
|
|
if (!headerEl || typeof ResizeObserver === 'undefined') return;
|
|
|
|
this.headerResizeObserver?.disconnect();
|
|
this.headerResizeObserver = new ResizeObserver(() => {
|
|
this.syncHeaderOffsetCssVar();
|
|
});
|
|
this.headerResizeObserver.observe(headerEl);
|
|
|
|
// Garante sincronismo do offset após a animação de "scrolled" do header.
|
|
this.headerTransitionEndCleanup?.();
|
|
const onTransitionEnd = (ev: TransitionEvent) => {
|
|
if (ev.target !== headerEl) return;
|
|
if (ev.propertyName && ev.propertyName !== 'padding-top' && ev.propertyName !== 'padding-bottom') {
|
|
return;
|
|
}
|
|
this.syncHeaderOffsetCssVar();
|
|
};
|
|
headerEl.addEventListener('transitionend', onTransitionEnd);
|
|
this.headerTransitionEndCleanup = () => headerEl.removeEventListener('transitionend', onTransitionEnd);
|
|
}
|
|
|
|
private scheduleHeaderOffsetSync() {
|
|
if (!isPlatformBrowser(this.platformId)) return;
|
|
requestAnimationFrame(() => this.syncHeaderOffsetCssVar());
|
|
}
|
|
|
|
private syncHeaderOffsetCssVar() {
|
|
if (!isPlatformBrowser(this.platformId)) return;
|
|
const headerEl = this.getHeaderElement();
|
|
if (!headerEl) return;
|
|
|
|
const height = Math.ceil(headerEl.getBoundingClientRect().height || headerEl.offsetHeight || 0);
|
|
if (height > 0) {
|
|
document.documentElement.style.setProperty('--app-header-offset', `${height}px`);
|
|
}
|
|
}
|
|
|
|
private getHeaderElement(): HTMLElement | null {
|
|
return this.hostElement.nativeElement.querySelector('.app-header');
|
|
}
|
|
|
|
private ensureClientTenantName() {
|
|
const profile = this.authService.currentUserProfile;
|
|
const tenantId = String(profile?.tenantId || '').trim();
|
|
|
|
if (!tenantId) {
|
|
this.clientTenantDisplayName = '';
|
|
this.clientTenantNameTenantId = null;
|
|
return;
|
|
}
|
|
|
|
if (this.clientTenantNameTenantId === tenantId && this.clientTenantDisplayName) {
|
|
return;
|
|
}
|
|
|
|
this.clientTenantNameTenantId = tenantId;
|
|
this.clientTenantDisplayName = '';
|
|
|
|
this.http.get<string[]>(`${this.baseApi}/lines/clients`).subscribe({
|
|
next: (clients) => {
|
|
const list = (clients || [])
|
|
.map((x) => String(x ?? '').trim())
|
|
.filter((x) => !!x && x.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) !== 0);
|
|
|
|
this.clientTenantDisplayName = list[0] || this.resolveFallbackClientTenantName();
|
|
},
|
|
error: () => {
|
|
this.clientTenantDisplayName = this.resolveFallbackClientTenantName();
|
|
},
|
|
});
|
|
}
|
|
|
|
private resolveFallbackClientTenantName(): string {
|
|
const profileName = String(this.authService.currentUserProfile?.nome || '').trim();
|
|
if (profileName) return profileName;
|
|
return 'Cliente';
|
|
}
|
|
|
|
private abbreviateClientTenantName(value: string): string {
|
|
const name = (value || '').trim();
|
|
if (!name) return 'Cliente';
|
|
|
|
const maxLen = 30;
|
|
if (name.length <= maxLen) return name;
|
|
|
|
const parts = name.split(/\s+/).filter(Boolean);
|
|
if (parts.length === 1) {
|
|
return `${parts[0].slice(0, maxLen - 1)}…`;
|
|
}
|
|
|
|
const first = parts[0];
|
|
const last = parts[parts.length - 1];
|
|
let candidate = `${first} ${last}`;
|
|
|
|
if (candidate.length <= maxLen) return candidate;
|
|
|
|
const shortFirst = `${first.slice(0, Math.max(3, maxLen - (last.length + 3)))}.`;
|
|
candidate = `${shortFirst} ${last}`;
|
|
if (candidate.length <= maxLen) return candidate;
|
|
|
|
return `${name.slice(0, maxLen - 1)}…`;
|
|
}
|
|
}
|