From ec3abc056fc537a0c4cc37fd55436448b989b0d5 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Wed, 25 Feb 2026 11:34:51 -0300 Subject: [PATCH] =?UTF-8?q?Adi=C3=A7=C3=A3o=20Lote=20de=20Linhas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/header/header.scss | 203 +++- .../pages/geral/batch-mass-input.util.spec.ts | 156 +++ src/app/pages/geral/batch-mass-input.util.ts | 438 ++++++++ src/app/pages/geral/geral.html | 767 +++++++++++++- src/app/pages/geral/geral.scss | 79 ++ src/app/pages/geral/geral.spec.ts | 45 + src/app/pages/geral/geral.ts | 967 ++++++++++++++++-- src/app/pages/notificacoes/notificacoes.scss | 388 +++++++ 8 files changed, 2944 insertions(+), 99 deletions(-) create mode 100644 src/app/pages/geral/batch-mass-input.util.spec.ts create mode 100644 src/app/pages/geral/batch-mass-input.util.ts diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index 264ec7f..313a733 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -55,10 +55,11 @@ $border-color: #e5e7eb; display: inline-flex; align-items: center; gap: 6px; color: $text-main; text-decoration: none; font-weight: 600; font-size: 14px; transition: color 0.2s; &:hover { color: $primary; } } -.header-actions { display: flex; align-items: center; } +.header-actions { display: flex; align-items: center; margin-left: auto; justify-content: flex-end; flex: 0 0 auto; } .btn-login-header { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99px; border: 1px solid rgba(28, 56, 201, 0.18); background: #fff; color: $primary; font-weight: 700; font-size: 13px; text-decoration: none; transition: all 0.2s; + white-space: nowrap; &:hover { transform: translateY(-1px); background: rgba(28, 56, 201, 0.04); box-shadow: 0 4px 12px rgba(28, 56, 201, 0.15); } } @@ -739,6 +740,7 @@ $border-color: #e5e7eb; .header-inner { gap: 8px; + flex-wrap: nowrap; } .logged-header { @@ -780,6 +782,36 @@ $border-color: #e5e7eb; } } + /* Header público (Home/Login/Register): mantém logo visível e CTA fixo à direita */ + .header-inner > .logo-area { + flex: 1 1 auto; + min-width: 0; + } + + .header-inner > .logo-area .logo-text { + display: block; + font-size: 14px; + line-height: 1; + white-space: nowrap; + } + + .header-inner > .header-actions { + margin-left: auto; + flex: 0 0 auto; + justify-content: flex-end; + } + + .header-inner > .header-actions .btn-login-header { + padding: 7px 10px; + gap: 4px; + font-size: 12px; + } + + /* Header logado: mantém nome visível, porém menor para smartphone */ + .left-logged .logo-area .logo-text { + font-size: 13px; + } + .logged-actions { gap: 6px; } @@ -809,10 +841,23 @@ $border-color: #e5e7eb; position: fixed; top: calc(var(--app-header-offset, 76px) + 8px); right: 8px; - width: min(260px, calc(100vw - 16px)); + width: min(228px, calc(100vw - 16px)); + padding: 4px; + border-radius: 12px; z-index: 1250; } + .options-dropdown .options-item { + padding: 8px 10px; + font-size: 12px; + gap: 8px; + border-radius: 7px; + } + + .options-dropdown .divider { + margin: 3px 0; + } + .notifications-head { padding: 12px; flex-wrap: wrap; @@ -912,6 +957,20 @@ $border-color: #e5e7eb; border-radius: 14px; } + .modal-actions { + padding: 12px 14px; + gap: 8px; + } + + .modal-actions .btn-primary, + .modal-actions .btn-secondary { + min-height: 40px; + height: 40px; + padding: 0 12px; + font-size: 13px; + border-radius: 10px; + } + .modal-card.manage-users-modal { width: calc(100vw - 12px); height: min(calc(100dvh - 12px), 680px); @@ -928,6 +987,20 @@ $border-color: #e5e7eb; max-height: 38vh; } + .manage-search { + padding: 10px 12px; + } + + .manage-search .search-input-wrapper input { + height: 34px; + font-size: 12px; + } + + .manage-search .search-input-wrapper i { + left: 10px; + font-size: 13px; + } + .manage-right-wrapper { height: auto; min-height: 0; @@ -937,6 +1010,58 @@ $border-color: #e5e7eb; padding: 14px; } + .manage-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .manage-table { + min-width: 560px; + } + + .manage-table thead th { + padding: 8px 10px; + font-size: 10px; + letter-spacing: 0.35px; + } + + .manage-table tbody tr td { + padding: 9px 10px; + } + + .user-cell { + gap: 8px; + } + + .user-cell .avatar-mini { + width: 28px; + height: 28px; + min-width: 28px; + font-size: 11px; + } + + .user-cell .user-info .u-name { + font-size: 12px; + } + + .user-cell .user-info .u-email { + font-size: 10px; + } + + .badge-role { + padding: 2px 6px; + font-size: 9px; + } + + .actions-group { + gap: 3px; + } + + .actions-group .btn-action { + width: 26px; + height: 26px; + } + .edit-header-info { gap: 10px; margin-bottom: 14px; @@ -983,8 +1108,36 @@ $border-color: #e5e7eb; } @media (max-width: 420px) { - .logo-area .logo-text { - display: none; + .header-inner > .logo-area { + gap: 6px; + } + + .header-inner > .logo-area .logo-icon { + width: 30px; + height: 30px; + font-size: 15px; + } + + .header-inner > .logo-area .logo-text { + display: block; + font-size: 13px; + letter-spacing: -0.25px; + } + + .header-inner > .header-actions .btn-login-header { + padding: 6px 8px; + font-size: 11px; + gap: 3px; + } + + .left-logged .logo-area { + gap: 6px; + } + + .left-logged .logo-area .logo-text { + display: block; + font-size: 12px; + letter-spacing: -0.2px; } .logged-actions { @@ -1006,6 +1159,48 @@ $border-color: #e5e7eb; .options-dropdown { right: 6px; top: calc(var(--app-header-offset, 76px) + 6px); + width: min(216px, calc(100vw - 12px)); + } + + .options-dropdown .options-item { + padding: 7px 9px; + font-size: 11px; + gap: 7px; + } + + .modal-actions { + padding: 10px 12px; + } + + .modal-actions .btn-primary, + .modal-actions .btn-secondary { + min-height: 40px; + height: 40px; + font-size: 12px; + } + + .manage-table { + min-width: 520px; + } + + .manage-table thead th { + padding: 7px 8px; + font-size: 9px; + } + + .manage-table tbody tr td { + padding: 8px; + } + + .user-cell .avatar-mini { + width: 26px; + height: 26px; + min-width: 26px; + } + + .actions-group .btn-action { + width: 24px; + height: 24px; } .notifications-head { diff --git a/src/app/pages/geral/batch-mass-input.util.spec.ts b/src/app/pages/geral/batch-mass-input.util.spec.ts new file mode 100644 index 0000000..fd7e789 --- /dev/null +++ b/src/app/pages/geral/batch-mass-input.util.spec.ts @@ -0,0 +1,156 @@ +import { buildBatchMassExampleText, buildBatchMassHeaderLine, buildBatchMassPreview, mergeMassRows } from './batch-mass-input.util'; + +describe('batch-mass-input.util', () => { + it('parses rows separated by semicolon', () => { + const preview = buildBatchMassPreview( + '11999999999;8955000000000000001;Usuario 1;eSIM;PLANO A;ATIVO;EMPRESA A;CONTA A;2026-01-01;2027-01-01' + ); + + expect(preview.separator).toBe('SEMICOLON'); + expect(preview.recognizedRows).toBe(1); + expect(preview.rows[0].data['linha']).toBe('11999999999'); + expect(preview.rows[0].data['chip']).toBe('8955000000000000001'); + expect(preview.rows[0].data['planoContrato']).toBe('PLANO A'); + expect(preview.rows[0].errors).toEqual([]); + }); + + it('parses rows separated by TAB', () => { + const preview = buildBatchMassPreview( + '11999999999\t8955000000000000001\tUsuario 1\teSIM\tPLANO A\tATIVO\tEMPRESA A\tCONTA A\t2026-01-01\t2027-01-01' + ); + + expect(preview.separator).toBe('TAB'); + expect(preview.recognizedRows).toBe(1); + expect(preview.rows[0].data['usuario']).toBe('Usuario 1'); + }); + + it('parses rows separated by pipe', () => { + const preview = buildBatchMassPreview( + '11999999999|8955000000000000001|Usuario 1|eSIM|PLANO A|ATIVO|EMPRESA A|CONTA A|2026-01-01|2027-01-01' + ); + + expect(preview.separator).toBe('PIPE'); + expect(preview.recognizedRows).toBe(1); + expect(preview.rows[0].data['tipoDeChip']).toBe('eSIM'); + }); + + it('ignores empty lines', () => { + const preview = buildBatchMassPreview( + '\n\n11999999999;8955;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01\n\n' + ); + + expect(preview.recognizedRows).toBe(1); + }); + + it('detects and uses header row when present', () => { + const preview = buildBatchMassPreview( + [ + 'Linha;ICCID;Usuario;Tipo de Chip;Plano Contrato;Status;Empresa (Conta);Conta;Dt Efetivacao Servico;Dt Termino Fidelizacao', + '11999999999;8955;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;01/02/2026;01/02/2027' + ].join('\n') + ); + + expect(preview.hasHeader).toBeTrue(); + expect(preview.recognizedRows).toBe(1); + expect(preview.rows[0].data['dtEfetivacaoServico']).toBe('2026-02-01'); + expect(preview.rows[0].data['dtTerminoFidelizacao']).toBe('2027-02-01'); + }); + + it('maps official header labels with parentheses (Chip (ICCID), Empresa (Conta))', () => { + const preview = buildBatchMassPreview( + [ + 'Linha;Chip (ICCID);Usuario;Tipo de Chip;Plano Contrato;Status;Empresa (Conta);Conta;Dt. Efetivação Serviço;Dt. Término Fidelização', + '11999999999;8955000000000000001;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01' + ].join('\n') + ); + + expect(preview.hasHeader).toBeTrue(); + expect(preview.recognizedRows).toBe(1); + expect(preview.rows[0].data['chip']).toBe('8955000000000000001'); + expect(preview.rows[0].data['contaEmpresa']).toBe('EMPRESA'); + expect(preview.rows[0].errors).toEqual([]); + }); + + it('fills missing columns with defaults when available', () => { + const preview = buildBatchMassPreview('11999999999;8955', { + defaults: { + planoContrato: 'PLANO PADRAO', + status: 'ATIVO', + contaEmpresa: 'EMPRESA A', + conta: 'CONTA A', + dtEfetivacaoServico: '2026-01-01', + dtTerminoFidelizacao: '2027-01-01' + } + }); + + expect(preview.rows[0].data['planoContrato']).toBe('PLANO PADRAO'); + expect(preview.rows[0].data['status']).toBe('ATIVO'); + expect(preview.rows[0].errors).toEqual([]); + }); + + it('uses row value instead of default when both exist', () => { + const preview = buildBatchMassPreview( + '11999999999;8955;U1;eSIM;PLANO LINHA;SUSPENSO;EMPRESA LINHA;CONTA LINHA;2026-05-01;2027-05-01', + { + defaults: { + planoContrato: 'PLANO PADRAO', + status: 'ATIVO', + contaEmpresa: 'EMPRESA PADRAO', + conta: 'CONTA PADRAO', + dtEfetivacaoServico: '2026-01-01', + dtTerminoFidelizacao: '2027-01-01' + } + } + ); + + expect(preview.rows[0].data['planoContrato']).toBe('PLANO LINHA'); + expect(preview.rows[0].data['status']).toBe('SUSPENSO'); + expect(preview.rows[0].data['contaEmpresa']).toBe('EMPRESA LINHA'); + }); + + it('marks row invalid when required fields are missing', () => { + const preview = buildBatchMassPreview('11999999999;8955'); + + expect(preview.invalidRows).toBe(1); + expect(preview.rows[0].errors).toContain('Plano Contrato obrigatorio.'); + expect(preview.rows[0].errors).toContain('Status obrigatorio.'); + expect(preview.rows[0].errors).toContain('Empresa (Conta) obrigatoria.'); + expect(preview.rows[0].errors).toContain('Conta obrigatoria.'); + expect(preview.rows[0].errors).toContain('Dt. Efetivacao Servico obrigatoria.'); + expect(preview.rows[0].errors).toContain('Dt. Termino Fidelizacao obrigatoria.'); + }); + + it('detects duplicate line numbers inside the batch', () => { + const text = [ + '11999999999;8955;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01', + '11 99999-9999;9999;U2;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01' + ].join('\n'); + + const preview = buildBatchMassPreview(text); + + expect(preview.duplicateRows).toBe(2); + expect(preview.rows[0].errors).toContain('Linha duplicada no lote.'); + expect(preview.rows[1].errors).toContain('Linha duplicada no lote.'); + }); + + it('mergeMassRows keeps existing rows when mode is ADD', () => { + const merged = mergeMassRows([{ linha: '1' }], [{ linha: '2' }, { linha: '3' }], 'ADD'); + + expect(merged.map((x) => x.linha)).toEqual(['1', '2', '3']); + }); + + it('mergeMassRows replaces existing rows when mode is REPLACE', () => { + const merged = mergeMassRows([{ linha: '1' }], [{ linha: '2' }, { linha: '3' }], 'REPLACE'); + + expect(merged.map((x) => x.linha)).toEqual(['2', '3']); + }); + + it('builds header and example using selected separator', () => { + const header = buildBatchMassHeaderLine('TAB'); + const example = buildBatchMassExampleText('PIPE', true); + + expect(header).toContain('\t'); + expect(example.split('\n')[0]).toContain('|'); + expect(example.split('\n').length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/app/pages/geral/batch-mass-input.util.ts b/src/app/pages/geral/batch-mass-input.util.ts new file mode 100644 index 0000000..9ba3c6b --- /dev/null +++ b/src/app/pages/geral/batch-mass-input.util.ts @@ -0,0 +1,438 @@ +export type BatchMassSeparatorMode = 'AUTO' | 'SEMICOLON' | 'TAB' | 'PIPE'; +export type BatchMassApplyMode = 'ADD' | 'REPLACE'; + +export interface BatchMassColumnGuideItem { + key: string; + label: string; + required: boolean; + canUseDefault: boolean; + note?: string; +} + +export interface BatchMassDefaults { + usuario?: string; + tipoDeChip?: string; + planoContrato?: string; + status?: string; + contaEmpresa?: string; + conta?: string; + dtEfetivacaoServico?: string; + dtTerminoFidelizacao?: string; +} + +export interface BatchMassPreviewRow { + sourceLineNumber: number; + rawLine: string; + values: string[]; + data: Record; + errors: string[]; +} + +export interface BatchMassPreviewResult { + separator: BatchMassSeparatorMode; + recognizedRows: number; + validRows: number; + invalidRows: number; + duplicateRows: number; + hasHeader: boolean; + rows: BatchMassPreviewRow[]; + parseErrors: string[]; +} + +const SEQUENCE_KEYS = [ + 'linha', + 'chip', + 'usuario', + 'tipoDeChip', + 'planoContrato', + 'status', + 'contaEmpresa', + 'conta', + 'dtEfetivacaoServico', + 'dtTerminoFidelizacao' +] as const; + +const SEQUENCE_LABELS: Record<(typeof SEQUENCE_KEYS)[number], string> = { + linha: 'Linha', + chip: 'Chip (ICCID)', + usuario: 'Usuário', + tipoDeChip: 'Tipo de Chip', + planoContrato: 'Plano Contrato', + status: 'Status', + contaEmpresa: 'Empresa (Conta)', + conta: 'Conta', + dtEfetivacaoServico: 'Dt. Efetivação Serviço', + dtTerminoFidelizacao: 'Dt. Término Fidelização' +}; + +export const BATCH_MASS_COLUMN_GUIDE: BatchMassColumnGuideItem[] = [ + { key: 'linha', label: 'Linha', required: true, canUseDefault: false, note: 'Número da linha (telefone).' }, + { key: 'chip', label: 'Chip (ICCID)', required: true, canUseDefault: false, note: 'ICCID da linha.' }, + { key: 'usuario', label: 'Usuário', required: false, canUseDefault: true, note: 'Pode vir por linha ou usar padrão.' }, + { key: 'tipoDeChip', label: 'Tipo de Chip', required: false, canUseDefault: true, note: 'Pode vir por linha ou usar padrão.' }, + { key: 'planoContrato', label: 'Plano Contrato', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' }, + { key: 'status', label: 'Status', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' }, + { key: 'contaEmpresa', label: 'Empresa (Conta)', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' }, + { key: 'conta', label: 'Conta', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' }, + { key: 'dtEfetivacaoServico', label: 'Dt. Efetivação Serviço', required: true, canUseDefault: true, note: 'Aceita YYYY-MM-DD ou DD/MM/YYYY.' }, + { key: 'dtTerminoFidelizacao', label: 'Dt. Término Fidelização', required: true, canUseDefault: true, note: 'Aceita YYYY-MM-DD ou DD/MM/YYYY.' } +]; + +const REQUIRED_KEYS = [ + 'linha', + 'chip', + 'planoContrato', + 'status', + 'contaEmpresa', + 'conta', + 'dtEfetivacaoServico', + 'dtTerminoFidelizacao' +] as const; + +const HEADER_ALIAS_TO_KEY: Array<[string, (typeof SEQUENCE_KEYS)[number]]> = [ + ['linha', 'linha'], + ['numero linha', 'linha'], + ['n linha', 'linha'], + ['telefone', 'linha'], + ['chip', 'chip'], + ['iccid', 'chip'], + ['chip iccid', 'chip'], + ['usuario', 'usuario'], + ['usuario da linha', 'usuario'], + ['tipo de chip', 'tipoDeChip'], + ['tipodechip', 'tipoDeChip'], + ['plano contrato', 'planoContrato'], + ['plano', 'planoContrato'], + ['status', 'status'], + ['empresa conta', 'contaEmpresa'], + ['empresa', 'contaEmpresa'], + ['conta empresa', 'contaEmpresa'], + ['conta', 'conta'], + ['dt efetivacao servico', 'dtEfetivacaoServico'], + ['data efetivacao servico', 'dtEfetivacaoServico'], + ['dt efetivacao', 'dtEfetivacaoServico'], + ['efetivacao', 'dtEfetivacaoServico'], + ['dt termino fidelizacao', 'dtTerminoFidelizacao'], + ['data termino fidelizacao', 'dtTerminoFidelizacao'], + ['termino fidelizacao', 'dtTerminoFidelizacao'], + ['fidelizacao', 'dtTerminoFidelizacao'] +]; + +function stripAccents(value: string): string { + return value.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +} + +function normalizeHeaderCell(value: string): string { + return stripAccents((value ?? '').toString()) + .toLowerCase() + // Normalize punctuation like parentheses so headers such as + // "Chip (ICCID)" and "Empresa (Conta)" match aliases. + .replace(/[^a-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function getSeparatorChar(mode: Exclude): string { + if (mode === 'SEMICOLON') return ';'; + if (mode === 'TAB') return '\t'; + return '|'; +} + +function getEffectiveSeparatorForTemplate(mode: BatchMassSeparatorMode): Exclude { + return mode === 'AUTO' ? 'SEMICOLON' : mode; +} + +function detectSeparator(text: string): Exclude { + const lines = text.split(/\r?\n/).map((x) => x.trim()).filter(Boolean); + const first = lines[0] ?? ''; + const counts = { + TAB: (first.match(/\t/g) ?? []).length, + SEMICOLON: (first.match(/;/g) ?? []).length, + PIPE: (first.match(/\|/g) ?? []).length + } as const; + + const entries = Object.entries(counts) as Array<[Exclude, number]>; + entries.sort((a, b) => b[1] - a[1]); + return entries[0]?.[1] ? entries[0][0] : 'SEMICOLON'; +} + +function splitBySeparator(line: string, mode: BatchMassSeparatorMode): string[] { + const effective: Exclude = mode === 'AUTO' ? detectSeparator(line) : mode; + const sepChar = getSeparatorChar(effective); + return line + .split(sepChar) + .map((x) => x.trim()) + .map((x) => (x === '""' ? '' : x)); +} + +function resolveHeaderKey(cell: string): (typeof SEQUENCE_KEYS)[number] | null { + const normalized = normalizeHeaderCell(cell); + if (!normalized) return null; + + const alias = HEADER_ALIAS_TO_KEY.find(([name]) => name === normalized); + return alias?.[1] ?? null; +} + +function maybeNormalizeDate(value: string): string { + const raw = (value ?? '').toString().trim(); + if (!raw) return ''; + + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw; + if (/^\d{4}\/\d{2}\/\d{2}$/.test(raw)) return raw.replace(/\//g, '-'); + + const m = raw.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{4})$/); + if (m) { + const day = m[1].padStart(2, '0'); + const month = m[2].padStart(2, '0'); + const year = m[3]; + return `${year}-${month}-${day}`; + } + + return raw; +} + +function normalizeRowData(data: Record): Record { + const next = { ...data }; + next['linha'] = (next['linha'] ?? '').toString().trim(); + next['chip'] = (next['chip'] ?? '').toString().trim(); + next['usuario'] = (next['usuario'] ?? '').toString().trim(); + next['tipoDeChip'] = (next['tipoDeChip'] ?? '').toString().trim(); + next['planoContrato'] = (next['planoContrato'] ?? '').toString().trim(); + next['status'] = (next['status'] ?? '').toString().trim(); + next['contaEmpresa'] = (next['contaEmpresa'] ?? '').toString().trim(); + next['conta'] = (next['conta'] ?? '').toString().trim(); + next['dtEfetivacaoServico'] = maybeNormalizeDate(next['dtEfetivacaoServico'] ?? ''); + next['dtTerminoFidelizacao'] = maybeNormalizeDate(next['dtTerminoFidelizacao'] ?? ''); + return next; +} + +function parseHeaderMap(values: string[]): Map { + const map = new Map(); + values.forEach((cell, idx) => { + const key = resolveHeaderKey(cell); + if (key) map.set(idx, key); + }); + return map; +} + +function looksLikeDataRow(values: string[]): boolean { + const first = (values[0] ?? '').trim(); + const second = (values[1] ?? '').trim(); + const firstDigits = first.replace(/\D/g, ''); + const secondDigits = second.replace(/\D/g, ''); + + return firstDigits.length >= 8 || secondDigits.length >= 8; +} + +function buildDataFromValues( + values: string[], + defaults: BatchMassDefaults, + headerMap?: Map +): Record { + const base: Record = { + linha: '', + chip: '', + usuario: defaults.usuario?.toString().trim() ?? '', + tipoDeChip: defaults.tipoDeChip?.toString().trim() ?? '', + planoContrato: defaults.planoContrato?.toString().trim() ?? '', + status: defaults.status?.toString().trim() ?? '', + contaEmpresa: defaults.contaEmpresa?.toString().trim() ?? '', + conta: defaults.conta?.toString().trim() ?? '', + dtEfetivacaoServico: defaults.dtEfetivacaoServico?.toString().trim() ?? '', + dtTerminoFidelizacao: defaults.dtTerminoFidelizacao?.toString().trim() ?? '' + }; + + if (headerMap && headerMap.size > 0) { + values.forEach((value, idx) => { + const key = headerMap.get(idx); + if (!key) return; + base[key] = value; + }); + return normalizeRowData(base); + } + + values.forEach((value, idx) => { + const key = SEQUENCE_KEYS[idx]; + if (!key) return; + base[key] = value; + }); + + return normalizeRowData(base); +} + +function validatePreviewRows(rows: BatchMassPreviewRow[]): void { + const linhaCounts = new Map(); + + rows.forEach((row) => { + const digits = (row.data['linha'] ?? '').replace(/\D/g, ''); + if (!digits) return; + linhaCounts.set(digits, (linhaCounts.get(digits) ?? 0) + 1); + }); + + rows.forEach((row) => { + const errors: string[] = []; + const linha = (row.data['linha'] ?? '').trim(); + const chip = (row.data['chip'] ?? '').trim(); + const linhaDigits = linha.replace(/\D/g, ''); + + if (!linha) errors.push('Linha obrigatoria.'); + else if (!linhaDigits) errors.push('Numero de linha invalido.'); + if (!chip) errors.push('Chip (ICCID) obrigatorio.'); + + REQUIRED_KEYS.forEach((key) => { + if (key === 'linha' || key === 'chip') return; + if (!(row.data[key] ?? '').toString().trim()) { + if (key === 'contaEmpresa') errors.push('Empresa (Conta) obrigatoria.'); + else if (key === 'planoContrato') errors.push('Plano Contrato obrigatorio.'); + else if (key === 'dtEfetivacaoServico') errors.push('Dt. Efetivacao Servico obrigatoria.'); + else if (key === 'dtTerminoFidelizacao') errors.push('Dt. Termino Fidelizacao obrigatoria.'); + else if (key === 'conta') errors.push('Conta obrigatoria.'); + else if (key === 'status') errors.push('Status obrigatorio.'); + } + }); + + if (linhaDigits && (linhaCounts.get(linhaDigits) ?? 0) > 1) { + errors.push('Linha duplicada no lote.'); + } + + row.errors = errors; + }); +} + +export function buildBatchMassPreview( + text: string, + opts?: { + separatorMode?: BatchMassSeparatorMode; + defaults?: BatchMassDefaults; + detectHeader?: boolean; + } +): BatchMassPreviewResult { + const rawText = (text ?? '').toString(); + const separatorMode = opts?.separatorMode ?? 'AUTO'; + const defaults = opts?.defaults ?? {}; + const detectHeaderEnabled = opts?.detectHeader ?? true; + const parseErrors: string[] = []; + + const nonEmptyLines = rawText + .split(/\r?\n/) + .map((line, idx) => ({ line: line.trim(), sourceLineNumber: idx + 1 })) + .filter((x) => x.line.length > 0); + + if (nonEmptyLines.length === 0) { + return { + separator: separatorMode === 'AUTO' ? 'SEMICOLON' : separatorMode, + recognizedRows: 0, + validRows: 0, + invalidRows: 0, + duplicateRows: 0, + hasHeader: false, + rows: [], + parseErrors: [] + }; + } + + const effectiveSeparator = separatorMode === 'AUTO' ? detectSeparator(nonEmptyLines[0].line) : separatorMode; + const splitLines = nonEmptyLines.map((entry) => ({ + ...entry, + values: splitBySeparator(entry.line, effectiveSeparator) + })); + + let headerMap: Map | undefined; + let hasHeader = false; + if (detectHeaderEnabled && splitLines.length > 0) { + const first = splitLines[0]; + const candidate = parseHeaderMap(first.values); + const minAliases = looksLikeDataRow(first.values) ? 4 : 2; + if (candidate.size >= minAliases) { + headerMap = candidate; + hasHeader = true; + } + } + + const rows: BatchMassPreviewRow[] = []; + const startIndex = hasHeader ? 1 : 0; + for (let i = startIndex; i < splitLines.length; i++) { + const entry = splitLines[i]; + const allEmpty = entry.values.every((v) => !v.trim()); + if (allEmpty) continue; + + const data = buildDataFromValues(entry.values, defaults, headerMap); + rows.push({ + sourceLineNumber: entry.sourceLineNumber, + rawLine: entry.line, + values: entry.values, + data, + errors: [] + }); + } + + if (rows.length === 0 && hasHeader) { + parseErrors.push('Nenhuma linha de dados encontrada abaixo do cabecalho.'); + } + + validatePreviewRows(rows); + + const duplicateRows = rows.filter((r) => r.errors.some((e) => e.includes('duplicada'))).length; + const invalidRows = rows.filter((r) => r.errors.length > 0).length; + + return { + separator: effectiveSeparator, + recognizedRows: rows.length, + validRows: rows.length - invalidRows, + invalidRows, + duplicateRows, + hasHeader, + rows, + parseErrors + }; +} + +export function mergeMassRows(existing: T[], incoming: T[], mode: BatchMassApplyMode): T[] { + return mode === 'REPLACE' ? [...incoming] : [...existing, ...incoming]; +} + +export function buildBatchMassHeaderLine(mode: BatchMassSeparatorMode = 'SEMICOLON'): string { + const sep = getSeparatorChar(getEffectiveSeparatorForTemplate(mode)); + return SEQUENCE_KEYS.map((key) => SEQUENCE_LABELS[key]).join(sep); +} + +export function buildBatchMassExampleText(mode: BatchMassSeparatorMode = 'SEMICOLON', withHeader = true): string { + const sep = getSeparatorChar(getEffectiveSeparatorForTemplate(mode)); + const lines: string[] = []; + + if (withHeader) { + lines.push(buildBatchMassHeaderLine(mode)); + } + + lines.push( + [ + '11999999999', + '8955000000000000001', + 'João', + 'eSIM', + 'SMART EMPRESAS 6GB', + 'ATIVO', + 'VIVO MACROPHONY', + '0430237019', + '2026-01-01', + '2027-01-01' + ].join(sep) + ); + + lines.push( + [ + '11999999998', + '8955000000000000002', + 'Maria', + 'Físico', + 'SMART EMPRESAS 10GB', + 'ATIVO', + 'VIVO MACROPHONY', + '0430237019', + '2026-01-02', + '2027-01-02' + ].join(sep) + ); + + return lines.join('\n'); +} diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index 063ec89..baf4fc8 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -515,12 +515,17 @@ *ngIf="createOpen" #createModal class="modal-card modal-lg modal-create" + [class.batch-mode]="isCreateBatchMode" (click)="$event.stopPropagation()" > diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index 3cec0d6..d155bfc 100644 --- a/src/app/pages/geral/geral.scss +++ b/src/app/pages/geral/geral.scss @@ -377,6 +377,7 @@ .modal-body .box-body { overflow: visible; } .modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } .modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; } +.modal-card.modal-create.batch-mode { width: min(1560px, 99vw); } /* === MODAL DE EDITAR E SEÇÕES (Accordion) === */ /* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */ @@ -429,3 +430,81 @@ div.box-body { padding: 16px; &.compact { padding: 12px 16px; } &.compact-paddin .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; @media (max-width: 600px) { grid-template-columns: 1fr; } } .form-field { display: flex; flex-direction: column; gap: 6px; label { font-size: 0.75rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(17,18,20,0.65); } &.span-2 { grid-column: span 2; } } .form-control, .form-select { border-radius: 8px; border: 1px solid rgba(17,18,20,0.15); background-color: #fff; font-size: 0.9rem; font-weight: 500; color: var(--text); transition: border-color 0.2s, box-shadow 0.2s; &:hover { border-color: rgba(17, 18, 20, 0.7); } &:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); outline: none; } &:disabled, &[readonly] { background-color: rgba(17, 18, 20, 0.04); border-color: rgba(17, 18, 20, 0.2); color: var(--muted); } } + +/* === CREATE MODES / LOTE === */ +.create-entry-mode { display: grid; gap: 10px; } +.mode-pill-group { display: inline-flex; flex-wrap: wrap; gap: 8px; background: rgba(255,255,255,0.75); padding: 6px; border-radius: 999px; border: 1px solid rgba(17,18,20,0.08); width: fit-content; max-width: 100%; } +.mode-pill { border: 1px solid transparent; background: transparent; color: rgba(17,18,20,0.7); border-radius: 999px; padding: 8px 14px; font-size: 0.85rem; font-weight: 800; line-height: 1; transition: all 0.2s ease; white-space: nowrap; &:hover:not(:disabled) { background: rgba(227, 61, 207, 0.06); color: var(--brand); } &:disabled { opacity: 0.6; cursor: not-allowed; } &.active { background: linear-gradient(180deg, rgba(227,61,207,0.12), rgba(227,61,207,0.06)); color: var(--brand); border-color: rgba(227,61,207,0.18); box-shadow: 0 4px 12px rgba(227,61,207,0.08); } } +.mode-helper { font-size: 0.83rem; color: rgba(17,18,20,0.65); background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.06); border-radius: 12px; padding: 10px 12px; } + +.batch-lines-panel { .detail-box { border-color: rgba(3,15,170,0.08); box-shadow: 0 10px 20px rgba(3,15,170,0.03); } } +.batch-client-setup { .detail-box { border-color: rgba(17,18,20,0.08); } } +.batch-count-badge { font-size: 0.72rem; font-weight: 900; color: var(--blue); background: rgba(3,15,170,0.08); border: 1px solid rgba(3,15,170,0.12); border-radius: 999px; padding: 3px 8px; } +.batch-summary-strip { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; } +.summary-pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 5px 10px; font-size: 0.75rem; font-weight: 900; border: 1px solid rgba(17,18,20,0.08); background: #fff; color: rgba(17,18,20,0.72); &.total { color: var(--blue); background: rgba(3,15,170,0.04); border-color: rgba(3,15,170,0.12); } &.ok { color: #157347; background: rgba(25,135,84,0.08); border-color: rgba(25,135,84,0.15); } &.warn { color: #b58105; background: rgba(255,193,7,0.14); border-color: rgba(255,193,7,0.18); } &.dup { color: #842029; background: rgba(220,53,69,0.08); border-color: rgba(220,53,69,0.15); } } +.batch-validation-banner { display: flex; align-items: center; gap: 8px; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px; font-size: 0.84rem; font-weight: 700; border: 1px solid rgba(17,18,20,0.08); background: rgba(255,255,255,0.7); color: rgba(17,18,20,0.72); i { font-size: 1rem; } &.is-danger { color: #842029; background: rgba(220,53,69,0.08); border-color: rgba(220,53,69,0.15); } &.is-ok { color: #157347; background: rgba(25,135,84,0.08); border-color: rgba(25,135,84,0.15); } } +.batch-inheritance-note { border-radius: 12px; border: 1px solid rgba(17,18,20,0.06); background: rgba(255,255,255,0.72); padding: 10px 12px; color: rgba(17,18,20,0.65); font-size: 0.82rem; line-height: 1.35; margin-bottom: 12px; } +.batch-mass-input-box { border: 1px solid rgba(17,18,20,0.07); background: linear-gradient(180deg, rgba(255,255,255,0.85), rgba(255,255,255,0.72)); border-radius: 14px; padding: 12px; margin-bottom: 12px; display: grid; gap: 10px; } +.batch-mass-input-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; } +.batch-mass-title { font-size: 0.9rem; font-weight: 900; color: var(--text); } +.batch-mass-sub { font-size: 0.76rem; color: rgba(17,18,20,0.62); line-height: 1.35; margin-top: 3px; code { font-size: 0.72rem; color: var(--blue); background: rgba(3,15,170,0.05); border: 1px solid rgba(3,15,170,0.08); border-radius: 6px; padding: 1px 4px; } } +.batch-mass-controls { display: grid; gap: 4px; min-width: 150px; } +.batch-mass-guide { border: 1px solid rgba(3,15,170,0.08); border-radius: 12px; background: rgba(3,15,170,0.02); overflow: hidden; + summary { cursor: pointer; list-style: none; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px 12px; padding: 10px 12px; font-weight: 800; color: rgba(17,18,20,0.78); small { font-size: 0.72rem; font-weight: 700; color: rgba(17,18,20,0.55); text-align: left; white-space: normal; } } +} +.batch-mass-guide-body { padding: 10px 12px 12px; border-top: 1px solid rgba(3,15,170,0.06); display: grid; gap: 10px; } +.batch-mass-guide-list { display: grid; grid-template-columns: 1fr; gap: 8px; } +.batch-mass-guide-item { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: start; border: 1px solid rgba(17,18,20,0.06); background: rgba(255,255,255,0.88); border-radius: 10px; padding: 8px; .pos { width: 28px; height: 28px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: rgba(3,15,170,0.08); color: var(--blue); font-weight: 900; font-size: 0.78rem; } .meta { min-width: 0; display: grid; gap: 2px; } .name { display: block; font-size: 0.79rem; font-weight: 800; color: var(--text); line-height: 1.25; } .hint { display: block; font-size: 0.71rem; color: rgba(17,18,20,0.62); font-weight: 800; line-height: 1.2; } .note { display: block; font-size: 0.71rem; color: rgba(17,18,20,0.56); line-height: 1.25; } } +.batch-mass-guide-note { border-radius: 8px; background: rgba(255,255,255,0.85); border: 1px solid rgba(17,18,20,0.05); padding: 8px 10px; font-size: 0.76rem; color: rgba(17,18,20,0.62); } +.batch-mass-defaults { border: 1px solid rgba(17,18,20,0.06); border-radius: 12px; background: rgba(255,255,255,0.8); overflow: hidden; + summary { cursor: pointer; list-style: none; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 12px; font-weight: 800; color: rgba(17,18,20,0.78); background: rgba(3,15,170,0.02); small { font-size: 0.72rem; font-weight: 700; color: rgba(17,18,20,0.55); text-align: right; } } +} +.batch-mass-defaults-body { padding: 12px; border-top: 1px solid rgba(17,18,20,0.05); } +.batch-mass-textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.82rem; line-height: 1.35; resize: vertical; min-height: 120px; } +.batch-mass-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.batch-mass-preview { border-top: 1px solid rgba(17,18,20,0.06); padding-top: 10px; display: grid; gap: 8px; } +.batch-mass-preview-pills { display: flex; flex-wrap: wrap; gap: 6px; } +.batch-mass-preview-errors { border-radius: 10px; border: 1px solid rgba(220,53,69,0.16); background: rgba(220,53,69,0.05); color: #842029; padding: 8px 10px; font-size: 0.78rem; ul { margin: 0; padding-left: 16px; } } +.batch-mass-preview-table-wrap { overflow: auto; border: 1px solid rgba(17,18,20,0.06); border-radius: 10px; background: #fff; } +.batch-mass-preview-table { width: 100%; min-width: 780px; border-collapse: separate; border-spacing: 0; th, td { padding: 8px 10px; border-bottom: 1px solid rgba(17,18,20,0.05); font-size: 0.76rem; vertical-align: top; } thead th { background: rgba(248,249,250,0.95); font-weight: 900; text-transform: uppercase; letter-spacing: 0.03em; color: rgba(17,18,20,0.62); white-space: nowrap; } tbody tr:last-child td { border-bottom: 0; } } +.batch-mass-preview-foot { font-size: 0.74rem; color: rgba(17,18,20,0.58); padding: 8px 10px 0; } +.batch-actions-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; } +.batch-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 12px; align-items: start; } +.batch-grid-pane { min-width: 0; display: grid; gap: 10px; } +.batch-drawer-col { min-width: 0; } +.batch-lines-empty { border: 1px dashed rgba(17,18,20,0.12); background: rgba(255,255,255,0.65); color: rgba(17,18,20,0.6); border-radius: 12px; padding: 14px; text-align: center; font-weight: 600; } +.batch-lines-table-wrap { overflow: auto; border: 1px solid rgba(17,18,20,0.08); border-radius: 14px; background: #fff; } +.batch-lines-table { width: 100%; min-width: 1010px; border-collapse: separate; border-spacing: 0; th, td { padding: 10px; border-bottom: 1px solid rgba(17,18,20,0.06); vertical-align: middle; } thead th { position: sticky; top: 0; z-index: 1; background: rgba(248, 249, 250, 0.96); font-size: 0.73rem; text-transform: uppercase; letter-spacing: 0.04em; color: rgba(17,18,20,0.65); font-weight: 900; white-space: nowrap; } thead th:last-child { padding-left: 4px; padding-right: 4px; } tbody tr { transition: background-color 0.15s ease; &.is-selected { background: rgba(3,15,170,0.03); } &.is-invalid-row { background: rgba(220,53,69,0.025); } &:hover { background: rgba(227,61,207,0.03); } } tbody tr:last-child td { border-bottom: 0; } .index-cell { width: 64px; text-align: center; font-weight: 900; color: var(--blue); } .validation-cell { min-width: 160px; } .actions-cell { width: 76px; min-width: 76px; text-align: left; white-space: nowrap; padding-left: 0; padding-right: 2px; display: flex; align-items: center; justify-content: flex-start; gap: 4px; } .form-control { min-width: 140px; } } +.batch-input-invalid { border-color: rgba(220,53,69,0.45) !important; background: rgba(220,53,69,0.03) !important; box-shadow: inset 0 0 0 1px rgba(220,53,69,0.08); } +.batch-row-valid { display: inline-flex; align-items: center; gap: 6px; font-size: 0.76rem; font-weight: 900; color: #157347; background: rgba(25,135,84,0.07); border: 1px solid rgba(25,135,84,0.12); border-radius: 999px; padding: 5px 9px; } +.batch-row-errors { margin: 0; padding-left: 14px; color: #842029; font-size: 0.74rem; line-height: 1.2; li + li { margin-top: 3px; } } +.batch-row-errors-compact { display: grid; gap: 2px; color: #842029; } +.batch-row-error-main { font-size: 0.76rem; font-weight: 800; line-height: 1.15; } +.batch-row-more { font-size: 0.7rem; color: rgba(132,32,41,0.8); font-weight: 700; } +.batch-detail-attention { border-color: rgba(220,53,69,0.25) !important; color: #842029 !important; background: rgba(220,53,69,0.06) !important; } +.batch-selected-hint { margin-top: 10px; font-size: 0.8rem; color: rgba(17,18,20,0.6); display: flex; align-items: center; gap: 8px; i { color: var(--blue); } } +.batch-detail-drawer { background: #fff; border: 1px solid rgba(17,18,20,0.08); border-radius: 16px; box-shadow: 0 14px 28px rgba(17,18,20,0.08); overflow: hidden; display: flex; flex-direction: column; max-height: 72vh; position: sticky; top: 0; } +.batch-detail-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; padding: 12px; border-bottom: 1px solid rgba(17,18,20,0.06); background: linear-gradient(180deg, rgba(3,15,170,0.03), rgba(255,255,255,0.9)); } +.batch-detail-title { font-size: 0.95rem; font-weight: 900; color: var(--text); } +.batch-detail-sub { margin-top: 2px; font-size: 0.76rem; color: rgba(17,18,20,0.6); } +.batch-detail-body { padding: 12px; overflow: auto; } +.batch-detail-body .detail-box { border-radius: 12px; } +.batch-detail-placeholder { border: 1px dashed rgba(17,18,20,0.12); border-radius: 16px; background: rgba(255,255,255,0.72); padding: 18px; color: rgba(17,18,20,0.62); display: grid; gap: 8px; align-content: start; min-height: 180px; i { font-size: 1.4rem; color: var(--blue); } p { margin: 0; font-weight: 700; } small { color: rgba(17,18,20,0.58); } } + +@media (max-width: 768px) { + .mode-pill-group { width: 100%; border-radius: 14px; } + .mode-pill { flex: 1 1 180px; justify-content: center; } + .batch-actions-row .btn { flex: 1 1 200px; } + .batch-mass-input-head { flex-direction: column; } + .batch-mass-controls { min-width: 0; width: 100%; } + .batch-mass-guide summary { flex-direction: column; align-items: flex-start; } + .batch-mass-defaults summary { flex-direction: column; align-items: flex-start; } + .batch-mass-actions .btn { flex: 1 1 180px; } + .batch-editor-layout { grid-template-columns: 1fr; } + .batch-detail-drawer { position: static; max-height: none; } + .batch-detail-header { flex-direction: column; align-items: stretch; } + .batch-detail-header > .d-flex { flex-wrap: wrap; } + .batch-summary-strip { gap: 6px; } + .summary-pill { font-size: 0.72rem; } + .batch-validation-banner { align-items: flex-start; } +} diff --git a/src/app/pages/geral/geral.spec.ts b/src/app/pages/geral/geral.spec.ts index 491ce60..05f32d1 100644 --- a/src/app/pages/geral/geral.spec.ts +++ b/src/app/pages/geral/geral.spec.ts @@ -28,4 +28,49 @@ describe('Geral', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should not create manual batch row automatically when switching to batch mode', () => { + component.createBatchLines = []; + component.setCreateEntryMode('BATCH'); + + expect(component.createEntryMode).toBe('BATCH'); + expect(component.createBatchLines.length).toBe(0); + expect(component.batchDetailOpen).toBeFalse(); + }); + + it('should add parsed mass-input rows to existing batch and keep rows editable', async () => { + spyOn(component, 'showToast').and.resolveTo(); + component.createEntryMode = 'BATCH'; + component.batchMassInputText = + '11999999999;8955000000000000001;Joao;eSIM;PLANO A;ATIVO;EMPRESA A;CONTA A;2026-01-01;2027-01-01'; + + await component.applyBatchMassInput('ADD'); + + expect(component.createBatchLines.length).toBe(1); + expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO A'); + + component.createBatchLines[0]['planoContrato'] = 'PLANO EDITADO'; + component.onBatchLineDetailsChange(); + + expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO EDITADO'); + }); + + it('should replace current batch when applying mass input in replace mode', async () => { + spyOn(component, 'showToast').and.resolveTo(); + component.createEntryMode = 'BATCH'; + component.batchMassInputText = + '11999999999;8955000000000000001;Joao;eSIM;PLANO A;ATIVO;EMPRESA A;CONTA A;2026-01-01;2027-01-01'; + + await component.applyBatchMassInput('ADD'); + expect(component.createBatchLines.length).toBe(1); + + component.batchMassInputText = + '11888888888;8955000000000000002;Maria;FISICO;PLANO B;ATIVO;EMPRESA B;CONTA B;2026-02-01;2027-02-01'; + + await component.applyBatchMassInput('REPLACE'); + + expect(component.createBatchLines.length).toBe(1); + expect(component.createBatchLines[0].linha).toBe('11888888888'); + expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO B'); + }); }); diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 3bbc7b2..12adc05 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -24,9 +24,20 @@ import { AuthService } from '../../services/auth.service'; import { firstValueFrom, Subscription, filter } from 'rxjs'; import { environment } from '../../../environments/environment'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; +import { + BATCH_MASS_COLUMN_GUIDE, + type BatchMassApplyMode, + buildBatchMassExampleText, + buildBatchMassHeaderLine, + type BatchMassPreviewResult, + type BatchMassSeparatorMode, + buildBatchMassPreview, + mergeMassRows +} from './batch-mass-input.util'; type SortDir = 'asc' | 'desc'; type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP'; +type CreateEntryMode = 'SINGLE' | 'BATCH'; type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT'; type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo'; @@ -122,6 +133,43 @@ interface AccountCompanyOption { contas: string[]; } +interface CreateBatchLineDraft extends Partial { + uid: number; + linha: string; + chip: string; + tipoDeChip: string; + usuario: string; + contaEmpresa?: string; +} + +interface BatchLineValidation { + uid: number; + index: number; + linhaDigits: string; + errors: string[]; +} + +interface BatchValidationSummary { + total: number; + valid: number; + invalid: number; + duplicates: number; +} + +interface CreateMobileLinesBatchRequest { + lines: CreateMobileLineRequest[]; +} + +interface CreateMobileLinesBatchResponse { + created?: number; + items?: Array<{ + id: string; + item: number; + linha?: string | null; + cliente?: string | null; + }>; +} + @Component({ standalone: true, @@ -130,6 +178,7 @@ interface AccountCompanyOption { styleUrls: ['./geral.scss'] }) export class Geral implements OnInit, AfterViewInit, OnDestroy { + readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE; toastMessage = ''; @ViewChild('successToast', { static: false }) successToast!: ElementRef; @@ -201,6 +250,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { createOpen = false; createSaving = false; createMode: CreateMode = 'NEW_CLIENT'; + createEntryMode: CreateEntryMode = 'SINGLE'; + createBatchLines: CreateBatchLineDraft[] = []; + selectedBatchLineUid: number | null = null; + batchDetailOpen = false; + batchMassInputText = ''; + batchMassSeparatorMode: BatchMassSeparatorMode = 'AUTO'; + batchMassPreview: BatchMassPreviewResult | null = null; + createBatchValidationByUid: Record = {}; + createBatchValidationSummary: BatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 }; detailData: any = null; financeData: any = null; @@ -216,6 +274,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private groupsRequestVersion = 0; private linesRequestVersion = 0; private clientsRequestVersion = 0; + private createBatchUidSeed = 0; loadingKpis = false; kpiTotalClientes = 0; @@ -323,6 +382,74 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { lucro: null }; + get isCreateBatchMode(): boolean { + return this.createEntryMode === 'BATCH'; + } + + get createBatchCount(): number { + return this.createBatchLines.length; + } + + get createSubmitText(): string { + if (this.createSaving) return ''; + if (this.isCreateBatchMode) { + const count = this.createBatchCount; + return count > 0 ? `Cadastrar Lote (${count})` : 'Cadastrar Lote'; + } + return 'Cadastrar'; + } + + get isCreateSaveDisabled(): boolean { + if (this.createSaving) return true; + if (!this.isCreateBatchMode) return false; + if (this.createBatchCount === 0) return true; + return this.createBatchValidationSummary.invalid > 0; + } + + get hasBatchSelection(): boolean { + if (this.selectedBatchLineUid == null) return false; + return this.createBatchLines.some((x) => x.uid === this.selectedBatchLineUid); + } + + get selectedBatchLine(): CreateBatchLineDraft | null { + if (this.selectedBatchLineUid == null) return null; + return this.createBatchLines.find((x) => x.uid === this.selectedBatchLineUid) ?? null; + } + + get selectedBatchLineIndex(): number { + if (this.selectedBatchLineUid == null) return -1; + return this.createBatchLines.findIndex((x) => x.uid === this.selectedBatchLineUid); + } + + get batchActiveDetailLine(): CreateBatchLineDraft | null { + if (!this.batchDetailOpen) return null; + return this.selectedBatchLine; + } + + get batchValidationMessage(): string { + const s = this.createBatchValidationSummary; + if (!this.isCreateBatchMode) return ''; + if (s.total === 0) return 'Adicione linhas ao lote para começar o preenchimento.'; + if (s.invalid > 0) return `Corrija ${s.invalid} linha(s) inválida(s) antes de salvar.`; + return `Lote pronto para envio: ${s.valid} linha(s) válida(s).`; + } + + get batchMassHasPreview(): boolean { + return !!this.batchMassPreview && (this.batchMassPreview.recognizedRows > 0 || this.batchMassPreview.parseErrors.length > 0); + } + + get batchMassSeparatorLabel(): string { + if (!this.batchMassPreview) return ''; + if (this.batchMassPreview.separator === 'TAB') return 'TAB'; + if (this.batchMassPreview.separator === 'PIPE') return '|'; + return ';'; + } + + get batchMassPreviewRowsPreview(): Array<{ line: number; data: Record; errors: string[] }> { + const rows = this.batchMassPreview?.rows ?? []; + return rows.slice(0, 5).map((row) => ({ line: row.sourceLineNumber, data: row.data, errors: row.errors })); + } + get isGroupMode(): boolean { return this.viewMode === 'GROUPS'; } @@ -536,6 +663,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.editModel = null; this.editingId = null; + this.batchDetailOpen = false; + this.batchMassPreview = null; // Limpa overlays/locks residuais this.cleanupModalArtifacts(); @@ -1740,9 +1869,705 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { desconto: null, lucro: null }; + this.createEntryMode = 'SINGLE'; + this.createBatchLines = []; + this.selectedBatchLineUid = null; + this.batchDetailOpen = false; + this.batchMassInputText = ''; + this.batchMassSeparatorMode = 'AUTO'; + this.batchMassPreview = null; + this.createBatchValidationByUid = {}; + this.createBatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 }; this.createSaving = false; } + setCreateEntryMode(mode: CreateEntryMode) { + this.createEntryMode = mode; + if (mode === 'BATCH') { + if (this.createBatchLines.length > 0) { + if (this.selectedBatchLineUid == null) { + this.selectedBatchLineUid = this.createBatchLines[0]?.uid ?? null; + } + this.batchDetailOpen = true; + this.ensureBatchLineDefaults(this.selectedBatchLine); + } else { + this.selectedBatchLineUid = null; + this.batchDetailOpen = false; + } + this.recomputeBatchValidation(); + return; + } + + this.batchDetailOpen = false; + this.recomputeBatchValidation(); + } + + addBatchLine(seed?: Partial) { + const templateSource = this.selectedBatchLine ?? this.createBatchLines[this.createBatchLines.length - 1] ?? this.createModel; + const row = this.createBatchDraftFromSource(templateSource, seed, { + keepLinha: false, + keepChip: false, + copyDetails: false + }); + + this.createBatchLines = [...this.createBatchLines, row]; + this.selectedBatchLineUid = row.uid; + this.recomputeBatchValidation(); + } + + addBatchLines(count: number) { + const total = Math.max(1, Math.min(200, Math.floor(Number(count) || 1))); + for (let i = 0; i < total; i++) this.addBatchLine(); + } + + removeBatchLine(uid: number) { + this.createBatchLines = this.createBatchLines.filter((x) => x.uid !== uid); + if (this.selectedBatchLineUid === uid) { + this.selectedBatchLineUid = this.createBatchLines[this.createBatchLines.length - 1]?.uid ?? null; + } + if (this.createBatchLines.length === 0) this.batchDetailOpen = false; + this.recomputeBatchValidation(); + } + + clearBatchLines() { + this.createBatchLines = []; + this.selectedBatchLineUid = null; + this.batchDetailOpen = false; + this.recomputeBatchValidation(); + } + + onBatchMassInputChange() { + this.batchMassPreview = null; + } + + previewBatchMassInput() { + this.batchMassPreview = buildBatchMassPreview(this.batchMassInputText, { + separatorMode: this.batchMassSeparatorMode, + defaults: this.getBatchMassDefaults(), + detectHeader: true + }); + } + + clearBatchMassInput() { + this.batchMassInputText = ''; + this.batchMassPreview = null; + } + + useBatchMassExample() { + this.batchMassInputText = buildBatchMassExampleText(this.batchMassSeparatorMode, true); + this.batchMassPreview = null; + } + + useBatchMassHeaderTemplate() { + this.batchMassInputText = buildBatchMassHeaderLine(this.batchMassSeparatorMode); + this.batchMassPreview = null; + } + + async applyBatchMassInput(mode: BatchMassApplyMode) { + if (!this.batchMassInputText.trim()) { + await this.showToast('Cole ou digite as linhas no campo de entrada em massa.'); + return; + } + + this.previewBatchMassInput(); + const preview = this.batchMassPreview; + if (!preview || preview.recognizedRows <= 0) { + await this.showToast(preview?.parseErrors[0] ?? 'Nenhuma linha reconhecida na entrada em massa.'); + return; + } + + const parsedRows = preview.rows.map((previewRow) => + this.createBatchDraftFromSource( + this.createModel, + { + ...previewRow.data + } as Partial, + { keepLinha: true, keepChip: true, copyDetails: false } + ) + ); + + this.createBatchLines = mergeMassRows(this.createBatchLines, parsedRows, mode); + this.selectedBatchLineUid = parsedRows[parsedRows.length - 1]?.uid ?? this.selectedBatchLineUid; + this.batchDetailOpen = this.createBatchLines.length > 0; + this.recomputeBatchValidation(); + + await this.showToast( + mode === 'REPLACE' + ? `${parsedRows.length} linha(s) carregada(s) (substituindo o lote atual).` + : `${parsedRows.length} linha(s) adicionada(s) ao lote.` + ); + } + + selectBatchLine(uid: number) { + this.selectedBatchLineUid = uid; + } + + openBatchLineDetails(uid: number) { + this.selectBatchLine(uid); + this.batchDetailOpen = true; + this.ensureBatchLineDefaults(this.selectedBatchLine); + this.recomputeBatchValidation(); + } + + closeBatchLineDetails() { + this.batchDetailOpen = false; + } + + selectPreviousBatchLine() { + const idx = this.selectedBatchLineIndex; + if (idx <= 0) return; + const prev = this.createBatchLines[idx - 1]; + if (!prev) return; + this.openBatchLineDetails(prev.uid); + } + + selectNextBatchLine() { + const idx = this.selectedBatchLineIndex; + if (idx < 0 || idx >= this.createBatchLines.length - 1) return; + const next = this.createBatchLines[idx + 1]; + if (!next) return; + this.openBatchLineDetails(next.uid); + } + + duplicateLastBatchLine() { + const source = this.createBatchLines[this.createBatchLines.length - 1]; + if (!source) return; + this.duplicateBatchLine(source.uid); + } + + duplicateSelectedBatchLine() { + if (this.selectedBatchLineUid == null) return; + this.duplicateBatchLine(this.selectedBatchLineUid); + } + + duplicateBatchLine(uid: number) { + const idx = this.createBatchLines.findIndex((x) => x.uid === uid); + if (idx < 0) return; + + const source = this.createBatchLines[idx]; + const clone = this.createBatchDraftFromSource(source, undefined, { + keepLinha: true, + keepChip: true, + copyDetails: true + }); + + const next = [...this.createBatchLines]; + next.splice(idx + 1, 0, clone); + this.createBatchLines = next; + this.selectedBatchLineUid = clone.uid; + this.recomputeBatchValidation(); + } + + async removeInvalidBatchLines() { + if (this.createBatchLines.length === 0) return; + const before = this.createBatchLines.length; + this.createBatchLines = this.createBatchLines.filter((row) => (this.getBatchValidation(row.uid)?.errors.length ?? 0) === 0); + const removed = before - this.createBatchLines.length; + if (removed <= 0) return; + + if (this.selectedBatchLineUid != null && !this.createBatchLines.some((x) => x.uid === this.selectedBatchLineUid)) { + this.selectedBatchLineUid = this.createBatchLines[this.createBatchLines.length - 1]?.uid ?? null; + } + + this.recomputeBatchValidation(); + await this.showToast(`${removed} linha(s) inválida(s) removida(s) do lote.`); + } + + onBatchLineDraftChange() { + this.recomputeBatchValidation(); + } + + onBatchLineFieldChange(uid: number) { + this.selectBatchLine(uid); + this.onBatchLineDraftChange(); + } + + onBatchLineDetailsChange() { + this.onBatchLineDraftChange(); + } + + applySelectedBatchLineDetailsToAll() { + const source = this.selectedBatchLine; + if (!source) return; + + const sourceData = this.getBatchLineDataWithoutInternal(source); + this.createBatchLines = this.createBatchLines.map((row) => { + if (row.uid === source.uid) return row; + return { + ...row, + ...sourceData, + uid: row.uid, + linha: row.linha, + chip: row.chip + }; + }); + + this.recomputeBatchValidation(); + } + + trackBatchLine(_index: number, row: CreateBatchLineDraft): number { + return row.uid; + } + + isBatchLineSelected(uid: number): boolean { + return this.selectedBatchLineUid === uid; + } + + getBatchValidation(uid: number): BatchLineValidation | null { + return this.createBatchValidationByUid[uid] ?? null; + } + + getBatchLineErrors(uid: number): string[] { + return this.createBatchValidationByUid[uid]?.errors ?? []; + } + + hasBatchLineError(uid: number): boolean { + return (this.createBatchValidationByUid[uid]?.errors.length ?? 0) > 0; + } + + hasBatchFieldError(uid: number, field: 'linha' | 'chip'): boolean { + const errors = this.createBatchValidationByUid[uid]?.errors ?? []; + if (field === 'linha') { + return errors.some((e) => e.toLowerCase().includes('linha')); + } + return errors.some((e) => e.toLowerCase().includes('chip')); + } + + hasBatchDetailError(uid: number): boolean { + const errors = this.createBatchValidationByUid[uid]?.errors ?? []; + const detailKeywords = ['empresa', 'conta', 'plano', 'status', 'efetivação', 'fidelização', 'detalhes']; + return errors.some((e) => detailKeywords.some((k) => e.toLowerCase().includes(k))); + } + + hasBatchRequiredFieldError(uid: number, fieldKey: string): boolean { + const errors = this.createBatchValidationByUid[uid]?.errors ?? []; + const key = fieldKey.toLowerCase(); + if (key === 'conta') { + return errors.some((e) => e.toLowerCase().startsWith('conta obrigatória')); + } + if (key === 'empresa') { + return errors.some((e) => e.toLowerCase().startsWith('empresa (conta) obrigatória')); + } + return errors.some((e) => e.toLowerCase().includes(key)); + } + + getContaEmpresaOptionsForBatchLine(row: any): string[] { + return this.mergeOption(row?.contaEmpresa, this.contaEmpresaOptions); + } + + getContaOptionsForBatchLine(row: any): string[] { + const empresaSelecionada = (row?.contaEmpresa ?? '').toString().trim(); + const baseOptions = empresaSelecionada ? this.getContasByEmpresa(empresaSelecionada) : this.getAllContas(); + return this.mergeOption(row?.conta, baseOptions); + } + + getPlanOptionsForBatchLine(row: any): string[] { + return this.mergeOption(row?.planoContrato, this.planOptions); + } + + getStatusOptionsForBatchLine(row: any): string[] { + return this.mergeOption(row?.status, this.statusOptions); + } + + getSkilOptionsForBatchLine(row: any): string[] { + return this.mergeOption(row?.skil, this.skilOptions); + } + + onBatchContaEmpresaChange(row: any) { + if (!row) return; + const contas = this.getContasByEmpresa(row.contaEmpresa); + const selectedConta = (row.conta ?? '').toString().trim(); + if (selectedConta) { + const hasMatch = contas.some((c) => this.sameConta(c, selectedConta)); + if (!hasMatch) row.conta = ''; + } + this.onBatchLineDetailsChange(); + } + + onBatchPlanoChange(row: any) { + if (!row) return; + const plan = (row.planoContrato ?? '').toString().trim(); + if (!plan) { + this.onBatchLineDetailsChange(); + return; + } + + const suggestion = this.planAutoFill.suggest(plan); + if (suggestion) { + if (suggestion.franquiaGb != null) { + row.franquiaVivo = suggestion.franquiaGb; + if (row.franquiaLine === null || row.franquiaLine === undefined || row.franquiaLine === '') { + row.franquiaLine = suggestion.franquiaGb; + } + } + if (suggestion.valorPlano != null) row.valorPlanoVivo = suggestion.valorPlano; + } + + this.calculateFinancials(row); + this.onBatchLineDetailsChange(); + } + + onBatchFinancialChange(row: any) { + if (!row) return; + this.calculateFinancials(row); + this.onBatchLineDetailsChange(); + } + + private getBatchMassDefaults() { + return { + usuario: (this.createModel?.usuario ?? '').toString().trim(), + tipoDeChip: (this.createModel?.tipoDeChip ?? '').toString().trim(), + planoContrato: (this.createModel?.planoContrato ?? '').toString().trim(), + status: (this.createModel?.status ?? '').toString().trim(), + contaEmpresa: (this.createModel?.contaEmpresa ?? '').toString().trim(), + conta: (this.createModel?.conta ?? '').toString().trim(), + dtEfetivacaoServico: (this.createModel?.dtEfetivacaoServico ?? '').toString().trim(), + dtTerminoFidelizacao: (this.createModel?.dtTerminoFidelizacao ?? '').toString().trim() + }; + } + + private createBatchDraftFromSource( + source: any, + seed?: Partial, + opts?: { keepLinha?: boolean; keepChip?: boolean; copyDetails?: boolean } + ): CreateBatchLineDraft { + const keepLinha = !!opts?.keepLinha; + const keepChip = !!opts?.keepChip; + const copyDetails = opts?.copyDetails ?? true; + const baseSource = source ?? this.createModel ?? {}; + const raw = this.getBatchLineDataWithoutInternal(baseSource); + + this.createBatchUidSeed += 1; + + const row: CreateBatchLineDraft = { + ...raw, + ...(seed ? this.getBatchLineDataWithoutInternal(seed) : {}), + uid: this.createBatchUidSeed, + item: 0, + linha: keepLinha ? (seed?.linha ?? raw.linha ?? '') : (seed?.linha ?? ''), + chip: keepChip ? (seed?.chip ?? raw.chip ?? '') : (seed?.chip ?? ''), + usuario: (seed?.usuario ?? raw.usuario ?? '').toString(), + tipoDeChip: (seed?.tipoDeChip ?? raw.tipoDeChip ?? '').toString() + }; + + if (!copyDetails) { + const clearScalarFields = [ + 'contaEmpresa', + 'conta', + 'status', + 'planoContrato', + 'vencConta', + 'modalidade', + 'cedente', + 'solicitante', + 'dataBloqueio', + 'dataEntregaOpera', + 'dataEntregaCliente', + 'dtEfetivacaoServico', + 'dtTerminoFidelizacao' + ]; + const clearNumericFields = [ + 'franquiaVivo', + 'valorPlanoVivo', + 'gestaoVozDados', + 'skeelo', + 'vivoNewsPlus', + 'vivoTravelMundo', + 'vivoGestaoDispositivo', + 'vivoSync', + 'valorContratoVivo', + 'franquiaLine', + 'franquiaGestao', + 'locacaoAp', + 'valorContratoLine', + 'desconto', + 'lucro' + ]; + + const mutableRow = row as Record; + clearScalarFields.forEach((key) => (mutableRow[key] = '')); + clearNumericFields.forEach((key) => (mutableRow[key] = null)); + + if (seed) { + Object.assign(row, this.getBatchLineDataWithoutInternal(seed)); + } + } + + this.ensureBatchLineDefaults(row); + return row; + } + + private getBatchLineDataWithoutInternal(source: any): any { + if (!source) return {}; + const { uid, ...rest } = source; + return { ...rest }; + } + + private ensureBatchLineDefaults(row: any) { + if (!row) return; + + if (!('item' in row)) row.item = 0; + if (!('skil' in row) || row.skil === null || row.skil === undefined || row.skil === '') { + row.skil = this.createMode === 'NEW_LINE_IN_GROUP' ? (this.createModel?.skil ?? 'PESSOA FÍSICA') : (this.createModel?.skil ?? 'PESSOA FÍSICA'); + } + + const scalarDefaults: Array<[string, any]> = [ + ['linha', ''], + ['chip', ''], + ['tipoDeChip', ''], + ['usuario', ''], + ['contaEmpresa', ''], + ['conta', ''], + ['status', ''], + ['planoContrato', ''], + ['vencConta', ''], + ['modalidade', ''], + ['cedente', ''], + ['solicitante', ''], + ['dataBloqueio', ''], + ['dataEntregaOpera', ''], + ['dataEntregaCliente', ''], + ['dtEfetivacaoServico', ''], + ['dtTerminoFidelizacao', ''] + ]; + + const numericDefaults: Array<[string, any]> = [ + ['franquiaVivo', null], + ['valorPlanoVivo', null], + ['gestaoVozDados', null], + ['skeelo', null], + ['vivoNewsPlus', null], + ['vivoTravelMundo', null], + ['vivoGestaoDispositivo', null], + ['vivoSync', null], + ['valorContratoVivo', null], + ['franquiaLine', null], + ['franquiaGestao', null], + ['locacaoAp', null], + ['valorContratoLine', null], + ['desconto', null], + ['lucro', null] + ]; + + scalarDefaults.forEach(([k, v]) => { + if (!(k in row) || row[k] === null || row[k] === undefined) row[k] = v; + }); + numericDefaults.forEach(([k, v]) => { + if (!(k in row)) row[k] = v; + }); + + if (!(row.contaEmpresa ?? '').toString().trim() && (row.conta ?? '').toString().trim()) { + row.contaEmpresa = this.findEmpresaByConta(row.conta); + } + + row.cliente = (this.createModel?.cliente ?? row.cliente ?? '').toString(); + } + + private recomputeBatchValidation() { + const byUid: Record = {}; + const counts = new Map(); + + this.createBatchLines.forEach((row) => { + const linhaDigits = (row?.linha ?? '').toString().replace(/\D/g, ''); + if (!linhaDigits) return; + counts.set(linhaDigits, (counts.get(linhaDigits) ?? 0) + 1); + }); + + let valid = 0; + let invalid = 0; + let duplicates = 0; + + this.createBatchLines.forEach((row, index) => { + const linhaRaw = (row?.linha ?? '').toString().trim(); + const chipRaw = (row?.chip ?? '').toString().trim(); + const linhaDigits = linhaRaw.replace(/\D/g, ''); + const errors: string[] = []; + + if (!linhaRaw) errors.push('Linha obrigatória.'); + else if (!linhaDigits) errors.push('Número de linha inválido.'); + + if (!chipRaw) errors.push('Chip (ICCID) obrigatório.'); + + const contaEmpresa = (row?.['contaEmpresa'] ?? '').toString().trim(); + const conta = (row?.['conta'] ?? '').toString().trim(); + const status = (row?.['status'] ?? '').toString().trim(); + const plano = (row?.['planoContrato'] ?? '').toString().trim(); + const dtEfet = (row?.['dtEfetivacaoServico'] ?? '').toString().trim(); + const dtFidel = (row?.['dtTerminoFidelizacao'] ?? '').toString().trim(); + + if (!contaEmpresa) errors.push('Empresa (Conta) obrigatória.'); + if (!conta) errors.push('Conta obrigatória.'); + if (!status) errors.push('Status obrigatório.'); + if (!plano) errors.push('Plano Contrato obrigatório.'); + if (!dtEfet) errors.push('Dt. Efetivação Serviço obrigatória.'); + if (!dtFidel) errors.push('Dt. Término Fidelização obrigatória.'); + + const isDuplicate = !!linhaDigits && (counts.get(linhaDigits) ?? 0) > 1; + if (isDuplicate) { + errors.push('Linha duplicada no lote.'); + duplicates++; + } + + const hasDetailPending = errors.some((e) => + ['empresa', 'conta', 'status', 'plano', 'efetivação', 'fidelização'].some((k) => + e.toLowerCase().includes(k) + ) + ); + if (hasDetailPending && !errors.some((e) => e.toLowerCase().includes('pendências nos detalhes'))) { + errors.unshift('Pendências nos detalhes da linha.'); + } + + if (errors.length > 0) invalid++; + else valid++; + + byUid[row.uid] = { + uid: row.uid, + index, + linhaDigits, + errors + }; + }); + + this.createBatchValidationByUid = byUid; + this.createBatchValidationSummary = { + total: this.createBatchLines.length, + valid, + invalid, + duplicates + }; + } + + private validateCreateClientFields(): string | null { + if (this.createMode !== 'NEW_CLIENT') return null; + + if (!this.createModel.cliente) { + return this.createModel.docType === 'PF' ? 'Informe o Nome Completo.' : 'Informe a Razão Social.'; + } + if (!this.createModel.docNumber) { + return `O ${this.createModel.docType === 'PF' ? 'CPF' : 'CNPJ'} é obrigatório.`; + } + return null; + } + + private validateCreateCommonFields(opts?: { requireLinha?: boolean; requireChip?: boolean }): string | null { + const requireLinha = opts?.requireLinha ?? true; + const requireChip = opts?.requireChip ?? true; + + if (!this.createModel.contaEmpresa) return 'Selecione a Empresa (Conta).'; + if (!this.createModel.conta) return 'Selecione uma Conta.'; + if (requireLinha && !this.createModel.linha) return 'O número da Linha é obrigatório.'; + if (requireChip && !this.createModel.chip) return 'O Chip (ICCID) é obrigatório.'; + if (!this.createModel.status) return 'Selecione um Status.'; + if (!this.createModel.planoContrato) return 'Selecione um Plano.'; + if (!this.createModel.dtEfetivacaoServico) return 'A Dt. Efetivação Serviço é obrigatória.'; + if (!this.createModel.dtTerminoFidelizacao) return 'A Dt. Término Fidelização é obrigatória.'; + return null; + } + + private validateBatchLines(): string | null { + this.recomputeBatchValidation(); + + if (this.createBatchLines.length === 0) { + return 'Adicione ao menos uma linha no lote.'; + } + + if (this.createBatchValidationSummary.invalid <= 0) { + return null; + } + + for (let i = 0; i < this.createBatchLines.length; i++) { + const row = this.createBatchLines[i]; + const errors = this.getBatchLineErrors(row.uid); + if (errors.length <= 0) continue; + return `Linha ${i + 1}: ${errors[0]}`; + } + + return 'Existem linhas inválidas no lote.'; + } + + private buildCreatePayload(model: any): CreateMobileLineRequest { + this.calculateFinancials(model); + + const { contaEmpresa: _contaEmpresa, uid: _uid, ...createModelPayload } = model; + + return { + ...createModelPayload, + item: Number(model.item), + dataBloqueio: this.dateInputToIso(model.dataBloqueio), + dataEntregaOpera: this.dateInputToIso(model.dataEntregaOpera), + dataEntregaCliente: this.dateInputToIso(model.dataEntregaCliente), + dtEfetivacaoServico: this.dateInputToIso(model.dtEfetivacaoServico), + dtTerminoFidelizacao: this.dateInputToIso(model.dtTerminoFidelizacao), + franquiaVivo: this.toNullableNumber(model.franquiaVivo), + valorPlanoVivo: this.toNullableNumber(model.valorPlanoVivo), + gestaoVozDados: this.toNullableNumber(model.gestaoVozDados), + skeelo: this.toNullableNumber(model.skeelo), + vivoNewsPlus: this.toNullableNumber(model.vivoNewsPlus), + vivoTravelMundo: this.toNullableNumber(model.vivoTravelMundo), + vivoGestaoDispositivo: this.toNullableNumber(model.vivoGestaoDispositivo), + vivoSync: this.toNullableNumber(model.vivoSync), + valorContratoVivo: this.toNullableNumber(model.valorContratoVivo), + franquiaLine: this.toNullableNumber(model.franquiaLine), + franquiaGestao: this.toNullableNumber(model.franquiaGestao), + locacaoAp: this.toNullableNumber(model.locacaoAp), + valorContratoLine: this.toNullableNumber(model.valorContratoLine), + desconto: this.toNullableNumber(model.desconto), + lucro: this.toNullableNumber(model.lucro), + tipoDeChip: (model.tipoDeChip ?? '').toString() + }; + } + + private buildBatchPayloads(): CreateMobileLineRequest[] { + const clientName = (this.createModel?.cliente ?? '').toString().trim(); + + return this.createBatchLines.map((row) => { + const lineModel = { + ...row, + cliente: clientName || (row?.['cliente'] ?? '').toString(), + linha: (row.linha ?? '').toString(), + chip: (row.chip ?? '').toString(), + usuario: (row.usuario ?? '').toString(), + tipoDeChip: (row.tipoDeChip ?? '').toString() + }; + + return this.buildCreatePayload(lineModel); + }); + } + + private getCreateSuccessMessage(createdCount: number): string { + if (createdCount <= 1) { + return this.createMode === 'NEW_CLIENT' ? 'Sucesso! Cliente cadastrado.' : 'Linha cadastrada com sucesso.'; + } + + return `Sucesso! ${createdCount} linhas cadastradas no lote.`; + } + + private async finalizeCreateSuccess(createdCount: number) { + const targetClient = (this.createModel?.cliente ?? '').toString().trim(); + + this.createSaving = false; + this.closeAllModals(); + + await this.showToast(this.getCreateSuccessMessage(createdCount)); + + if (this.createMode === 'NEW_LINE_IN_GROUP' && this.expandedGroup === targetClient) { + const term = (this.searchTerm ?? '').trim(); + const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; + + this.fetchGroupLines(this.expandedGroup!, useTerm); + this.loadGroups(); + this.loadKpis(); + } else { + this.refreshData(); + } + } + + private async handleCreateError(err: HttpErrorResponse, fallbackMessage = 'Erro ao criar registro.') { + this.createSaving = false; + const msg = (err.error as any)?.message || fallbackMessage; + await this.showToast(msg); + } + onContaEmpresaChange(isEdit: boolean) { const model = isEdit ? this.editModel : this.createModel; if (!model) return; @@ -1779,106 +2604,74 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } async saveCreate() { - if (this.createMode === 'NEW_CLIENT') { - if (!this.createModel.cliente) { - this.showToast(this.createModel.docType === 'PF' ? 'Informe o Nome Completo.' : 'Informe a Razão Social.'); - return; - } - if (!this.createModel.docNumber) { - this.showToast(`O ${this.createModel.docType === 'PF' ? 'CPF' : 'CNPJ'} é obrigatório.`); - return; - } + if (this.isCreateBatchMode) { + await this.saveCreateBatch(); + return; } - if (!this.createModel.contaEmpresa) { - this.showToast('Selecione a Empresa (Conta).'); + await this.saveCreateSingle(); + } + + private async saveCreateSingle() { + const clientError = this.validateCreateClientFields(); + if (clientError) { + await this.showToast(clientError); return; } - if (!this.createModel.conta) { - this.showToast('Selecione uma Conta.'); - return; - } - if (!this.createModel.linha) { - this.showToast('O número da Linha é obrigatório.'); - return; - } - if (!this.createModel.chip) { - this.showToast('O Chip (ICCID) é obrigatório.'); - return; - } - if (!this.createModel.status) { - this.showToast('Selecione um Status.'); - return; - } - if (!this.createModel.planoContrato) { - this.showToast('Selecione um Plano.'); - return; - } - if (!this.createModel.dtEfetivacaoServico) { - this.showToast('A Dt. Efetivação Serviço é obrigatória.'); - return; - } - if (!this.createModel.dtTerminoFidelizacao) { - this.showToast('A Dt. Término Fidelização é obrigatória.'); + + const commonError = this.validateCreateCommonFields({ requireLinha: true, requireChip: true }); + if (commonError) { + await this.showToast(commonError); return; } this.createSaving = true; - this.calculateFinancials(this.createModel); - - const { contaEmpresa: _contaEmpresa, ...createModelPayload } = this.createModel; - - const payload: CreateMobileLineRequest = { - ...createModelPayload, - item: Number(this.createModel.item), - dataBloqueio: this.dateInputToIso(this.createModel.dataBloqueio), - dataEntregaOpera: this.dateInputToIso(this.createModel.dataEntregaOpera), - dataEntregaCliente: this.dateInputToIso(this.createModel.dataEntregaCliente), - dtEfetivacaoServico: this.dateInputToIso(this.createModel.dtEfetivacaoServico), - dtTerminoFidelizacao: this.dateInputToIso(this.createModel.dtTerminoFidelizacao), - franquiaVivo: this.toNullableNumber(this.createModel.franquiaVivo), - valorPlanoVivo: this.toNullableNumber(this.createModel.valorPlanoVivo), - gestaoVozDados: this.toNullableNumber(this.createModel.gestaoVozDados), - skeelo: this.toNullableNumber(this.createModel.skeelo), - vivoNewsPlus: this.toNullableNumber(this.createModel.vivoNewsPlus), - vivoTravelMundo: this.toNullableNumber(this.createModel.vivoTravelMundo), - vivoGestaoDispositivo: this.toNullableNumber(this.createModel.vivoGestaoDispositivo), - vivoSync: this.toNullableNumber(this.createModel.vivoSync), - valorContratoVivo: this.toNullableNumber(this.createModel.valorContratoVivo), - franquiaLine: this.toNullableNumber(this.createModel.franquiaLine), - franquiaGestao: this.toNullableNumber(this.createModel.franquiaGestao), - locacaoAp: this.toNullableNumber(this.createModel.locacaoAp), - valorContratoLine: this.toNullableNumber(this.createModel.valorContratoLine), - desconto: this.toNullableNumber(this.createModel.desconto), - lucro: this.toNullableNumber(this.createModel.lucro), - tipoDeChip: (this.createModel.tipoDeChip ?? '').toString() - }; - + const payload = this.buildCreatePayload(this.createModel); this.http.post(this.apiBase, payload).subscribe({ next: async () => { - this.createSaving = false; - - // fecha e limpa overlay SEMPRE - this.closeAllModals(); - - await this.showToast('Sucesso! Cliente cadastrado.'); - - if (this.createMode === 'NEW_LINE_IN_GROUP' && this.expandedGroup === this.createModel.cliente) { - const term = (this.searchTerm ?? '').trim(); - const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; - - this.fetchGroupLines(this.expandedGroup!, useTerm); - this.loadGroups(); - this.loadKpis(); - } else { - this.refreshData(); - } + await this.finalizeCreateSuccess(1); }, error: async (err: HttpErrorResponse) => { - this.createSaving = false; - const msg = (err.error as any)?.message || 'Erro ao criar registro.'; - await this.showToast(msg); + await this.handleCreateError(err); + } + }); + } + + private async saveCreateBatch() { + const clientError = this.validateCreateClientFields(); + if (clientError) { + await this.showToast(clientError); + return; + } + + const batchError = this.validateBatchLines(); + if (batchError) { + await this.showToast(batchError); + return; + } + + this.createSaving = true; + + const payload: CreateMobileLinesBatchRequest = { + lines: this.buildBatchPayloads() + }; + + this.http.post(`${this.apiBase}/batch`, payload).subscribe({ + next: async (res) => { + const createdCount = Number(res?.created ?? payload.lines.length) || payload.lines.length; + await this.finalizeCreateSuccess(createdCount); + }, + error: async (err: HttpErrorResponse) => { + if (err.status === 405) { + await this.showToast( + 'A API em execução não aceita POST em /api/lines/batch (405). Reinicie/atualize o backend para a versão com o endpoint de lote.' + ); + this.createSaving = false; + return; + } + + await this.handleCreateError(err, 'Erro ao criar lote de linhas.'); } }); } diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss index 7d4a357..ae15b80 100644 --- a/src/app/pages/notificacoes/notificacoes.scss +++ b/src/app/pages/notificacoes/notificacoes.scss @@ -343,3 +343,391 @@ $border: #e5e7eb; /* Mobile optimization: show button usually only on hover desktop, always mobile */ @media(min-width: 768px) { opacity: 0.6; } } + +/* ========================================================================== + RESPONSIVIDADE MOBILE (Central de Notificações) + ========================================================================== */ +@media (max-width: 768px) { + .wrap { + padding: calc(var(--app-header-offset, 72px) - 8px) 0 24px; + } + + .main-container { + padding: 0 12px; + } + + .page-header { + margin-bottom: 20px; + text-align: center; + + .header-text { + text-align: center; + margin-bottom: 10px; + } + + h2 { + font-size: 22px; + line-height: 1.12; + margin-bottom: 6px; + letter-spacing: -0.35px; + word-break: break-word; + } + + p { + font-size: 13px; + line-height: 1.35; + margin-bottom: 14px; + } + } + + .filters-bar { + display: flex; + width: 100%; + max-width: 100%; + justify-content: flex-start; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + padding: 4px; + border-radius: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03); + } + + .pill { + flex: 0 0 auto; + white-space: nowrap; + padding: 7px 10px; + font-size: 12px; + gap: 5px; + + .count-badge { + font-size: 9px; + padding: 1px 5px; + } + } + + .search-row { + margin-top: 8px; + } + + .search-box { + width: 100%; + gap: 8px; + padding: 8px 10px; + border-radius: 10px; + min-height: 40px; + + i { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + font-size: 14px; + line-height: 1; + flex: 0 0 auto; + } + + input { + min-width: 0; + font-size: 16px; /* evita zoom no iPhone */ + line-height: 20px; + height: 20px; + padding: 0; + + &::placeholder { + font-size: 12px; + line-height: 20px; + color: rgba($text-secondary, 0.95); + } + } + } + + .clear-btn { + flex: 0 0 auto; + } + + .bulk-actions-bar { + margin-top: 12px; + gap: 10px; + align-items: stretch; + } + + .bulk-left { + width: 100%; + gap: 8px; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + } + + .select-all { + flex: 0 0 auto; + font-size: 11px; + letter-spacing: 0.3px; + + input { + width: 18px; + height: 18px; + } + } + + .bulk-count { + flex: 1 1 auto; + min-width: 0; + font-size: 10px; + line-height: 1.35; + letter-spacing: 0.35px; + margin-left: auto; + text-align: right; + white-space: normal; + } + + .bulk-actions { + width: 100%; + display: grid; + grid-template-columns: 1fr; + gap: 8px; + } + + .bulk-btn { + width: 100%; + justify-content: center; + padding: 8px 10px; + font-size: 11px; + line-height: 1.2; + border-radius: 10px; + text-align: center; + white-space: normal; + } + + .state-container { + padding: 24px 14px; + } + + .empty-state-large { + padding: 32px 14px; + + .illustration { + font-size: 46px; + margin-bottom: 10px; + } + + h3 { + font-size: 17px; + margin-bottom: 6px; + } + + p { + font-size: 13px; + line-height: 1.35; + margin-bottom: 0; + } + } + + .notif-list { + gap: 10px; + } + + .list-item { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: start; + gap: 10px; + padding: 12px 12px 12px 14px; + border-radius: 14px; + + &:hover { + transform: none; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.04); + } + } + + .item-select { + margin: 0; + min-width: 20px; + align-self: center; + + input { + width: 18px; + height: 18px; + } + } + + .item-icon { + width: 30px; + height: 30px; + margin: 0; + font-size: 18px; + align-self: start; + } + + .item-content { + min-width: 0; + } + + .content-top { + grid-template-columns: 1fr; + gap: 8px; + margin-bottom: 8px; + } + + .item-title { + font-size: 14px; + line-height: 1.25; + gap: 4px; + } + + .separator { + display: none; + } + + .item-client { + display: block; + width: 100%; + overflow-wrap: anywhere; + } + + .date-stack { + width: 100%; + min-width: 0; + align-items: flex-start; + text-align: left; + gap: 4px; + } + + .date-pill { + font-size: 10px; + padding: 4px 7px; + letter-spacing: 0.25px; + line-height: 1.2; + white-space: normal; + } + + .item-meta-grid { + grid-template-columns: 1fr; + gap: 6px; + } + + .meta-row { + gap: 3px; + } + + .meta-label { + font-size: 10px; + letter-spacing: 0.35px; + } + + .meta-value { + font-size: 12px; + line-height: 1.25; + overflow-wrap: anywhere; + } + + .badge-tag { + font-size: 9px; + letter-spacing: 0.35px; + padding: 4px 7px; + } + + .item-actions { + grid-column: 1 / -1; + margin: 2px 0 0 0; + width: 100%; + } + + .btn-action { + width: 100%; + justify-content: center; + padding: 8px 10px; + border-radius: 10px; + font-size: 12px; + gap: 6px; + } + + .btn-action .d-none.d-md-inline { + display: inline !important; + } +} + +@media (max-width: 420px) { + .wrap { + padding: calc(var(--app-header-offset, 72px) - 10px) 0 24px; + } + + .main-container { + padding: 0 10px; + } + + .page-header { + .header-text { + margin-bottom: 8px; + } + + h2 { + font-size: 20px; + } + + p { + font-size: 12px; + margin-bottom: 12px; + } + } + + .filters-bar { + border-radius: 12px; + } + + .pill { + padding: 6px 9px; + font-size: 11px; + gap: 4px; + } + + .search-box { + padding: 8px 9px; + gap: 7px; + + input::placeholder { + font-size: 11px; + letter-spacing: -0.1px; + } + } + + .bulk-btn { + font-size: 10px; + padding: 8px 8px; + } + + .bulk-left { + gap: 6px; + } + + .bulk-count { + font-size: 9px; + letter-spacing: 0.25px; + } + + .list-item { + gap: 8px; + padding: 11px 10px 11px 12px; + } + + .item-title { + font-size: 13px; + } + + .date-pill { + font-size: 9px; + letter-spacing: 0.15px; + } + + .meta-value { + font-size: 11px; + } + + .btn-action { + font-size: 11px; + padding: 7px 8px; + } +}