Actualizar Cruce de Cuentas: Panama, Reportar y dist validado
This commit is contained in:
@@ -55,6 +55,7 @@ export default function App() {
|
||||
const [currentView, setCurrentView] = useState<ViewType>('cruce');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [uploadedPayroll, setUploadedPayroll] = useState<UploadedFile | null>(null);
|
||||
const [uploadedPayrollFiles, setUploadedPayrollFiles] = useState<UploadedFile[]>([]);
|
||||
const [uploadedBankFiles, setUploadedBankFiles] = useState<UploadedFile[]>([]);
|
||||
const [isCruceExecuted, setIsCruceExecuted] = useState(false);
|
||||
const [cruceResponse, setCruceResponse] = useState<ReconciliationResponse | null>(null);
|
||||
@@ -235,6 +236,7 @@ export default function App() {
|
||||
}
|
||||
setCurrentView(draft.currentView);
|
||||
setUploadedPayroll(draft.uploadedPayroll);
|
||||
setUploadedPayrollFiles(Array.isArray(draft.uploadedPayrollFiles) ? draft.uploadedPayrollFiles : []);
|
||||
setUploadedBankFiles(draft.uploadedBankFiles);
|
||||
setIsCruceExecuted(draft.isCruceExecuted);
|
||||
setCruceResponse(draft.cruceResponse);
|
||||
@@ -262,6 +264,7 @@ export default function App() {
|
||||
countryCode: selectedCountryCode,
|
||||
currentView,
|
||||
uploadedPayroll,
|
||||
uploadedPayrollFiles,
|
||||
uploadedBankFiles,
|
||||
isCruceExecuted,
|
||||
cruceResponse,
|
||||
@@ -271,6 +274,7 @@ export default function App() {
|
||||
selectedCountryCode,
|
||||
currentView,
|
||||
uploadedPayroll,
|
||||
uploadedPayrollFiles,
|
||||
uploadedBankFiles,
|
||||
isCruceExecuted,
|
||||
cruceResponse,
|
||||
@@ -278,6 +282,7 @@ export default function App() {
|
||||
|
||||
const clearCurrentCruce = () => {
|
||||
setUploadedPayroll(null);
|
||||
setUploadedPayrollFiles([]);
|
||||
setUploadedBankFiles([]);
|
||||
setIsCruceExecuted(false);
|
||||
setCruceResponse(null);
|
||||
@@ -411,6 +416,8 @@ export default function App() {
|
||||
onShowToast={handleShowToast}
|
||||
uploadedPayroll={uploadedPayroll}
|
||||
setUploadedPayroll={setUploadedPayroll}
|
||||
uploadedPayrollFiles={uploadedPayrollFiles}
|
||||
setUploadedPayrollFiles={setUploadedPayrollFiles}
|
||||
uploadedBankFiles={uploadedBankFiles}
|
||||
setUploadedBankFiles={setUploadedBankFiles}
|
||||
isCruceExecuted={isCruceExecuted}
|
||||
|
||||
@@ -63,13 +63,11 @@ export default function BambooCorrectionModal({
|
||||
setConfirmed(false);
|
||||
}, [row.id, suggested?.employeeName, suggested?.employeeNumber]);
|
||||
|
||||
const recipientLabel = useMemo(
|
||||
() =>
|
||||
country.code === 'TT'
|
||||
? 'Laurele Nicome-Jack'
|
||||
: 'Pablo Gamboa Cú, Sara Meyer Lima y María Morales Adler',
|
||||
[country.code],
|
||||
);
|
||||
const recipientLabel = useMemo(() => {
|
||||
if (country.code === 'TT') return 'Laurele Nicome-Jack';
|
||||
if (country.code === 'PA') return 'Delarys Peñalba y Ariadne Cortéz';
|
||||
return 'Pablo Gamboa Cú, Sara Meyer Lima y María Morales Adler';
|
||||
}, [country.code]);
|
||||
|
||||
const sourceNames = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function CountrySelector({ onSelect }: CountrySelectorProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{COUNTRIES.map((country) => (
|
||||
<button
|
||||
key={country.code}
|
||||
|
||||
+241
-24
@@ -18,6 +18,7 @@ import {
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import BambooCorrectionModal from './BambooCorrectionModal';
|
||||
import ReportIssueButton from './ReportIssueButton';
|
||||
import { createClientRequestId, ensureFreshSupabaseSession, warmRemoteOrigin, withFreshRequestQuery } from '../lib/sessionHealth';
|
||||
import {
|
||||
CountryConfig,
|
||||
@@ -33,6 +34,8 @@ interface DashboardProps {
|
||||
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||
uploadedPayroll: UploadedFile | null;
|
||||
setUploadedPayroll: (file: UploadedFile | null) => void;
|
||||
uploadedPayrollFiles: UploadedFile[];
|
||||
setUploadedPayrollFiles: (files: UploadedFile[]) => void;
|
||||
uploadedBankFiles: UploadedFile[];
|
||||
setUploadedBankFiles: (files: UploadedFile[]) => void;
|
||||
isCruceExecuted: boolean;
|
||||
@@ -74,6 +77,13 @@ function getReconciliationUrl(countryCode: string) {
|
||||
return (import.meta.env.VITE_N8N_TRINIDAD_WEBHOOK_URL as string | undefined) || '';
|
||||
}
|
||||
|
||||
if (countryCode === 'PA') {
|
||||
return (
|
||||
(import.meta.env.VITE_N8N_PANAMA_WEBHOOK_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/nominapa-bamboo-test'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(import.meta.env.VITE_N8N_GUATEMALA_WEBHOOK_URL as string | undefined) ||
|
||||
(import.meta.env.VITE_N8N_WEBHOOK_URL as string | undefined) ||
|
||||
@@ -128,6 +138,15 @@ function isCsvFile(file: File) {
|
||||
return file.name.toLowerCase().endsWith('.csv');
|
||||
}
|
||||
|
||||
function toUploadedPayrollFiles(files: FileList | File[]) {
|
||||
return Array.from(files).map((file) => ({
|
||||
name: file.name,
|
||||
size: formatBytes(file.size),
|
||||
type: 'payroll' as const,
|
||||
rawFile: file,
|
||||
}));
|
||||
}
|
||||
|
||||
function toUploadedBankFiles(files: FileList | File[]) {
|
||||
return Array.from(files).map((file) => ({
|
||||
name: file.name,
|
||||
@@ -141,6 +160,12 @@ function toIsoDate(year: number, month: number, day: number) {
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatPeriodDate(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!match) return value;
|
||||
return `${match[3]}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
|
||||
function getPeriodRange(year: number, month: number, periodType: PeriodType): PeriodRange {
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const monthLabel = MONTHS.find((m) => m.value === month)?.label ?? 'Mes';
|
||||
@@ -746,6 +771,8 @@ export default function Dashboard({
|
||||
onShowToast,
|
||||
uploadedPayroll,
|
||||
setUploadedPayroll,
|
||||
uploadedPayrollFiles,
|
||||
setUploadedPayrollFiles,
|
||||
uploadedBankFiles,
|
||||
setUploadedBankFiles,
|
||||
isCruceExecuted,
|
||||
@@ -759,6 +786,7 @@ export default function Dashboard({
|
||||
country,
|
||||
}: DashboardProps) {
|
||||
const defaultPeriod = useMemo(() => readStoredPeriod(country.code), [country.code]);
|
||||
const isPanama = country.code === 'PA';
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [processingStep, setProcessingStep] = useState('');
|
||||
const [filterMode, setFilterMode] = useState<
|
||||
@@ -926,15 +954,7 @@ export default function Dashboard({
|
||||
setFilterMode('todos');
|
||||
};
|
||||
|
||||
const applyPayrollFile = async (file: File) => {
|
||||
setUploadedPayroll({
|
||||
name: file.name,
|
||||
size: formatBytes(file.size),
|
||||
type: 'payroll',
|
||||
rawFile: file,
|
||||
});
|
||||
resetResults();
|
||||
|
||||
const applyDetectedPayrollPeriod = async (file: File, count = 1) => {
|
||||
const detected = await analyzePayrollFilename(file.name, country);
|
||||
const completeDetection =
|
||||
Number.isInteger(detected.year) &&
|
||||
@@ -946,18 +966,69 @@ export default function Dashboard({
|
||||
setSelectedMonth(detected.month!);
|
||||
setPeriodType(detected.periodType!);
|
||||
const detectedRange = getPeriodRange(detected.year!, detected.month!, detected.periodType!);
|
||||
const prefix = count > 1
|
||||
? `${count} archivos de nómina cargados.`
|
||||
: 'Nómina cargada.';
|
||||
|
||||
if (detected.requiresManualReview) {
|
||||
onShowToast(
|
||||
`Nómina cargada. Período sugerido: ${detectedRange.label}. Confirma los datos antes de ejecutar.`,
|
||||
`${prefix} Período sugerido: ${detectedRange.label}. Confirma los datos antes de ejecutar.`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
onShowToast(`Nómina cargada. Período detectado: ${detectedRange.label}.`, 'success');
|
||||
onShowToast(`${prefix} Período detectado: ${detectedRange.label}.`, 'success');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const applyPayrollFile = async (file: File) => {
|
||||
setUploadedPayroll({
|
||||
name: file.name,
|
||||
size: formatBytes(file.size),
|
||||
type: 'payroll',
|
||||
rawFile: file,
|
||||
});
|
||||
setUploadedPayrollFiles([]);
|
||||
resetResults();
|
||||
|
||||
const detected = await applyDetectedPayrollPeriod(file);
|
||||
if (!detected) {
|
||||
onShowToast('Archivo de nómina cargado con éxito.', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const addPanamaPayrollFiles = async (files: File[]) => {
|
||||
const excelFiles = files.filter(isExcelFile);
|
||||
|
||||
if (excelFiles.length === 0) {
|
||||
onShowToast('Formato inválido. Panamá requiere uno o varios archivos Excel (.xlsx, .xls).', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
onShowToast('Archivo de nómina cargado con éxito.', 'success');
|
||||
const existingKeys = new Set(
|
||||
uploadedPayrollFiles.map((file) => `${file.name}|${file.rawFile?.size ?? file.size}`)
|
||||
);
|
||||
const newFiles = toUploadedPayrollFiles(excelFiles).filter(
|
||||
(file) => !existingKeys.has(`${file.name}|${file.rawFile?.size ?? file.size}`)
|
||||
);
|
||||
|
||||
const combined = [...uploadedPayrollFiles, ...newFiles];
|
||||
setUploadedPayrollFiles(combined);
|
||||
setUploadedPayroll(null);
|
||||
resetResults();
|
||||
|
||||
const detectionFile =
|
||||
excelFiles.find((file) => /quincenal/i.test(file.name)) ||
|
||||
excelFiles[0];
|
||||
|
||||
const detected = await applyDetectedPayrollPeriod(detectionFile, combined.length);
|
||||
|
||||
if (!detected) {
|
||||
onShowToast(`${newFiles.length} archivo(s) de nómina de Panamá cargado(s) con éxito.`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
@@ -966,9 +1037,15 @@ export default function Dashboard({
|
||||
|
||||
const handlePayrollDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
const files = Array.from(e.dataTransfer.files) as File[];
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (isPanama) {
|
||||
await addPanamaPayrollFiles(files);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
if (isExcelFile(file)) {
|
||||
await applyPayrollFile(file);
|
||||
} else {
|
||||
@@ -992,9 +1069,15 @@ export default function Dashboard({
|
||||
};
|
||||
|
||||
const handlePayrollSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const files = Array.from(e.target.files || []) as File[];
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (isPanama) {
|
||||
await addPanamaPayrollFiles(files);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
if (isExcelFile(file)) {
|
||||
await applyPayrollFile(file);
|
||||
} else {
|
||||
@@ -1002,6 +1085,15 @@ export default function Dashboard({
|
||||
}
|
||||
};
|
||||
|
||||
const removePanamaPayrollFile = (indexToRemove: number) => {
|
||||
setUploadedPayrollFiles(
|
||||
uploadedPayrollFiles.filter((_, index) => index !== indexToRemove)
|
||||
);
|
||||
if (payrollInputRef.current) payrollInputRef.current.value = '';
|
||||
resetResults();
|
||||
onShowToast('Archivo de nómina removido.', 'info');
|
||||
};
|
||||
|
||||
const handleBankSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -1025,6 +1117,17 @@ export default function Dashboard({
|
||||
onShowToast('Planilla de banco removida.', 'info');
|
||||
};
|
||||
|
||||
const clearPanamaUploadedFiles = () => {
|
||||
setUploadedPayroll(null);
|
||||
setUploadedPayrollFiles([]);
|
||||
setUploadedBankFiles([]);
|
||||
setBankFileSearch('');
|
||||
if (payrollInputRef.current) payrollInputRef.current.value = '';
|
||||
if (bankInputRef.current) bankInputRef.current.value = '';
|
||||
resetResults();
|
||||
onShowToast('Todos los archivos cargados fueron eliminados.', 'info');
|
||||
};
|
||||
|
||||
|
||||
const applyBonus14PayrollFile = (file: File) => {
|
||||
setUploadedBonus14Payroll({
|
||||
@@ -1105,8 +1208,20 @@ export default function Dashboard({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uploadedPayroll?.rawFile) {
|
||||
onShowToast('Debes cargar la nómina antes de ejecutar el cruce.', 'error');
|
||||
const payrollFilesForRun = isPanama
|
||||
? uploadedPayrollFiles
|
||||
: (uploadedPayroll ? [uploadedPayroll] : []);
|
||||
|
||||
if (
|
||||
payrollFilesForRun.length === 0 ||
|
||||
payrollFilesForRun.some((file) => !file.rawFile)
|
||||
) {
|
||||
onShowToast(
|
||||
isPanama
|
||||
? 'Debes cargar la nómina principal y los suplementos de Panamá antes de ejecutar el cruce.'
|
||||
: 'Debes cargar la nómina antes de ejecutar el cruce.',
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1152,6 +1267,10 @@ export default function Dashboard({
|
||||
clientRequestId,
|
||||
);
|
||||
|
||||
const mainPayrollFile =
|
||||
payrollFilesForRun.find((file) => /quincenal/i.test(file.name)) ||
|
||||
payrollFilesForRun[0];
|
||||
|
||||
const metadata = {
|
||||
country: country.code,
|
||||
country_name: country.name,
|
||||
@@ -1161,7 +1280,8 @@ export default function Dashboard({
|
||||
period_label: periodRange.label,
|
||||
period_start: periodRange.start,
|
||||
period_end: periodRange.end,
|
||||
payroll_file_name: uploadedPayroll.name,
|
||||
payroll_file_name: mainPayrollFile.name,
|
||||
payroll_file_names: payrollFilesForRun.map((file) => file.name),
|
||||
bank_file_names: uploadedBankFiles.map((file) => file.name),
|
||||
requested_by_name: operatorName,
|
||||
requested_by_email: operatorEmail,
|
||||
@@ -1177,7 +1297,19 @@ export default function Dashboard({
|
||||
Object.entries(metadata).forEach(([key, value]) => {
|
||||
formData.append(key, Array.isArray(value) ? JSON.stringify(value) : String(value));
|
||||
});
|
||||
formData.append('payroll_file', uploadedPayroll.rawFile, uploadedPayroll.name);
|
||||
if (isPanama) {
|
||||
payrollFilesForRun.forEach((file, index) => {
|
||||
if (!file.rawFile) return;
|
||||
formData.append('payroll_files', file.rawFile, file.name);
|
||||
formData.append(`payroll_file_${index + 1}`, file.rawFile, file.name);
|
||||
});
|
||||
if (mainPayrollFile.rawFile) {
|
||||
formData.append('payroll_file', mainPayrollFile.rawFile, mainPayrollFile.name);
|
||||
}
|
||||
} else if (mainPayrollFile.rawFile) {
|
||||
formData.append('payroll_file', mainPayrollFile.rawFile, mainPayrollFile.name);
|
||||
}
|
||||
|
||||
uploadedBankFiles.forEach((file, index) => {
|
||||
if (file.rawFile) {
|
||||
formData.append('bank_files', file.rawFile, file.name);
|
||||
@@ -1415,8 +1547,15 @@ export default function Dashboard({
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 flex items-center" aria-label={`Bandera de ${country.name}`}>
|
||||
<div className="border border-neutral-200 bg-white px-2.5 py-2 shadow-sm">
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<ReportIssueButton
|
||||
country={country}
|
||||
operatorName={operatorName}
|
||||
operatorEmail={operatorEmail}
|
||||
section="Cruce de Cuentas"
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
<div className="border border-neutral-200 bg-white px-2.5 py-2 shadow-sm" aria-label={`Bandera de ${country.name}`}>
|
||||
<img src={country.flag} alt={`Bandera de ${country.name}`} className="h-5 sm:h-6 w-auto object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1490,7 +1629,7 @@ export default function Dashboard({
|
||||
<CalendarDays className="w-4 h-4 text-[#4F758B] mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-black text-[#4F758B]">Período calculado:</span>{' '}
|
||||
{periodRange.start} al {periodRange.end}
|
||||
{formatPeriodDate(periodRange.start)} al {formatPeriodDate(periodRange.end)}
|
||||
<p className="text-[10px] text-neutral-500 mt-0.5">{selectedPeriodDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1500,6 +1639,21 @@ export default function Dashboard({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isPanama && (uploadedPayrollFiles.length > 0 || uploadedBankFiles.length > 0) && (
|
||||
<div className="flex justify-end" style={{ marginBottom: '-20px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearPanamaUploadedFiles}
|
||||
className="inline-flex items-center gap-2 border border-red-100 bg-white px-3 py-2 text-[10px] font-black text-red-600 hover:bg-red-50 transition-colors uppercase tracking-wide shadow-sm"
|
||||
aria-label="Borrar todos los archivos cargados"
|
||||
title="Eliminar de una vez todas las nóminas y planillas del banco cargadas"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Borrar todo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white border border-[#D0D0D0] p-6 flex flex-col justify-between relative shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-[#4F758B]" />
|
||||
@@ -1514,10 +1668,72 @@ export default function Dashboard({
|
||||
ref={payrollInputRef}
|
||||
onChange={handlePayrollSelect}
|
||||
accept=".xlsx,.xls"
|
||||
multiple={isPanama}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{uploadedPayroll ? (
|
||||
{isPanama ? (
|
||||
uploadedPayrollFiles.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="border border-[#4F758B]/20 bg-[#4F758B]/5 px-4 py-3">
|
||||
<p className="text-[10px] font-black uppercase tracking-wider text-[#4F758B]">
|
||||
{uploadedPayrollFiles.length} archivo(s) de nómina cargado(s)
|
||||
</p>
|
||||
<p className="text-[10px] text-neutral-500 mt-1">
|
||||
Incluye la quincenal y todas las planillas extraordinarias, vacaciones o XIII Mes que correspondan al período.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[260px] overflow-y-auto border border-neutral-200 divide-y divide-neutral-100">
|
||||
{uploadedPayrollFiles.map((file, index) => (
|
||||
<div key={`${file.name}-${index}`} className="bg-white px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FileSpreadsheet className="w-6 h-6 text-[#6CC24A] flex-shrink-0" />
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-xs font-bold text-neutral-700 truncate max-w-[290px]" title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-neutral-400 font-mono mt-0.5">{file.size} • Excel</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removePanamaPayrollFile(index)}
|
||||
className="p-1.5 text-neutral-400 hover:text-red-500 hover:bg-neutral-100 transition-all rounded"
|
||||
aria-label={`Remover ${file.name}`}
|
||||
>
|
||||
<Trash2 className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => payrollInputRef.current?.click()}
|
||||
className="w-full border border-dashed border-[#4F758B]/40 bg-white px-4 py-2.5 text-[10px] font-black text-[#4F758B] hover:bg-[#4F758B]/5 transition-colors uppercase tracking-wide"
|
||||
>
|
||||
Agregar más archivos de nómina
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handlePayrollDrop}
|
||||
onClick={() => payrollInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-[#D0D0D0] bg-neutral-50 hover:bg-[#4F758B]/5 hover:border-[#4F758B]/40 transition-colors flex flex-col items-center justify-center py-10 px-4 text-center cursor-pointer min-h-[190px]"
|
||||
>
|
||||
<Upload className="w-8 h-8 text-neutral-300 mb-2" />
|
||||
<p className="text-xs font-bold text-neutral-700">Arrastre la nómina y sus suplementos</p>
|
||||
<p className="text-[10px] text-neutral-400 mt-1 font-medium">Puede seleccionar varios .xlsx / .xls a la vez</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 border border-[#D0D0D0] bg-white px-4 py-1.5 text-[10px] font-bold text-neutral-600 hover:border-[#4F758B] hover:text-[#4F758B] transition-colors shadow-sm uppercase tracking-wide"
|
||||
>
|
||||
Seleccionar Archivos
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : uploadedPayroll ? (
|
||||
<div className="border border-[#6CC24A]/30 bg-[#6CC24A]/5 p-5 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FileSpreadsheet className="w-8 h-8 text-[#6CC24A] flex-shrink-0" />
|
||||
@@ -1557,6 +1773,7 @@ export default function Dashboard({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { CountryConfig, HistoricalReport, PeriodRange, ReconciliationRow } from '../types';
|
||||
import BambooCorrectionModal from './BambooCorrectionModal';
|
||||
import ReportIssueButton from './ReportIssueButton';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface HistoryProps {
|
||||
@@ -32,6 +33,13 @@ function getHistoryUrl(countryCode: string) {
|
||||
return (import.meta.env.VITE_HISTORICOS_TT_URL as string | undefined) || '';
|
||||
}
|
||||
|
||||
if (countryCode === 'PA') {
|
||||
return (
|
||||
(import.meta.env.VITE_HISTORICOS_PA_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-pa-historicos'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(import.meta.env.VITE_HISTORICOS_GT_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-historicos'
|
||||
@@ -43,6 +51,13 @@ function getMarkResolvedUrl(countryCode: string) {
|
||||
return (import.meta.env.VITE_MARCAR_RESUELTO_TT_URL as string | undefined) || '';
|
||||
}
|
||||
|
||||
if (countryCode === 'PA') {
|
||||
return (
|
||||
(import.meta.env.VITE_MARCAR_RESUELTO_PA_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-pa-marcar-resuelto'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(import.meta.env.VITE_MARCAR_RESUELTO_GT_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto'
|
||||
@@ -128,7 +143,7 @@ function normalizeReports(input: unknown, country: CountryConfig): HistoricalRep
|
||||
|
||||
return {
|
||||
id: String(report.id ?? `reporte-${index + 1}`),
|
||||
country: (rawCountry === 'TT' ? 'TT' : 'GT') as HistoricalReport['country'],
|
||||
country: (rawCountry === 'TT' ? 'TT' : rawCountry === 'PA' ? 'PA' : 'GT') as HistoricalReport['country'],
|
||||
date: formatDate(report.fecha ?? report.period_end ?? rawExecutionDate, country.locale),
|
||||
executionDate: formatDateTime(rawExecutionDate, country.locale),
|
||||
period: String(report.periodo ?? report.period_label ?? report.period ?? 'Período no especificado'),
|
||||
@@ -442,7 +457,7 @@ export default function History({
|
||||
if (!isCurrentRequest()) return;
|
||||
|
||||
// Respaldo temporal: webhook histórico existente. También se envía el país
|
||||
// para impedir que se mezclen reportes de Guatemala y Trinidad y Tobago.
|
||||
// para impedir que se mezclen reportes de Guatemala, Trinidad y Tobago y Panamá.
|
||||
const historyUrl = getHistoryUrl(country.code);
|
||||
if (!historyUrl) {
|
||||
throw new Error(`No está configurado el origen histórico de ${country.name}.`);
|
||||
@@ -708,7 +723,14 @@ export default function History({
|
||||
<span className="truncate">Reportes Históricos - {country.name}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<ReportIssueButton
|
||||
country={country}
|
||||
operatorName={operatorName}
|
||||
operatorEmail={operatorEmail}
|
||||
section="Reportes Históricos"
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
<div className="hidden sm:flex items-center border border-neutral-200 bg-white px-2.5 py-2 shadow-sm" aria-label={`Bandera de ${country.name}`}>
|
||||
<img src={country.flag} alt={`Bandera de ${country.name}`} className="h-5 sm:h-6 w-auto object-contain" />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { getAuthRedirectUrl, isSupabaseConfigured, supabase } from '../lib/supabase';
|
||||
import {
|
||||
getAuthRedirectUrl,
|
||||
isSupabaseConfigured,
|
||||
refreshAuthEntryDocumentCache,
|
||||
supabase,
|
||||
} from '../lib/supabase';
|
||||
|
||||
interface LoginProps {
|
||||
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||
@@ -18,6 +23,12 @@ export default function Login({ onShowToast }: LoginProps) {
|
||||
|
||||
setIsLoggingIn(true);
|
||||
|
||||
// El OAuth vuelve a la misma URL base de la aplicación. Antes de salir,
|
||||
// refrescamos esa entrada en la caché HTTP del navegador para evitar que el
|
||||
// retorno de Google restaure un index.html perteneciente a un despliegue
|
||||
// anterior. Si la revalidación falla, el login continúa de todos modos.
|
||||
await refreshAuthEntryDocumentCache();
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Loader2, MessageSquareWarning, Send, X } from 'lucide-react';
|
||||
import { CountryConfig } from '../types';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ensureFreshSupabaseSession } from '../lib/sessionHealth';
|
||||
|
||||
const REPORT_APP_URL =
|
||||
(import.meta.env.VITE_REPORTAR_APP_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-reportar';
|
||||
|
||||
interface ReportIssueButtonProps {
|
||||
country: CountryConfig;
|
||||
operatorName: string;
|
||||
operatorEmail: string;
|
||||
section: string;
|
||||
onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
function parseJsonSafely(text: string) {
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createReportSessionId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `report-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export default function ReportIssueButton({
|
||||
country,
|
||||
operatorName,
|
||||
operatorEmail,
|
||||
section,
|
||||
onShowToast,
|
||||
}: ReportIssueButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
if (isSubmitting) return;
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const description = message.trim();
|
||||
|
||||
if (!description) {
|
||||
onShowToast('Escribe una descripción del reporte.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!REPORT_APP_URL) {
|
||||
onShowToast('Falta configurar VITE_REPORTAR_APP_URL.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
onShowToast('Supabase no está configurado en esta aplicación.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const session = await ensureFreshSupabaseSession(300);
|
||||
const accessToken = session?.access_token;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Tu sesión expiró. Inicia sesión nuevamente antes de enviar el reporte.');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
message: description.slice(0, 5000),
|
||||
userName: operatorName,
|
||||
userEmail: operatorEmail,
|
||||
country: country.code,
|
||||
countryName: country.name,
|
||||
section,
|
||||
submittedAt: new Date().toISOString(),
|
||||
pageUrl: window.location.href,
|
||||
appSessionId: createReportSessionId(),
|
||||
};
|
||||
|
||||
// FormData evita una preflight CORS innecesaria. El token viaja únicamente
|
||||
// por HTTPS y el workflow de n8n lo valida contra Supabase.
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('access_token', accessToken);
|
||||
|
||||
const response = await fetch(REPORT_APP_URL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
const responsePayload = parseJsonSafely(responseText);
|
||||
|
||||
if (!response.ok || responsePayload?.ok === false) {
|
||||
const errorMessage =
|
||||
responsePayload?.message ||
|
||||
responsePayload?.error ||
|
||||
responseText ||
|
||||
'No se pudo enviar el reporte.';
|
||||
throw new Error(String(errorMessage));
|
||||
}
|
||||
|
||||
setMessage('');
|
||||
setIsOpen(false);
|
||||
onShowToast('Reporte enviado correctamente.', 'success');
|
||||
} catch (error) {
|
||||
onShowToast(
|
||||
error instanceof Error ? error.message : 'No se pudo enviar el reporte.',
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex items-center justify-center gap-2 border border-[#4F758B]/25 bg-white text-[#4F758B] h-9 px-2.5 sm:px-3 text-xs font-black hover:border-[#4F758B]/55 hover:bg-[#4F758B]/5 transition-colors shadow-sm"
|
||||
title="Reportar un error o incidencia"
|
||||
aria-label="Reportar"
|
||||
>
|
||||
<MessageSquareWarning className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Reportar</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center px-4 py-6">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/45"
|
||||
onClick={closeModal}
|
||||
aria-label="Cerrar reporte"
|
||||
/>
|
||||
|
||||
<div className="relative w-full max-w-2xl bg-white border border-[#D0D0D0] shadow-2xl">
|
||||
<div className="h-1.5 bg-[#6CC24A]" />
|
||||
|
||||
<div className="px-6 py-5 border-b border-neutral-200 flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-black text-[#4F758B]">Reporte</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
disabled={isSubmitting}
|
||||
className="p-2 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 disabled:opacity-40"
|
||||
aria-label="Cerrar modal"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<label className="block">
|
||||
<span className="block text-[10px] uppercase tracking-wider font-black text-[#4F758B] mb-1.5">
|
||||
Descripción del Reporte *
|
||||
</span>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value.slice(0, 5000))}
|
||||
rows={7}
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
placeholder="Escribe aquí el error, incidencia o mejora que deseas reportar..."
|
||||
className="w-full resize-y min-h-[150px] border border-[#D0D0D0] bg-white px-3.5 py-3 text-sm text-neutral-700 placeholder-neutral-400 focus:outline-none focus:border-[#4F758B] disabled:bg-neutral-50"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-1.5 text-right text-[10px] text-neutral-400">
|
||||
{message.length}/5000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-neutral-200 bg-neutral-50 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
disabled={isSubmitting}
|
||||
className="border border-neutral-300 bg-white text-neutral-600 px-4 py-2.5 text-xs font-bold hover:bg-neutral-100 disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isSubmitting || !message.trim()}
|
||||
className="bg-[#4F758B] hover:bg-[#3F6174] text-white px-4 py-2.5 text-xs font-black inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
<span>{isSubmitting ? 'Enviando...' : 'Enviar reporte'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,14 @@ export const COUNTRIES: CountryConfig[] = [
|
||||
locale: 'en-TT',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
code: 'PA',
|
||||
name: 'Panamá',
|
||||
flag: 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Panama.svg',
|
||||
currency: 'USD',
|
||||
locale: 'es-PA',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_COUNTRY_CODE: CountryCode = 'GT';
|
||||
|
||||
@@ -18,3 +18,44 @@ export const supabase = isSupabaseConfigured
|
||||
export function getAuthRedirectUrl() {
|
||||
return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuerza una revalidación del documento de entrada justo antes de abandonar la
|
||||
* app para iniciar OAuth. El callback vuelve exactamente a la misma URL y, si el
|
||||
* navegador conserva un index.html anterior en su caché HTTP, puede mostrar por
|
||||
* unos segundos/una navegación una versión vieja del frontend hasta que el
|
||||
* usuario recarga manualmente.
|
||||
*
|
||||
* `cache: 'reload'` pide el HTML al servidor ignorando la copia almacenada y
|
||||
* permite que el navegador refresque su caché con la versión actualmente
|
||||
* desplegada. Es una operación best-effort: un fallo aquí nunca bloquea el
|
||||
* inicio de sesión; el OAuth continúa normalmente.
|
||||
*/
|
||||
export async function refreshAuthEntryDocumentCache() {
|
||||
if (typeof window === 'undefined' || typeof fetch === 'undefined') return;
|
||||
|
||||
const entryUrl = getAuthRedirectUrl();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
try {
|
||||
const response = await fetch(entryUrl, {
|
||||
method: 'GET',
|
||||
cache: 'reload',
|
||||
credentials: 'same-origin',
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Consumir el body completa la petición y evita dejar una respuesta
|
||||
// pendiente antes de que el navegador salga hacia Google.
|
||||
await response.text();
|
||||
} catch (error) {
|
||||
console.warn('No se pudo refrescar preventivamente la entrada de la app:', error);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface TabSessionDraft {
|
||||
countryCode: CountryCode | null;
|
||||
currentView: ViewType;
|
||||
uploadedPayroll: UploadedFile | null;
|
||||
uploadedPayrollFiles: UploadedFile[];
|
||||
uploadedBankFiles: UploadedFile[];
|
||||
isCruceExecuted: boolean;
|
||||
cruceResponse: ReconciliationResponse | null;
|
||||
|
||||
@@ -3,6 +3,16 @@ import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
// Algunos navegadores pueden restaurar una página completa desde BFCache al
|
||||
// volver de un proveedor OAuth. Si eso ocurre, recargamos una sola vez para que
|
||||
// la aplicación use siempre los assets del despliegue vigente. Una recarga
|
||||
// normal no vuelve a entrar aquí porque `persisted` será false.
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export type ViewType = 'cruce' | 'historial';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export type CountryCode = 'GT' | 'TT';
|
||||
export type CountryCode = 'GT' | 'TT' | 'PA';
|
||||
|
||||
export interface CountryConfig {
|
||||
code: CountryCode;
|
||||
|
||||
Vendored
+4
@@ -4,12 +4,16 @@ interface ImportMetaEnv {
|
||||
readonly VITE_N8N_WEBHOOK_URL?: string;
|
||||
readonly VITE_N8N_GUATEMALA_WEBHOOK_URL?: string;
|
||||
readonly VITE_N8N_TRINIDAD_WEBHOOK_URL?: string;
|
||||
readonly VITE_N8N_PANAMA_WEBHOOK_URL?: string;
|
||||
readonly VITE_HISTORICOS_GT_URL?: string;
|
||||
readonly VITE_MARCAR_RESUELTO_GT_URL?: string;
|
||||
readonly VITE_HISTORICOS_TT_URL?: string;
|
||||
readonly VITE_MARCAR_RESUELTO_TT_URL?: string;
|
||||
readonly VITE_HISTORICOS_PA_URL?: string;
|
||||
readonly VITE_MARCAR_RESUELTO_PA_URL?: string;
|
||||
readonly VITE_ANALIZAR_NOMINA_URL?: string;
|
||||
readonly VITE_REPORTAR_BAMBOO_URL?: string;
|
||||
readonly VITE_REPORTAR_APP_URL?: string;
|
||||
readonly VITE_ENABLE_SUPABASE_AUTH?: string;
|
||||
readonly VITE_SUPABASE_URL?: string;
|
||||
readonly VITE_SUPABASE_ANON_KEY?: string;
|
||||
|
||||
Reference in New Issue
Block a user