Adição Lote de Linhas

This commit is contained in:
Eduardo 2026-02-25 11:34:51 -03:00
parent 96d1b28c19
commit ec3abc056f
8 changed files with 2944 additions and 99 deletions

View File

@ -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; 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; } &: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 { .btn-login-header {
display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99px; 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; 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); } &: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 { .header-inner {
gap: 8px; gap: 8px;
flex-wrap: nowrap;
} }
.logged-header { .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 { .logged-actions {
gap: 6px; gap: 6px;
} }
@ -809,10 +841,23 @@ $border-color: #e5e7eb;
position: fixed; position: fixed;
top: calc(var(--app-header-offset, 76px) + 8px); top: calc(var(--app-header-offset, 76px) + 8px);
right: 8px; right: 8px;
width: min(260px, calc(100vw - 16px)); width: min(228px, calc(100vw - 16px));
padding: 4px;
border-radius: 12px;
z-index: 1250; 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 { .notifications-head {
padding: 12px; padding: 12px;
flex-wrap: wrap; flex-wrap: wrap;
@ -912,6 +957,20 @@ $border-color: #e5e7eb;
border-radius: 14px; 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 { .modal-card.manage-users-modal {
width: calc(100vw - 12px); width: calc(100vw - 12px);
height: min(calc(100dvh - 12px), 680px); height: min(calc(100dvh - 12px), 680px);
@ -928,6 +987,20 @@ $border-color: #e5e7eb;
max-height: 38vh; 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 { .manage-right-wrapper {
height: auto; height: auto;
min-height: 0; min-height: 0;
@ -937,6 +1010,58 @@ $border-color: #e5e7eb;
padding: 14px; 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 { .edit-header-info {
gap: 10px; gap: 10px;
margin-bottom: 14px; margin-bottom: 14px;
@ -983,8 +1108,36 @@ $border-color: #e5e7eb;
} }
@media (max-width: 420px) { @media (max-width: 420px) {
.logo-area .logo-text { .header-inner > .logo-area {
display: none; 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 { .logged-actions {
@ -1006,6 +1159,48 @@ $border-color: #e5e7eb;
.options-dropdown { .options-dropdown {
right: 6px; right: 6px;
top: calc(var(--app-header-offset, 76px) + 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 { .notifications-head {

View File

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

View File

@ -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<string, string>;
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<BatchMassSeparatorMode, 'AUTO'>): string {
if (mode === 'SEMICOLON') return ';';
if (mode === 'TAB') return '\t';
return '|';
}
function getEffectiveSeparatorForTemplate(mode: BatchMassSeparatorMode): Exclude<BatchMassSeparatorMode, 'AUTO'> {
return mode === 'AUTO' ? 'SEMICOLON' : mode;
}
function detectSeparator(text: string): Exclude<BatchMassSeparatorMode, 'AUTO'> {
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<BatchMassSeparatorMode, 'AUTO'>, 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<BatchMassSeparatorMode, 'AUTO'> = 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<string, string>): Record<string, string> {
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<number, (typeof SEQUENCE_KEYS)[number]> {
const map = new Map<number, (typeof SEQUENCE_KEYS)[number]>();
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<number, (typeof SEQUENCE_KEYS)[number]>
): Record<string, string> {
const base: Record<string, string> = {
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<string, number>();
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<number, (typeof SEQUENCE_KEYS)[number]> | 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<T>(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');
}

View File

@ -515,12 +515,17 @@
*ngIf="createOpen" *ngIf="createOpen"
#createModal #createModal
class="modal-card modal-lg modal-create" class="modal-card modal-lg modal-create"
[class.batch-mode]="isCreateBatchMode"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<div class="modal-header"> <div class="modal-header">
<div class="modal-title"> <div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span> <span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
{{ createMode === 'NEW_CLIENT' ? 'Cadastrar Novo Cliente' : 'Nova Linha para ' + createModel.cliente }} {{
createMode === 'NEW_CLIENT'
? (isCreateBatchMode ? 'Cadastrar Novo Cliente + Lote de Linhas' : 'Cadastrar Novo Cliente')
: ((isCreateBatchMode ? 'Novo Lote de Linhas para ' : 'Nova Linha para ') + createModel.cliente)
}}
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
@ -528,15 +533,42 @@
<i class="bi bi-x-lg me-1"></i> Cancelar <i class="bi bi-x-lg me-1"></i> Cancelar
</button> </button>
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving"> <button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="isCreateSaveDisabled">
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Cadastrar</span> <span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> {{ createSubmitText }}</span>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span> <span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button> </button>
</div> </div>
</div> </div>
<div class="modal-body modern-body bg-light-gray"> <div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard"> <div class="create-entry-mode mb-3">
<div class="mode-pill-group" role="tablist" aria-label="Modo de cadastro">
<button
type="button"
class="mode-pill"
[class.active]="!isCreateBatchMode"
(click)="setCreateEntryMode('SINGLE')"
[disabled]="createSaving"
>
<i class="bi bi-file-earmark-plus me-1"></i> Unitário
</button>
<button
type="button"
class="mode-pill"
[class.active]="isCreateBatchMode"
(click)="setCreateEntryMode('BATCH')"
[disabled]="createSaving"
>
<i class="bi bi-collection me-1"></i> Lote de Linhas
</button>
</div>
<div class="mode-helper" *ngIf="isCreateBatchMode">
Modo lote focado em volume: use a grade para preencher rapidamente e abra o painel lateral de <strong>Detalhes</strong>
da linha quando precisar completar campos de contrato, datas e financeiro.
</div>
</div>
<div class="details-dashboard" *ngIf="!isCreateBatchMode">
<div class="dashboard-column"> <div class="dashboard-column">
<details class="detail-box" open> <details class="detail-box" open>
<summary class="box-header"> <summary class="box-header">
@ -600,13 +632,29 @@
</div> </div>
<div class="form-field"> <div class="form-field">
<label>Linha <span class="text-danger">*</span></label> <label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linha" placeholder="119..." /> {{ isCreateBatchMode ? 'Linha (Preencher no Lote)' : 'Linha' }}
<span class="text-danger" *ngIf="!isCreateBatchMode">*</span>
</label>
<input
class="form-control form-control-sm"
[(ngModel)]="createModel.linha"
[disabled]="isCreateBatchMode"
[placeholder]="isCreateBatchMode ? 'Use a tabela de lote abaixo' : '119...'"
/>
</div> </div>
<div class="form-field"> <div class="form-field">
<label>Chip (ICCID) <span class="text-danger">*</span></label> <label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.chip" /> {{ isCreateBatchMode ? 'Chip (ICCID) (Preencher no Lote)' : 'Chip (ICCID)' }}
<span class="text-danger" *ngIf="!isCreateBatchMode">*</span>
</label>
<input
class="form-control form-control-sm"
[(ngModel)]="createModel.chip"
[disabled]="isCreateBatchMode"
[placeholder]="isCreateBatchMode ? 'Use a tabela de lote abaixo' : ''"
/>
</div> </div>
<div class="form-field"> <div class="form-field">
@ -771,6 +819,709 @@
</details> </details>
</div> </div>
</div> </div>
<div class="batch-client-setup mt-2" *ngIf="isCreateBatchMode">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Contexto do Lote</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2" *ngIf="createMode === 'NEW_CLIENT'">
<div class="d-flex gap-4 p-2 bg-white rounded border align-items-center justify-content-center">
<div class="form-check">
<input class="form-check-input" type="radio" name="docTypeBatch" value="PF" [(ngModel)]="createModel.docType" (change)="onDocTypeChange()">
<label class="form-check-label fw-bold small">Pessoa Física</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="docTypeBatch" value="PJ" [(ngModel)]="createModel.docType" (change)="onDocTypeChange()">
<label class="form-check-label fw-bold small">Pessoa Jurídica</label>
</div>
</div>
</div>
<div class="form-field span-2" *ngIf="createMode === 'NEW_CLIENT'">
<label>Nome do Cliente <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" placeholder="Razão Social ou Nome Completo" />
</div>
<div class="form-field span-2" *ngIf="createMode === 'NEW_CLIENT'">
<label>{{ createModel.docType === 'PF' ? 'CPF' : 'CNPJ' }} <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.docNumber" (input)="onDocInput($event)" placeholder="Somente números" />
</div>
<div class="form-field span-2" *ngIf="createMode === 'NEW_LINE_IN_GROUP'">
<label>Cliente Selecionado</label>
<input class="form-control form-control-sm bg-light" [value]="createModel.cliente" readonly />
</div>
<div class="form-field">
<label>Usuário padrão (opcional)</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" placeholder="Usado como referência para novas linhas" />
</div>
<div class="form-field">
<label>Tipo de Chip padrão (opcional)</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.tipoDeChip" placeholder="Usado como referência para novas linhas" />
</div>
</div>
</div>
</details>
</div>
<div class="batch-lines-panel mt-3" *ngIf="isCreateBatchMode">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-collection me-2"></i> Lote de Linhas</span>
<span class="batch-count-badge ms-2">{{ createBatchCount }} item(ns)</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="batch-summary-strip">
<span class="summary-pill total">Total: {{ createBatchValidationSummary.total }}</span>
<span class="summary-pill ok">Válidas: {{ createBatchValidationSummary.valid }}</span>
<span class="summary-pill warn" *ngIf="createBatchValidationSummary.invalid > 0">
Inválidas: {{ createBatchValidationSummary.invalid }}
</span>
<span class="summary-pill dup" *ngIf="createBatchValidationSummary.duplicates > 0">
Duplicadas: {{ createBatchValidationSummary.duplicates }}
</span>
</div>
<div
class="batch-validation-banner"
[class.is-danger]="createBatchValidationSummary.invalid > 0"
[class.is-ok]="createBatchValidationSummary.total > 0 && createBatchValidationSummary.invalid === 0"
>
<i class="bi" [ngClass]="createBatchValidationSummary.invalid > 0 ? 'bi-exclamation-triangle-fill' : 'bi-check-circle-fill'"></i>
<span>{{ batchValidationMessage }}</span>
</div>
<div class="batch-inheritance-note">
O cliente é definido pelo contexto do cadastro (novo cliente ou cliente existente). Na grade você preenche os
campos rápidos e usa <strong>Detalhes</strong> para completar os dados obrigatórios e opcionais da linha
(<strong>Conta, Plano Contrato, Status, Datas</strong>, financeiro, etc.). Esses campos são <strong>por linha</strong>
e podem ser diferentes entre linhas.
</div>
<div class="batch-mass-input-box">
<div class="batch-mass-input-head">
<div>
<div class="batch-mass-title"><i class="bi bi-clipboard2-plus me-2"></i>Entrada em Massa</div>
<div class="batch-mass-sub">
Cole ou digite várias linhas em sequência. Formato padrão:
<code>linha;chip;usuario;tipoDeChip;planoContrato;status;empresaConta;conta;dtEfetivacaoServico;dtTerminoFidelizacao</code>
</div>
</div>
<div class="batch-mass-controls">
<label class="small fw-bold text-muted mb-0">Separador</label>
<select class="form-select form-select-sm" [(ngModel)]="batchMassSeparatorMode" (ngModelChange)="onBatchMassInputChange()">
<option value="AUTO">Automático</option>
<option value="SEMICOLON">;</option>
<option value="TAB">TAB</option>
<option value="PIPE">|</option>
</select>
</div>
</div>
<details class="batch-mass-guide" open>
<summary>
<span><i class="bi bi-list-ol me-2"></i>Ordem Oficial de Colunas</span>
<small>Use essa ordem para reduzir erro de importação por texto</small>
</summary>
<div class="batch-mass-guide-body">
<div class="batch-mass-guide-list">
<div class="batch-mass-guide-item" *ngFor="let col of batchMassColumnGuide; let i = index">
<span class="pos">{{ i + 1 }}</span>
<span class="meta">
<span class="name">
{{ col.label }} <span class="text-danger" *ngIf="col.required">*</span>
</span>
<span class="hint">
{{ col.canUseDefault ? 'Aceita parâmetro padrão do lote' : 'Preenchimento por linha' }}
</span>
<span class="note" *ngIf="col.note">{{ col.note }}</span>
</span>
</div>
</div>
<div class="batch-mass-guide-note">
Regra: <strong>valor por linha</strong> sobrescreve <strong>parâmetro padrão do lote</strong>. Se um campo
obrigatório ficar vazio, a linha entra como inválida.
</div>
</div>
</details>
<details class="batch-mass-defaults">
<summary>
<span><i class="bi bi-sliders2 me-2"></i>Parâmetros Padrão do Lote (opcional)</span>
<small>Usados quando a coluna não vier na entrada em massa</small>
</summary>
<div class="batch-mass-defaults-body">
<div class="form-grid">
<div class="form-field">
<label>Empresa (Conta)</label>
<app-select
class="form-select"
size="sm"
[options]="contaEmpresaOptions"
[placeholder]="loadingAccountCompanies ? 'Carregando empresas...' : 'Selecione a empresa'"
[(ngModel)]="createModel.contaEmpresa"
(ngModelChange)="onContaEmpresaChange(false); onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field">
<label>Conta</label>
<app-select
class="form-select"
size="sm"
[options]="contaOptionsForCreate"
[disabled]="!createModel.contaEmpresa"
[placeholder]="createModel.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
[(ngModel)]="createModel.conta"
(ngModelChange)="onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field span-2">
<label>Plano Contrato</label>
<app-select
class="form-select"
size="sm"
[options]="planOptions"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onPlanoChange(false); onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field">
<label>Status</label>
<app-select
class="form-select"
size="sm"
[options]="statusOptions"
[(ngModel)]="createModel.status"
(ngModelChange)="onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field">
<label>Usuário padrão</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" (ngModelChange)="onBatchMassInputChange()" />
</div>
<div class="form-field">
<label>Tipo de Chip padrão</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.tipoDeChip" (ngModelChange)="onBatchMassInputChange()" />
</div>
<div class="form-field">
<label>Dt. Efetivação Serviço</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dtEfetivacaoServico" (ngModelChange)="onBatchMassInputChange()" />
</div>
<div class="form-field span-2">
<label>Dt. Término Fidelização</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dtTerminoFidelizacao" (ngModelChange)="onBatchMassInputChange()" />
</div>
</div>
</div>
</details>
<textarea
class="form-control batch-mass-textarea"
rows="5"
[(ngModel)]="batchMassInputText"
(ngModelChange)="onBatchMassInputChange()"
placeholder="Exemplo:
11999999999;8955000000000000001;João;eSIM;SMART EMPRESAS 6GB;ATIVO;VIVO MACROPHONY;0430237019;2026-01-01;2027-01-01
11999999998;8955000000000000002;Maria;Físico;SMART EMPRESAS 10GB;ATIVO;VIVO MACROPHONY;0430237019;2026-01-02;2027-01-02"
></textarea>
<div class="batch-mass-actions">
<button type="button" class="btn btn-sm btn-glass" (click)="useBatchMassExample()" [disabled]="createSaving">
<i class="bi bi-stars me-1"></i> Usar Exemplo
</button>
<button type="button" class="btn btn-sm btn-glass" (click)="useBatchMassHeaderTemplate()" [disabled]="createSaving">
<i class="bi bi-type me-1"></i> Usar Cabeçalho
</button>
<button type="button" class="btn btn-sm btn-glass" (click)="previewBatchMassInput()" [disabled]="createSaving">
<i class="bi bi-eye me-1"></i> Pré-visualizar
</button>
<button
type="button"
class="btn btn-sm btn-brand"
(click)="applyBatchMassInput('ADD')"
[disabled]="createSaving || !batchMassInputText.trim()"
>
<i class="bi bi-plus-circle me-1"></i> Adicionar ao Lote
</button>
<button
type="button"
class="btn btn-sm btn-glass"
(click)="applyBatchMassInput('REPLACE')"
[disabled]="createSaving || !batchMassInputText.trim()"
>
<i class="bi bi-arrow-repeat me-1"></i> Substituir Lote
</button>
<button
type="button"
class="btn btn-sm btn-glass text-danger"
(click)="clearBatchMassInput()"
[disabled]="createSaving || (!batchMassInputText && !batchMassHasPreview)"
>
<i class="bi bi-eraser me-1"></i> Limpar Campo
</button>
</div>
<div class="batch-mass-preview" *ngIf="batchMassHasPreview">
<div class="batch-mass-preview-pills">
<span class="summary-pill total">Reconhecidas: {{ batchMassPreview?.recognizedRows || 0 }}</span>
<span class="summary-pill ok">Válidas: {{ batchMassPreview?.validRows || 0 }}</span>
<span class="summary-pill warn" *ngIf="(batchMassPreview?.invalidRows || 0) > 0">
Inválidas: {{ batchMassPreview?.invalidRows || 0 }}
</span>
<span class="summary-pill dup" *ngIf="(batchMassPreview?.duplicateRows || 0) > 0">
Duplicadas: {{ batchMassPreview?.duplicateRows || 0 }}
</span>
<span class="summary-pill" *ngIf="batchMassPreview">Separador: {{ batchMassSeparatorLabel }}</span>
<span class="summary-pill" *ngIf="batchMassPreview?.hasHeader">Com cabeçalho</span>
</div>
<div class="batch-mass-preview-errors" *ngIf="(batchMassPreview?.parseErrors?.length || 0) > 0">
<div class="fw-bold mb-1"><i class="bi bi-exclamation-triangle me-1"></i>Erros de parsing</div>
<ul>
<li *ngFor="let err of batchMassPreview?.parseErrors">{{ err }}</li>
</ul>
</div>
<div class="batch-mass-preview-table-wrap" *ngIf="(batchMassPreview?.recognizedRows || 0) > 0">
<table class="batch-mass-preview-table">
<thead>
<tr>
<th>Linha origem</th>
<th>Linha</th>
<th>Chip</th>
<th>Plano</th>
<th>Status</th>
<th>Conta</th>
<th>Validação</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of batchMassPreviewRowsPreview">
<td>#{{ row.line }}</td>
<td>{{ row.data['linha'] || '-' }}</td>
<td>{{ row.data['chip'] || '-' }}</td>
<td>{{ row.data['planoContrato'] || '-' }}</td>
<td>{{ row.data['status'] || '-' }}</td>
<td>{{ row.data['conta'] || '-' }}</td>
<td>
<span class="batch-row-valid" *ngIf="row.errors.length === 0"><i class="bi bi-check-circle-fill"></i> OK</span>
<div class="batch-row-errors-compact" *ngIf="row.errors.length > 0" [attr.title]="row.errors.join(' | ')">
<div class="batch-row-error-main">{{ row.errors[0] }}</div>
<div class="batch-row-more" *ngIf="row.errors.length > 1">+{{ row.errors.length - 1 }} pendência(s)</div>
</div>
</td>
</tr>
</tbody>
</table>
<div class="batch-mass-preview-foot" *ngIf="(batchMassPreview?.recognizedRows || 0) > 5">
Mostrando 5 de {{ batchMassPreview?.recognizedRows }} linha(s) na prévia.
</div>
</div>
</div>
</div>
<div class="batch-actions-row">
<button
type="button"
class="btn btn-sm btn-glass"
(click)="removeInvalidBatchLines()"
[disabled]="createSaving || createBatchValidationSummary.invalid === 0"
>
<i class="bi bi-eraser me-1"></i> Remover Inválidas
</button>
<button
type="button"
class="btn btn-sm btn-glass text-danger"
(click)="clearBatchLines()"
[disabled]="createSaving || createBatchCount === 0"
>
<i class="bi bi-trash3 me-1"></i> Limpar Lote
</button>
</div>
<div class="batch-lines-empty" *ngIf="createBatchCount === 0">
Nenhuma linha no lote ainda. Use a <strong>Entrada em Massa</strong> acima para colar/digitar as linhas em
sequência e carregá-las na grade.
</div>
<div class="batch-editor-layout" *ngIf="createBatchCount > 0">
<div class="batch-grid-pane">
<div class="batch-lines-table-wrap">
<table class="batch-lines-table">
<thead>
<tr>
<th>#</th>
<th>Linha <span class="text-danger">*</span></th>
<th>Chip (ICCID) <span class="text-danger">*</span></th>
<th>Usuário</th>
<th>Tipo de Chip</th>
<th>Validação</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let row of createBatchLines; let i = index; trackBy: trackBatchLine"
[class.is-selected]="isBatchLineSelected(row.uid)"
[class.is-invalid-row]="hasBatchLineError(row.uid)"
(click)="selectBatchLine(row.uid)"
>
<td class="index-cell">{{ i + 1 }}</td>
<td>
<input
class="form-control form-control-sm"
[class.batch-input-invalid]="hasBatchFieldError(row.uid, 'linha')"
[(ngModel)]="row.linha"
(ngModelChange)="onBatchLineFieldChange(row.uid)"
(click)="$event.stopPropagation(); selectBatchLine(row.uid)"
placeholder="119..."
/>
</td>
<td>
<input
class="form-control form-control-sm"
[class.batch-input-invalid]="hasBatchFieldError(row.uid, 'chip')"
[(ngModel)]="row.chip"
(ngModelChange)="onBatchLineFieldChange(row.uid)"
(click)="$event.stopPropagation(); selectBatchLine(row.uid)"
placeholder="8955..."
/>
</td>
<td>
<input
class="form-control form-control-sm"
[(ngModel)]="row.usuario"
(ngModelChange)="onBatchLineFieldChange(row.uid)"
(click)="$event.stopPropagation(); selectBatchLine(row.uid)"
[placeholder]="createModel.usuario ? 'Opcional (padrão no contexto)' : 'Opcional'"
/>
</td>
<td>
<input
class="form-control form-control-sm"
[(ngModel)]="row.tipoDeChip"
(ngModelChange)="onBatchLineFieldChange(row.uid)"
(click)="$event.stopPropagation(); selectBatchLine(row.uid)"
[placeholder]="createModel.tipoDeChip ? 'Opcional (padrão no contexto)' : 'Opcional'"
/>
</td>
<td class="validation-cell">
<div class="batch-row-valid" *ngIf="getBatchLineErrors(row.uid).length === 0">
<i class="bi bi-check-circle-fill"></i> OK
</div>
<div
class="batch-row-errors-compact"
*ngIf="getBatchLineErrors(row.uid).length > 0"
[attr.title]="getBatchLineErrors(row.uid).join(' | ')"
>
<div class="batch-row-error-main">
{{ getBatchLineErrors(row.uid)[0] }}
</div>
<div class="batch-row-more" *ngIf="getBatchLineErrors(row.uid).length > 1">
+{{ getBatchLineErrors(row.uid).length - 1 }} pendência(s)
</div>
</div>
</td>
<td class="actions-cell">
<button
type="button"
class="btn btn-sm btn-glass btn-icon"
[class.batch-detail-attention]="hasBatchDetailError(row.uid)"
title="Abrir detalhes da linha"
aria-label="Abrir detalhes da linha"
(click)="$event.stopPropagation(); openBatchLineDetails(row.uid)"
[disabled]="createSaving"
>
<i class="bi bi-sliders2"></i>
</button>
<button
type="button"
class="btn btn-sm btn-icon danger"
title="Remover linha do lote"
(click)="$event.stopPropagation(); removeBatchLine(row.uid)"
[disabled]="createSaving"
>
<i class="bi bi-trash3"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="batch-selected-hint">
<i class="bi bi-cursor-fill"></i>
Após carregar o lote pela <strong>Entrada em Massa</strong>, selecione uma linha e clique em
<strong>Detalhes</strong> para preencher `Contrato`, `Datas`, `Financeiro` e demais campos obrigatórios do
cadastro unitário. `Plano Contrato`, `Status`, `Conta` e datas obrigatórias são validados por linha.
</div>
</div>
<div class="batch-drawer-col" *ngIf="batchActiveDetailLine as activeLine; else batchDrawerPlaceholder">
<aside class="batch-detail-drawer" (click)="$event.stopPropagation()">
<div class="batch-detail-header">
<div>
<div class="batch-detail-title">Detalhes da Linha #{{ selectedBatchLineIndex + 1 }}</div>
<div class="batch-detail-sub">
Cliente: <strong>{{ createModel.cliente || 'Novo cliente (preencha acima)' }}</strong>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" type="button" (click)="selectPreviousBatchLine()" [disabled]="createSaving || selectedBatchLineIndex <= 0">
<i class="bi bi-chevron-left"></i>
</button>
<button class="btn btn-glass btn-sm" type="button" (click)="selectNextBatchLine()" [disabled]="createSaving || selectedBatchLineIndex >= createBatchCount - 1">
<i class="bi bi-chevron-right"></i>
</button>
<button class="btn btn-glass btn-sm" type="button" (click)="applySelectedBatchLineDetailsToAll()" [disabled]="createSaving || createBatchCount <= 1">
<i class="bi bi-arrow-repeat me-1"></i> Aplicar Detalhes em Todas
</button>
<button class="btn btn-glass btn-sm" type="button" (click)="closeBatchLineDetails()" [disabled]="createSaving">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="batch-detail-body">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação & Contrato</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Linha (na grade) <span class="text-danger">*</span></label>
<input class="form-control form-control-sm bg-light" [value]="activeLine.linha || ''" readonly />
</div>
<div class="form-field">
<label>Chip (na grade) <span class="text-danger">*</span></label>
<input class="form-control form-control-sm bg-light" [value]="activeLine.chip || ''" readonly />
</div>
<div class="form-field">
<label>Empresa (Conta) <span class="text-danger">*</span></label>
<app-select
class="form-select"
[class.batch-input-invalid]="hasBatchRequiredFieldError(activeLine.uid, 'empresa')"
size="sm"
[options]="getContaEmpresaOptionsForBatchLine(activeLine)"
[placeholder]="loadingAccountCompanies ? 'Carregando empresas...' : 'Selecione a empresa'"
[(ngModel)]="activeLine.contaEmpresa"
(ngModelChange)="onBatchContaEmpresaChange(activeLine)"
></app-select>
</div>
<div class="form-field">
<label>Conta <span class="text-danger">*</span></label>
<app-select
class="form-select"
[class.batch-input-invalid]="hasBatchRequiredFieldError(activeLine.uid, 'conta')"
size="sm"
[options]="getContaOptionsForBatchLine(activeLine)"
[disabled]="!activeLine.contaEmpresa"
[placeholder]="activeLine.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
[(ngModel)]="activeLine.conta"
(ngModelChange)="onBatchLineDetailsChange()"
></app-select>
</div>
<div class="form-field span-2">
<label>Plano Contrato <span class="text-danger">*</span></label>
<app-select
class="form-select"
[class.batch-input-invalid]="hasBatchRequiredFieldError(activeLine.uid, 'plano')"
size="sm"
[options]="getPlanOptionsForBatchLine(activeLine)"
[(ngModel)]="activeLine.planoContrato"
(ngModelChange)="onBatchPlanoChange(activeLine)"
></app-select>
</div>
<div class="form-field">
<label>Status <span class="text-danger">*</span></label>
<app-select
class="form-select"
[class.batch-input-invalid]="hasBatchRequiredFieldError(activeLine.uid, 'status')"
size="sm"
[options]="getStatusOptionsForBatchLine(activeLine)"
[(ngModel)]="activeLine.status"
(ngModelChange)="onBatchLineDetailsChange()"
></app-select>
</div>
<div class="form-field">
<label>Skil</label>
<app-select
class="form-select"
size="sm"
[options]="getSkilOptionsForBatchLine(activeLine)"
[(ngModel)]="activeLine.skil"
(ngModelChange)="onBatchLineDetailsChange()"
></app-select>
</div>
<div class="form-field">
<label>Venc. Conta</label>
<input class="form-control form-control-sm" [(ngModel)]="activeLine.vencConta" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
<div class="form-field">
<label>Modalidade</label>
<input class="form-control form-control-sm" [(ngModel)]="activeLine.modalidade" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
<div class="form-field span-2">
<label>Usuário da Linha</label>
<input class="form-control form-control-sm" [(ngModel)]="activeLine.usuario" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
<div class="form-field span-2">
<label>Tipo de Chip</label>
<input class="form-control form-control-sm" [(ngModel)]="activeLine.tipoDeChip" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
</div>
</div>
</details>
<details class="detail-box mt-3" open>
<summary class="box-header">
<span><i class="bi bi-sliders me-2"></i> Gestão</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Cedente</label>
<input class="form-control form-control-sm" [(ngModel)]="activeLine.cedente" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
<div class="form-field">
<label>Solicitante</label>
<input class="form-control form-control-sm" [(ngModel)]="activeLine.solicitante" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
</div>
</div>
</details>
<details class="detail-box mt-3" open>
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Datas Importantes</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Data Entrega Operadora</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="activeLine.dataEntregaOpera" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
<div class="form-field">
<label>Data Entrega Cliente</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="activeLine.dataEntregaCliente" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
<div class="form-field">
<label>Data de Bloqueio</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="activeLine.dataBloqueio" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
<div class="form-field">
<label>Dt. Efetivação Serviço <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" [class.batch-input-invalid]="hasBatchRequiredFieldError(activeLine.uid, 'efetivação')" type="date" [(ngModel)]="activeLine.dtEfetivacaoServico" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
<div class="form-field span-2">
<label>Dt. Término Fidelização <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" [class.batch-input-invalid]="hasBatchRequiredFieldError(activeLine.uid, 'fidelização')" type="date" [(ngModel)]="activeLine.dtTerminoFidelizacao" (ngModelChange)="onBatchLineDetailsChange()" />
</div>
</div>
</div>
</details>
<details class="detail-box mt-3 vivo-border" open>
<summary class="box-header header-vivo">
<span><i class="bi bi-telephone-fill me-2"></i> Financeiro Vivo</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Franquia (GB)</label><input class="form-control form-control-sm" type="number" step="0.1" [(ngModel)]="activeLine.franquiaVivo" (ngModelChange)="onBatchLineDetailsChange()" /></div>
<div class="form-field"><label>Valor Plano</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="activeLine.valorPlanoVivo" (ngModelChange)="onBatchFinancialChange(activeLine)" /></div>
<div class="form-field"><label>Gestão Voz</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="activeLine.gestaoVozDados" (ngModelChange)="onBatchFinancialChange(activeLine)" /></div>
<div class="form-field"><label>Skeelo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="activeLine.skeelo" (ngModelChange)="onBatchFinancialChange(activeLine)" /></div>
<div class="form-field"><label>News+</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="activeLine.vivoNewsPlus" (ngModelChange)="onBatchFinancialChange(activeLine)" /></div>
<div class="form-field"><label>Travel Mundo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="activeLine.vivoTravelMundo" (ngModelChange)="onBatchFinancialChange(activeLine)" /></div>
<div class="form-field"><label>Gestão Disp.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="activeLine.vivoGestaoDispositivo" (ngModelChange)="onBatchFinancialChange(activeLine)" /></div>
<div class="form-field"><label>Vivo Sync</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="activeLine.vivoSync" (ngModelChange)="onBatchFinancialChange(activeLine)" /></div>
<div class="form-field"><label>Total Vivo (Auto)</label><input class="form-control form-control-sm bg-light" type="number" step="0.01" [(ngModel)]="activeLine.valorContratoVivo" readonly /></div>
</div>
</div>
</details>
<details class="detail-box mt-3 line-border" open>
<summary class="box-header header-line">
<span><i class="bi bi-hdd-network-fill me-2"></i> Financeiro Line</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Franquia Line</label><input class="form-control form-control-sm" type="number" step="0.1" [(ngModel)]="activeLine.franquiaLine" (ngModelChange)="onBatchLineDetailsChange()" /></div>
<div class="form-field"><label>Franquia Gestão</label><input class="form-control form-control-sm" type="number" step="0.1" [(ngModel)]="activeLine.franquiaGestao" (ngModelChange)="onBatchLineDetailsChange()" /></div>
<div class="form-field"><label>Locação Ap.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="activeLine.locacaoAp" (ngModelChange)="onBatchLineDetailsChange()" /></div>
<div class="form-field"><label>Total Line (Manual)</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="activeLine.valorContratoLine" (ngModelChange)="onBatchFinancialChange(activeLine)" /></div>
</div>
</div>
</details>
<details class="detail-box mt-3" open>
<summary class="box-header">
<span><i class="bi bi-graph-up-arrow me-2"></i> Resultados</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Desconto</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="activeLine.desconto" (ngModelChange)="onBatchLineDetailsChange()" /></div>
<div class="form-field"><label>Lucro Estimado</label><input class="form-control form-control-sm bg-light" type="number" step="0.01" [(ngModel)]="activeLine.lucro" readonly /></div>
</div>
</div>
</details>
</div>
</aside>
</div>
<ng-template #batchDrawerPlaceholder>
<div class="batch-drawer-col">
<div class="batch-detail-placeholder">
<i class="bi bi-layout-sidebar-inset"></i>
<p>Selecione uma linha e clique em <strong>Detalhes</strong> para preencher os campos completos da linha.</p>
<small>Os obrigatórios do cadastro unitário também são validados aqui (conta, plano, status, datas, etc.).</small>
</div>
</div>
</ng-template>
</div>
</div>
</details>
</div>
</div> </div>
</div> </div>

View File

@ -377,6 +377,7 @@
.modal-body .box-body { overflow: visible; } .modal-body .box-body { overflow: visible; }
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } .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 { width: min(1280px, 96vw); max-height: 92vh; }
.modal-card.modal-create.batch-mode { width: min(1560px, 99vw); }
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */ /* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */ /* ✅ 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-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-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); } } .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; }
}

View File

@ -28,4 +28,49 @@ describe('Geral', () => {
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); 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<any>(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<any>(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');
});
}); });

File diff suppressed because it is too large Load Diff

View File

@ -343,3 +343,391 @@ $border: #e5e7eb;
/* Mobile optimization: show button usually only on hover desktop, always mobile */ /* Mobile optimization: show button usually only on hover desktop, always mobile */
@media(min-width: 768px) { opacity: 0.6; } @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;
}
}