Adição Lote de Linhas
This commit is contained in:
parent
96d1b28c19
commit
ec3abc056f
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -515,12 +515,17 @@
|
|||
*ngIf="createOpen"
|
||||
#createModal
|
||||
class="modal-card modal-lg modal-create"
|
||||
[class.batch-mode]="isCreateBatchMode"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<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 class="d-flex align-items-center gap-2">
|
||||
|
|
@ -528,15 +533,42 @@
|
|||
<i class="bi bi-x-lg me-1"></i> Cancelar
|
||||
</button>
|
||||
|
||||
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
|
||||
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Cadastrar</span>
|
||||
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="isCreateSaveDisabled">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<details class="detail-box" open>
|
||||
<summary class="box-header">
|
||||
|
|
@ -600,13 +632,29 @@
|
|||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>Linha <span class="text-danger">*</span></label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="createModel.linha" placeholder="119..." />
|
||||
<label>
|
||||
{{ 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 class="form-field">
|
||||
<label>Chip (ICCID) <span class="text-danger">*</span></label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="createModel.chip" />
|
||||
<label>
|
||||
{{ 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 class="form-field">
|
||||
|
|
@ -771,6 +819,709 @@
|
|||
</details>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue