395 lines
15 KiB
TypeScript
395 lines
15 KiB
TypeScript
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { Location } from '@angular/common';
|
|
import { Employee, EmployeeInput } from '../../core/models/employee.model';
|
|
import { EmployeeService } from '../../core/services/employee.service';
|
|
import { AuthService } from '../../core/services/auth.service';
|
|
import { UserProfileService } from '../../core/services/user-profile.service';
|
|
import { WORK_SCHEDULES, workSchedule } from '../../core/models/work-schedule.model';
|
|
import { normalizeRow, cleanNum } from '../../shared/utils/header-normalization.utils';
|
|
import * as XLSX from 'xlsx';
|
|
|
|
type EmployeeForm = Omit<EmployeeInput, 'activo'> & { activo: boolean };
|
|
|
|
@Component({
|
|
standalone: true,
|
|
selector: 'app-employees',
|
|
imports: [CommonModule, FormsModule],
|
|
templateUrl: './employees.component.html',
|
|
styleUrls: ['./employees.component.css']
|
|
})
|
|
export class EmployeesComponent implements OnDestroy, OnInit {
|
|
employees: Employee[] = [];
|
|
form = this.emptyForm();
|
|
editingId: string | null = null;
|
|
loading = true;
|
|
saving = false;
|
|
error = '';
|
|
importMessage = '';
|
|
importing = false;
|
|
openRestDaysFor: string | null = null;
|
|
readonly schedules = WORK_SCHEDULES;
|
|
selectedIds = new Set<string>();
|
|
bulkSchedule = '';
|
|
bulkRestHours: number | null = null;
|
|
bulkVisits: number | null = null;
|
|
bulkRestDays = new Set<string>();
|
|
bulkSaving = false;
|
|
private destroyed = false;
|
|
private loadSequence = 0;
|
|
projectId = '';
|
|
|
|
constructor(
|
|
private employeesService: EmployeeService,
|
|
private auth: AuthService,
|
|
private router: Router,
|
|
private activatedRoute: ActivatedRoute,
|
|
private location: Location,
|
|
public profile: UserProfileService,
|
|
private cdr: ChangeDetectorRef
|
|
) {}
|
|
|
|
ngOnInit(): void {
|
|
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
|
|
if (!this.projectId) {
|
|
void this.router.navigate(['/home']);
|
|
return;
|
|
}
|
|
void this.load();
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.destroyed = true;
|
|
this.loadSequence++;
|
|
}
|
|
|
|
get activeEmployees(): number {
|
|
return this.employees.filter(employee => employee.activo).length;
|
|
}
|
|
|
|
get selectedScheduleHours(): number {
|
|
return workSchedule(this.form.jornada_codigo).weeklyHours;
|
|
}
|
|
|
|
get allSelected(): boolean {
|
|
return this.employees.length > 0 && this.employees.every(employee => this.selectedIds.has(employee.id));
|
|
}
|
|
|
|
get selectedEmployees(): Employee[] {
|
|
return this.employees.filter(employee => this.selectedIds.has(employee.id));
|
|
}
|
|
|
|
async load(): Promise<void> {
|
|
const sequence = ++this.loadSequence;
|
|
this.loading = true;
|
|
this.error = '';
|
|
try {
|
|
const employees = await this.employeesService.list(this.projectId);
|
|
if (this.destroyed || sequence !== this.loadSequence) return;
|
|
this.employees = employees;
|
|
this.selectedIds.clear();
|
|
this.cdr.detectChanges();
|
|
} catch (error: any) {
|
|
if (!this.destroyed && sequence === this.loadSequence) {
|
|
this.error = error?.message ?? 'No se pudieron cargar los empleados.';
|
|
}
|
|
} finally {
|
|
this.loading = false;
|
|
if (!this.destroyed && sequence === this.loadSequence) this.cdr.detectChanges();
|
|
}
|
|
}
|
|
|
|
edit(employee: Employee): void {
|
|
this.editingId = employee.id;
|
|
}
|
|
|
|
cancelEdit(): void {
|
|
this.editingId = null;
|
|
this.error = '';
|
|
void this.load();
|
|
}
|
|
|
|
async saveInline(employee: Employee): Promise<void> {
|
|
const rest = Number(employee.horas_descanso_por_dia);
|
|
const visits = Number(employee.visitas_maximas_por_dia);
|
|
if (!Number.isInteger(rest) || rest < 0 || rest > 3 || !Number.isInteger(visits) || visits < 1) {
|
|
this.error = 'Descanso/día debe ser un entero entre 0 y 3, y Visitas/día debe ser un entero mayor que 0.';
|
|
return;
|
|
}
|
|
this.saving = true;
|
|
this.error = '';
|
|
try {
|
|
await this.employeesService.update(employee.id, {
|
|
...employee,
|
|
dias_por_semana: workSchedule(employee.jornada_codigo).days.length,
|
|
horas_descanso_por_dia: rest,
|
|
visitas_maximas_por_dia: visits,
|
|
dias_descanso: (employee.dias_descanso ?? []).filter(day => workSchedule(employee.jornada_codigo).days.includes(day))
|
|
});
|
|
this.editingId = null;
|
|
await this.load();
|
|
} catch (error: any) {
|
|
this.error = error?.message ?? 'No se pudo actualizar el empleado.';
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
}
|
|
|
|
onInlineScheduleChange(employee: Employee): void {
|
|
employee.dias_descanso = (employee.dias_descanso ?? []).filter(day => workSchedule(employee.jornada_codigo).days.includes(day));
|
|
employee.dias_por_semana = workSchedule(employee.jornada_codigo).days.length;
|
|
}
|
|
|
|
async save(): Promise<void> {
|
|
if (!this.form.employee_code.trim() || !this.form.nombre.trim()) {
|
|
this.error = 'El ID y el nombre son obligatorios.';
|
|
return;
|
|
}
|
|
if (this.form.horas_descanso_por_dia >= this.form.horas_por_dia) {
|
|
this.error = 'Las horas de descanso deben ser menores que las horas de trabajo diarias.';
|
|
return;
|
|
}
|
|
this.saving = true;
|
|
this.error = '';
|
|
const employee: EmployeeInput = {
|
|
...this.form,
|
|
dias_por_semana: workSchedule(this.form.jornada_codigo).days.length,
|
|
employee_code: this.form.employee_code.trim(),
|
|
nombre: this.form.nombre.trim(),
|
|
usuario: this.nullable(this.form.usuario),
|
|
email: this.nullable(this.form.email),
|
|
perfil: this.nullable(this.form.perfil),
|
|
pais: this.nullable(this.form.pais),
|
|
regional: this.nullable(this.form.regional)
|
|
};
|
|
try {
|
|
if (this.editingId) await this.employeesService.update(this.editingId, employee);
|
|
else await this.employeesService.create(this.projectId, employee);
|
|
this.cancelEdit();
|
|
await this.load();
|
|
} catch (error: any) {
|
|
this.error = error?.message ?? 'No se pudo guardar el empleado.';
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
}
|
|
|
|
async toggleActive(employee: Employee): Promise<void> {
|
|
try {
|
|
await this.employeesService.update(employee.id, { ...employee, activo: !employee.activo });
|
|
await this.load();
|
|
} catch (error: any) {
|
|
this.error = error?.message ?? 'No se pudo actualizar el estado.';
|
|
}
|
|
}
|
|
|
|
toggleSelection(id: string, checked: boolean): void {
|
|
checked ? this.selectedIds.add(id) : this.selectedIds.delete(id);
|
|
}
|
|
|
|
toggleAll(checked: boolean): void {
|
|
this.selectedIds = checked ? new Set(this.employees.map(employee => employee.id)) : new Set<string>();
|
|
}
|
|
|
|
toggleBulkRestDay(day: string, checked: boolean): void {
|
|
checked ? this.bulkRestDays.add(day) : this.bulkRestDays.delete(day);
|
|
}
|
|
|
|
async applyBulkEmployeeChanges(): Promise<void> {
|
|
const selected = this.selectedEmployees;
|
|
if (!selected.length) return;
|
|
if (!this.bulkSchedule && this.bulkRestHours === null && this.bulkVisits === null && !this.bulkRestDays.size) {
|
|
this.error = 'Selecciona al menos un cambio masivo para aplicar.';
|
|
return;
|
|
}
|
|
if (this.bulkRestHours !== null && (!Number.isInteger(this.bulkRestHours) || this.bulkRestHours < 0 || this.bulkRestHours > 3)) {
|
|
this.error = 'Descanso/día debe ser un entero entre 0 y 3.';
|
|
return;
|
|
}
|
|
if (this.bulkVisits !== null && (!Number.isInteger(this.bulkVisits) || this.bulkVisits < 1)) {
|
|
this.error = 'Visitas/día debe ser un entero mayor que 0.';
|
|
return;
|
|
}
|
|
this.bulkSaving = true;
|
|
this.error = '';
|
|
try {
|
|
await Promise.all(selected.map(employee => {
|
|
const schedule = this.bulkSchedule || employee.jornada_codigo;
|
|
const restDays = this.bulkRestDays.size
|
|
? Array.from(this.bulkRestDays).filter(day => workSchedule(schedule).days.includes(day))
|
|
: (this.bulkSchedule ? employee.dias_descanso.filter(day => workSchedule(schedule).days.includes(day)) : employee.dias_descanso);
|
|
return this.employeesService.update(employee.id, {
|
|
...employee,
|
|
jornada_codigo: schedule,
|
|
dias_por_semana: workSchedule(schedule).days.length,
|
|
dias_descanso: restDays,
|
|
horas_descanso_por_dia: this.bulkRestHours ?? employee.horas_descanso_por_dia,
|
|
visitas_maximas_por_dia: this.bulkVisits ?? employee.visitas_maximas_por_dia
|
|
});
|
|
}));
|
|
this.importMessage = `${selected.length} empleados actualizados.`;
|
|
this.resetBulkFields();
|
|
await this.load();
|
|
} catch (error: any) {
|
|
this.error = error?.message ?? 'No se pudieron aplicar los cambios masivos.';
|
|
} finally {
|
|
this.bulkSaving = false;
|
|
}
|
|
}
|
|
|
|
async setSelectedActive(active: boolean): Promise<void> {
|
|
const selected = this.selectedEmployees;
|
|
if (!selected.length) return;
|
|
this.bulkSaving = true;
|
|
try {
|
|
await Promise.all(selected.map(employee => this.employeesService.update(employee.id, { ...employee, activo: active })));
|
|
this.importMessage = `${selected.length} empleados ${active ? 'activados' : 'desactivados'}.`;
|
|
await this.load();
|
|
} catch (error: any) {
|
|
this.error = error?.message ?? 'No se pudo actualizar el estado de los empleados.';
|
|
} finally {
|
|
this.bulkSaving = false;
|
|
}
|
|
}
|
|
|
|
async removeSelected(): Promise<void> {
|
|
const selected = this.selectedEmployees;
|
|
if (!selected.length || !window.confirm(`¿Eliminar ${selected.length} empleados seleccionados? Esta acción no se puede deshacer.`)) return;
|
|
this.bulkSaving = true;
|
|
try {
|
|
await Promise.all(selected.map(employee => this.employeesService.remove(employee.id)));
|
|
this.importMessage = `${selected.length} empleados eliminados.`;
|
|
await this.load();
|
|
} catch (error: any) {
|
|
this.error = error?.message ?? 'No se pudieron eliminar los empleados.';
|
|
} finally {
|
|
this.bulkSaving = false;
|
|
}
|
|
}
|
|
|
|
scheduleLabel(employee: Employee): string {
|
|
return workSchedule(employee.jornada_codigo).label;
|
|
}
|
|
|
|
availableRestDays(employee: Employee): string[] {
|
|
return workSchedule(employee.jornada_codigo).days;
|
|
}
|
|
|
|
toggleRestDayEditor(employee: Employee): void {
|
|
this.openRestDaysFor = this.openRestDaysFor === employee.id ? null : employee.id;
|
|
}
|
|
|
|
async toggleRestDay(employee: Employee, day: string, checked: boolean): Promise<void> {
|
|
const days = new Set(employee.dias_descanso ?? []);
|
|
checked ? days.add(day) : days.delete(day);
|
|
employee.dias_descanso = Array.from(days);
|
|
}
|
|
|
|
async importFile(event: Event): Promise<void> {
|
|
const file = (event.target as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
this.importing = true;
|
|
this.importMessage = '';
|
|
this.error = '';
|
|
try {
|
|
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
|
|
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
|
const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true }).map(row => normalizeRow(row as Record<string, unknown>));
|
|
const employees = rows.map((row, index) => this.fromExcelRow(row, index + 2));
|
|
const invalid = employees.filter((employee): employee is null => employee === null).length;
|
|
const valid = employees.filter((employee): employee is EmployeeInput => employee !== null);
|
|
if (!valid.length) throw new Error('El archivo no contiene empleados válidos.');
|
|
await this.employeesService.upsert(this.projectId, valid);
|
|
this.importMessage = `${valid.length} empleados guardados${invalid ? `; ${invalid} filas omitidas por datos inválidos` : ''}.`;
|
|
await this.load();
|
|
} catch (error: any) {
|
|
this.error = error?.message ?? 'No se pudo importar el archivo.';
|
|
} finally {
|
|
this.importing = false;
|
|
(event.target as HTMLInputElement).value = '';
|
|
}
|
|
}
|
|
|
|
async remove(employee: Employee): Promise<void> {
|
|
if (!window.confirm(`¿Eliminar a ${employee.nombre}? Esta acción no se puede deshacer.`)) return;
|
|
try {
|
|
await this.employeesService.remove(employee.id);
|
|
await this.load();
|
|
} catch (error: any) {
|
|
this.error = error?.message ?? 'No se pudo eliminar el empleado.';
|
|
}
|
|
}
|
|
|
|
async signOut(): Promise<void> {
|
|
this.destroyed = true;
|
|
this.loadSequence++;
|
|
this.employees = [];
|
|
this.loading = true;
|
|
await this.auth.signOut();
|
|
await this.router.navigate(['/login']);
|
|
}
|
|
|
|
async goHome(): Promise<void> {
|
|
await this.router.navigate(['/proyectos', this.projectId]);
|
|
}
|
|
|
|
goBack(): void {
|
|
if (history.length > 1) this.location.back();
|
|
else void this.router.navigate(['/proyectos', this.projectId]);
|
|
}
|
|
|
|
private nullable(value: string | null): string | null {
|
|
return value?.trim() || null;
|
|
}
|
|
|
|
private resetBulkFields(): void {
|
|
this.bulkSchedule = '';
|
|
this.bulkRestHours = null;
|
|
this.bulkVisits = null;
|
|
this.bulkRestDays.clear();
|
|
}
|
|
|
|
private emptyForm(): EmployeeForm {
|
|
return {
|
|
employee_code: '', nombre: '', usuario: '', email: '', perfil: 'Promotor', pais: 'Republica Dominicana', regional: '',
|
|
latitud: 18.4861, longitud: -69.9312, horas_por_dia: 8, dias_por_semana: 5, jornada_codigo: 'LUN_VIE_40', horas_descanso_por_dia: 1, dias_descanso: [], visitas_maximas_por_dia: 3, activo: true
|
|
};
|
|
}
|
|
|
|
private fromExcelRow(row: Record<string, unknown>, excelRow: number): EmployeeInput | null {
|
|
const code = String(row['id'] ?? '').trim();
|
|
const nombre = String(row['nombre_del_empleado'] ?? '').trim();
|
|
const latitude = cleanNum(row['latitud']);
|
|
const longitude = cleanNum(row['longitud']);
|
|
const dailyHours = cleanNum(row['horas_por_dia_usuario']);
|
|
const weeklyHours = cleanNum(row['horas_por_semana_usuario']);
|
|
if (!code || !nombre || !Number.isFinite(latitude) || !Number.isFinite(longitude) || !Number.isFinite(dailyHours)) return null;
|
|
const jornadaTexto = String(row['jornada'] ?? '').trim();
|
|
const jornadaCodigo = this.schedules.some(schedule => schedule.code === jornadaTexto)
|
|
? jornadaTexto
|
|
: weeklyHours === 44 ? 'LUN_SAB_44' : 'LUN_VIE_40';
|
|
const restDays = String(row['dias_descanso'] ?? '').split(',').map(day => day.trim()).filter(day => workSchedule(jornadaCodigo).days.includes(day));
|
|
const active = !['no', 'n', '0', 'false'].includes(String(row['usuario_activo'] ?? 'si').trim().toLowerCase());
|
|
return {
|
|
employee_code: code,
|
|
nombre,
|
|
usuario: this.nullable(String(row['usuario'] ?? '')),
|
|
email: this.nullable(String(row['email'] ?? '')),
|
|
perfil: this.nullable(String(row['perfil_de_acceso'] ?? '')),
|
|
pais: this.nullable(String(row['pais'] ?? '')),
|
|
regional: this.nullable(String(row['regionales'] ?? row['regional'] ?? '')),
|
|
latitud: latitude,
|
|
longitud: longitude,
|
|
horas_por_dia: dailyHours,
|
|
dias_por_semana: workSchedule(jornadaCodigo).days.length,
|
|
jornada_codigo: jornadaCodigo,
|
|
horas_descanso_por_dia: Math.min(3, Math.max(0, Math.round(cleanNum(row['horas_descanso_por_dia']) || 1))),
|
|
dias_descanso: restDays,
|
|
visitas_maximas_por_dia: Math.max(1, cleanNum(row['visitas_maximas_por_dia']) || 3),
|
|
activo: active
|
|
};
|
|
}
|
|
}
|