Update project files

This commit is contained in:
2026-07-13 16:10:01 -04:00
parent a6e66e6a89
commit e160a0901c
29 changed files with 3583 additions and 2898 deletions
+31 -5
View File
@@ -1,7 +1,33 @@
# Supabase Auth con Google
# Crea un archivo .env.local y coloca tus valores reales.
VITE_SUPABASE_URL="https://TU-PROYECTO.supabase.co"
# Portal multinacional - Cruce de Cuentas GLM
# Copiar como .env.local y ajustar los valores por ambiente.
# Ruta pública actual. Puede cambiarse a /cruce-cuentas/ cuando el portal
# multinacional sea publicado en una ruta general.
VITE_BASE_PATH="/cruce-cuentas/guatemala/"
# Guatemala: servicio principal que ejecuta el cruce Nómina vs Banco.
VITE_N8N_GUATEMALA_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominagt-bamboo-test"
# Compatibilidad con instalaciones anteriores.
VITE_N8N_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominagt-bamboo-test"
# Guatemala: históricos y resolución.
VITE_HISTORICOS_GT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-historicos"
VITE_MARCAR_RESUELTO_GT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto"
# Trinidad y Tobago: completar cuando los workflows estén listos.
VITE_N8N_TRINIDAD_WEBHOOK_URL=""
VITE_HISTORICOS_TT_URL=""
VITE_MARCAR_RESUELTO_TT_URL=""
# Endpoint opcional de n8n + Gemini para analizar el nombre del archivo de nómina.
# La app ya incluye detección local como respaldo.
VITE_ANALIZAR_NOMINA_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-analizar-nomina"
# Login Google vía Supabase.
VITE_ENABLE_SUPABASE_AUTH="true"
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
VITE_SUPABASE_ANON_KEY="TU_SUPABASE_ANON_KEY"
# URL pública esperada para producción:
# https://digitalcompass.agency/cruce-cuentas/
# Usuario temporal solo si VITE_ENABLE_SUPABASE_AUTH="false".
VITE_OPERATOR_NAME="Isaac Aracena"
VITE_OPERATOR_EMAIL="iaracena@gomezleemarketing.com"
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
+6
View File
@@ -0,0 +1,6 @@
Portal de Cruce de Cuentas GLM — Guatemala
Build validado: 2026-07-13
Ruta pública: /cruce-cuentas/guatemala/
Cruce GT: https://agenteit.digitalcompass.agency/webhook/nominagt-bamboo-test
Análisis de nómina: https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-analizar-nomina
Validaciones ejecutadas: npm run lint, npm run build
+4 -2
View File
@@ -1,9 +1,11 @@
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
<title>Portal de Cruce de Cuentas GLM</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" />
</head>
<body>
<div id="root"></div>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "Cruce de Cuentas GLM",
"description": "Portal centralizado de Cruce de Cuentas GLM para la conciliación de cuentas regionales.",
"name": "Portal de Cruce de Cuentas GLM",
"description": "Portal multinacional de GomezLee Marketing para conciliación de nóminas y planillas bancarias.",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}
+546 -2253
View File
File diff suppressed because it is too large Load Diff
+8 -17
View File
@@ -1,36 +1,27 @@
{
"name": "cruce-de-cuentas-glm",
"name": "cruce-cuentas-glm-guatemala",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"@supabase/supabase-js": "^2.86.0",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24",
"@supabase/supabase-js": "^2.77.0"
"react": "^19.0.1",
"react-dom": "^19.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"@vitejs/plugin-react": "^5.0.4",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
"vite": "^6.2.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+250 -105
View File
@@ -1,144 +1,289 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import Sidebar from './components/Sidebar';
import Dashboard from './components/Dashboard';
import History from './components/History';
import Toast from './components/Toast';
import Login from './components/Login';
import CountrySelector from './components/CountrySelector';
import {
AppUser,
CountryCode,
ReconciliationResponse,
ToastType,
UploadedFile,
ViewType,
} from './types';
import { isAllowedEmail } from './lib/auth';
import { isSupabaseConfigured, supabase } from './lib/supabase';
import { COUNTRIES, getCountryConfig } from './config/countries';
import { useEffect, useState } from "react";
import type { Session } from "@supabase/supabase-js";
import { AnimatePresence, motion } from "motion/react";
import { LoginScreen } from "./components/LoginScreen";
import { PortalPrincipal } from "./components/PortalPrincipal";
import { ALLOWED_EMAILS, isAllowedEmail } from "./lib/auth";
import { isSupabaseConfigured, supabase } from "./lib/supabase";
const AUTH_ENABLED = import.meta.env.VITE_ENABLE_SUPABASE_AUTH === 'true';
const COUNTRY_STORAGE_KEY = 'glm_cruce_selected_country';
const TEMPORARY_USER: AppUser = {
name: (import.meta.env.VITE_OPERATOR_NAME as string | undefined) || 'Usuario GLM',
email: (import.meta.env.VITE_OPERATOR_EMAIL as string | undefined) || 'usuario@gomezleemarketing.com',
};
function getDisplayNameFromSessionUser(user: any): string {
const metadata = user?.user_metadata ?? {};
const fullName = metadata.full_name || metadata.name || metadata.display_name;
if (fullName) return String(fullName);
const email = String(user?.email ?? 'Usuario GLM');
return email.includes('@') ? email.split('@')[0] : email;
}
function getStoredCountry(): CountryCode | null {
try {
const stored = window.localStorage.getItem(COUNTRY_STORAGE_KEY) as CountryCode | null;
const country = COUNTRIES.find((item) => item.code === stored && item.enabled);
return country?.code ?? null;
} catch {
return null;
}
}
export default function App() {
const [session, setSession] = useState<Session | null>(null);
const [isLoadingSession, setIsLoadingSession] = useState(true);
const [authError, setAuthError] = useState("");
const [currentView, setCurrentView] = useState<ViewType>('cruce');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [uploadedPayroll, setUploadedPayroll] = useState<UploadedFile | null>(null);
const [uploadedBankFiles, setUploadedBankFiles] = useState<UploadedFile[]>([]);
const [isCruceExecuted, setIsCruceExecuted] = useState(false);
const [cruceResponse, setCruceResponse] = useState<ReconciliationResponse | null>(null);
const [cruceResetToken, setCruceResetToken] = useState(0);
const [selectedCountryCode, setSelectedCountryCode] = useState<CountryCode | null>(() => getStoredCountry());
const [currentUser, setCurrentUser] = useState<AppUser | null>(AUTH_ENABLED ? null : TEMPORARY_USER);
const [authLoading, setAuthLoading] = useState(AUTH_ENABLED);
const [toast, setToast] = useState<{ message: string; type: ToastType } | null>(null);
const validateAndSetSession = async (currentSession: Session | null) => {
const email = currentSession?.user?.email;
const authReady = useMemo(() => AUTH_ENABLED && isSupabaseConfigured && supabase, []);
const selectedCountry = selectedCountryCode ? getCountryConfig(selectedCountryCode) : null;
if (!currentSession) {
setSession(null);
return;
}
if (!isAllowedEmail(email)) {
setSession(null);
setAuthError(
"Este correo no tiene acceso al portal. Usa ymadera@gomezleemarketing.com o iaracena@gomezleemarketing.com."
);
await supabase?.auth.signOut();
return;
}
setAuthError("");
setSession(currentSession);
const handleShowToast = (message: string, type: ToastType) => {
setToast({ message, type });
};
useEffect(() => {
if (!isSupabaseConfigured || !supabase) {
setAuthError("Falta configurar Supabase en el archivo .env.local.");
setIsLoadingSession(false);
if (!AUTH_ENABLED) return;
if (!authReady || !supabase) {
setAuthLoading(false);
return;
}
let isMounted = true;
supabase.auth.getSession().then(async ({ data, error }) => {
if (!isMounted) return;
if (error) {
setAuthError("No se pudo validar la sesión. Intenta iniciar sesión nuevamente.");
setSession(null);
} else {
await validateAndSetSession(data.session);
const applySessionUser = async (sessionUser: any) => {
if (!sessionUser?.email) {
setCurrentUser(null);
return;
}
setIsLoadingSession(false);
});
if (!isAllowedEmail(sessionUser.email)) {
await supabase.auth.signOut();
setCurrentUser(null);
handleShowToast('Tu usuario no tiene acceso autorizado a este módulo.', 'error');
return;
}
const { data: listener } = supabase.auth.onAuthStateChange(async (_event, currentSession) => {
await validateAndSetSession(currentSession);
setIsLoadingSession(false);
setCurrentUser({
name: getDisplayNameFromSessionUser(sessionUser),
email: sessionUser.email,
});
};
const loadSession = async () => {
const { data } = await supabase.auth.getSession();
if (!isMounted) return;
await applySessionUser(data.session?.user);
setAuthLoading(false);
};
loadSession();
const { data: listener } = supabase.auth.onAuthStateChange(async (_event, session) => {
await applySessionUser(session?.user);
});
return () => {
isMounted = false;
listener.subscription.unsubscribe();
};
}, []);
}, [authReady]);
const handleLogin = async () => {
setAuthError("");
if (!isSupabaseConfigured || !supabase) {
setAuthError("Falta configurar VITE_SUPABASE_URL y VITE_SUPABASE_ANON_KEY en .env.local.");
return;
}
const redirectTo = `${window.location.origin}${import.meta.env.BASE_URL}`;
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo,
},
});
if (error) {
setAuthError("No se pudo iniciar sesión con Google. Revisa la configuración de Supabase Auth.");
}
const clearCurrentCruce = () => {
setUploadedPayroll(null);
setUploadedBankFiles([]);
setIsCruceExecuted(false);
setCruceResponse(null);
setCruceResetToken((token) => token + 1);
};
const handleLogout = async () => {
setAuthError("");
await supabase?.auth.signOut();
setSession(null);
if (supabase) await supabase.auth.signOut();
setCurrentUser(AUTH_ENABLED ? null : TEMPORARY_USER);
setSelectedCountryCode(null);
window.localStorage.removeItem(COUNTRY_STORAGE_KEY);
clearCurrentCruce();
setCurrentView('cruce');
handleShowToast('Sesión cerrada correctamente.', 'success');
};
if (isLoadingSession) {
const handleNewCruce = () => {
clearCurrentCruce();
setCurrentView('cruce');
handleShowToast('Nuevo cruce iniciado.', 'success');
};
const handleSelectCountry = (countryCode: CountryCode) => {
const country = getCountryConfig(countryCode);
if (!country.enabled) {
handleShowToast(`${country.name} estará disponible próximamente.`, 'info');
return;
}
if (selectedCountryCode && selectedCountryCode !== countryCode) {
clearCurrentCruce();
}
setSelectedCountryCode(countryCode);
window.localStorage.setItem(COUNTRY_STORAGE_KEY, countryCode);
setCurrentView('cruce');
setIsSidebarOpen(false);
};
const handleChooseAnotherCountry = () => {
setSelectedCountryCode(null);
setIsSidebarOpen(false);
};
if (authLoading) {
return (
<div className="min-h-screen bg-[#f5f3f3] flex items-center justify-center text-[#365c71] text-sm font-semibold">
Validando sesión...
<div className="min-h-screen bg-[#F5F5F5] flex items-center justify-center text-[#4F758B] font-black uppercase tracking-wider">
Validando acceso...
</div>
);
}
const isLoggedIn = Boolean(session && isAllowedEmail(session.user.email));
if (AUTH_ENABLED && !currentUser) {
return (
<>
<Login onShowToast={handleShowToast} />
<AnimatePresence>
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
</AnimatePresence>
</>
);
}
if (!selectedCountry) {
return (
<>
<CountrySelector onSelect={handleSelectCountry} />
<AnimatePresence>
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
</AnimatePresence>
</>
);
}
const activeUser = currentUser ?? TEMPORARY_USER;
return (
<div className="min-h-screen bg-[#fbf9f8]" id="app-container">
<AnimatePresence mode="wait">
{!isLoggedIn ? (
<motion.div
key="login"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="w-full h-full"
>
<LoginScreen
onLogin={handleLogin}
authError={authError}
isSupabaseConfigured={isSupabaseConfigured}
allowedEmails={ALLOWED_EMAILS}
<div className="min-h-screen bg-[#F5F5F5] selection:bg-[#6CC24A] selection:text-white antialiased font-sans flex flex-col">
<motion.div
key={`interface-${selectedCountry.code}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen flex w-full relative overflow-x-hidden"
>
<AnimatePresence>
{isSidebarOpen && (
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsSidebarOpen(false)}
className="fixed inset-0 bg-black/45 z-45 md:hidden"
/>
</motion.div>
) : (
<motion.div
key="portal"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="w-full h-full"
>
<PortalPrincipal onLogout={handleLogout} userEmail={session?.user.email ?? ""} />
</motion.div>
)}
)}
</AnimatePresence>
<Sidebar
currentView={currentView}
onViewChange={(view) => {
setCurrentView(view);
setIsSidebarOpen(false);
}}
onNewCruce={() => {
handleNewCruce();
setIsSidebarOpen(false);
}}
isSidebarOpen={isSidebarOpen}
onCloseSidebar={() => setIsSidebarOpen(false)}
user={activeUser}
isAuthEnabled={AUTH_ENABLED}
onLogout={handleLogout}
country={selectedCountry}
onCountryChange={handleSelectCountry}
onChooseAnotherCountry={handleChooseAnotherCountry}
/>
<div className="pl-0 md:pl-64 flex-1 flex flex-col w-full min-h-screen transition-all duration-300">
<AnimatePresence mode="wait">
{currentView === 'cruce' ? (
<motion.div
key={`view-cruce-${selectedCountry.code}`}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
className="flex-1 flex flex-col"
>
<Dashboard
onShowToast={handleShowToast}
uploadedPayroll={uploadedPayroll}
setUploadedPayroll={setUploadedPayroll}
uploadedBankFiles={uploadedBankFiles}
setUploadedBankFiles={setUploadedBankFiles}
isCruceExecuted={isCruceExecuted}
setIsCruceExecuted={setIsCruceExecuted}
response={cruceResponse}
setResponse={setCruceResponse}
onOpenSidebar={() => setIsSidebarOpen(true)}
operatorName={activeUser.name}
operatorEmail={activeUser.email}
resetToken={cruceResetToken}
country={selectedCountry}
/>
</motion.div>
) : (
<motion.div
key={`view-history-${selectedCountry.code}`}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
className="flex-1 flex flex-col"
>
<History
onShowToast={handleShowToast}
onOpenSidebar={() => setIsSidebarOpen(true)}
operatorName={activeUser.name}
operatorEmail={activeUser.email}
country={selectedCountry}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
<AnimatePresence>
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
</AnimatePresence>
</div>
);
-47
View File
@@ -1,47 +0,0 @@
import { motion } from "motion/react";
import { ArrowRight } from "lucide-react";
import { Country } from "../data";
interface CountryCardProps {
country: Country;
onSelect: (country: Country) => void;
key?: string;
}
export function CountryCard({ country, onSelect }: CountryCardProps) {
return (
<motion.article
whileHover={{ scale: 1.01, transition: { duration: 0.15 } }}
className="bg-white border border-[#D0D0D0] border-l-4 border-l-[#6CC24A] p-6 flex flex-col justify-between min-h-[220px] rounded-none shadow-none hover:bg-[#F5F5F5] transition-colors group"
id={`card-${country.code.toLowerCase()}`}
>
<div>
{/* Country Flag Container */}
<div className="mb-4">
<img
alt={`Bandera de ${country.name}`}
className="w-12 h-8 object-cover border border-[#D0D0D0] rounded-none"
src={country.flagUrl}
referrerPolicy="no-referrer"
/>
</div>
{/* Country Name */}
<h3 className="text-xl font-bold text-[#4F758B] mb-2" id={`title-${country.code.toLowerCase()}`}>
{country.name}
</h3>
</div>
{/* Action Button */}
<button
onClick={() => onSelect(country)}
className="bg-[#4F758B] text-white text-xs font-semibold py-3 px-4 w-full rounded-none mt-6 hover:bg-[#365C71] active:bg-[#234b60] transition-colors text-left flex justify-between items-center group-hover:bg-[#365C71] cursor-pointer focus:outline-none"
title={`Cruce de Cuentas GLM - ${country.code}`}
id={`btn-cruce-${country.code.toLowerCase()}`}
>
<span>Cruce de Cuentas GLM - {country.code}</span>
<ArrowRight className="w-4 h-4 text-white group-hover:translate-x-1 transition-transform" />
</button>
</motion.article>
);
}
+73
View File
@@ -0,0 +1,73 @@
import React from 'react';
import { motion } from 'motion/react';
import { ArrowRight, Globe2, LockKeyhole } from 'lucide-react';
import { COUNTRIES } from '../config/countries';
import { CountryCode } from '../types';
interface CountrySelectorProps {
onSelect: (country: CountryCode) => void;
}
const LOGO_URL = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';
export default function CountrySelector({ onSelect }: CountrySelectorProps) {
return (
<div className="min-h-screen bg-[#F5F5F5] flex flex-col font-sans">
<div className="h-1.5 bg-[#6CC24A]" />
<main className="flex-1 flex items-center justify-center px-4 py-10">
<motion.section
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-3xl bg-white border border-[#D0D0D0] shadow-xl p-6 sm:p-9"
>
<img src={LOGO_URL} alt="GomezLee Marketing" className="h-14 object-contain mb-7" />
<div className="flex items-start gap-3 mb-7">
<div className="bg-[#4F758B]/10 p-2.5 rounded-full">
<Globe2 className="w-5 h-5 text-[#4F758B]" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-black text-[#4F758B] uppercase tracking-tight">
Selecciona el país
</h1>
<p className="text-sm text-neutral-500 mt-1">
El portal cargará las reglas, la moneda y los reportes correspondientes.
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{COUNTRIES.map((country) => (
<button
key={country.code}
type="button"
disabled={!country.enabled}
onClick={() => country.enabled && onSelect(country.code)}
className={`text-left border p-5 transition-all min-h-[150px] flex flex-col justify-between ${
country.enabled
? 'border-[#4F758B]/30 hover:border-[#4F758B] hover:shadow-md bg-white'
: 'border-neutral-200 bg-neutral-50 cursor-not-allowed opacity-70'
}`}
>
<div className="flex items-start justify-between gap-3">
<img src={country.flag} alt="" className="w-12 h-8 sm:w-16 sm:h-10 object-cover shadow-sm border border-neutral-200" aria-hidden="true" />
{country.enabled ? (
<ArrowRight className="w-5 h-5 text-[#6CC24A]" />
) : (
<LockKeyhole className="w-4 h-4 text-neutral-400" />
)}
</div>
<div>
<p className="font-black text-[#4F758B] uppercase tracking-wide">{country.name}</p>
<p className="text-xs text-neutral-500 mt-1">
{country.enabled ? `Moneda ${country.currency}` : 'Próximamente'}
</p>
</div>
</button>
))}
</div>
</motion.section>
</main>
</div>
);
}
File diff suppressed because it is too large Load Diff
+650
View File
@@ -0,0 +1,650 @@
import React, { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
AlertTriangle,
Calendar,
CheckCircle2,
Eye,
FileSpreadsheet,
Filter,
Loader2,
Menu,
RefreshCw,
Search,
X,
} from 'lucide-react';
import { CountryConfig, HistoricalReport } from '../types';
interface HistoryProps {
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
onOpenSidebar?: () => void;
operatorName: string;
operatorEmail: string;
country: CountryConfig;
}
const REPORTS_PER_PAGE = 10;
function getHistoryUrl(countryCode: string) {
if (countryCode === 'TT') {
return (import.meta.env.VITE_HISTORICOS_TT_URL as string | undefined) || '';
}
return (
(import.meta.env.VITE_HISTORICOS_GT_URL as string | undefined) ||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-historicos'
);
}
function getMarkResolvedUrl(countryCode: string) {
if (countryCode === 'TT') {
return (import.meta.env.VITE_MARCAR_RESUELTO_TT_URL as string | undefined) || '';
}
return (
(import.meta.env.VITE_MARCAR_RESUELTO_GT_URL as string | undefined) ||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto'
);
}
function toNumber(value: unknown) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function formatDate(value: unknown, locale: string) {
if (!value) return '';
const date = new Date(String(value));
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
return date.toLocaleDateString(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
function formatDateTime(value: unknown, locale: string) {
if (!value) return '';
const date = new Date(String(value));
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString(locale, {
dateStyle: 'short',
timeStyle: 'short',
});
}
function normalizeStatus(value: unknown): HistoricalReport['status'] {
return String(value ?? '').toLowerCase().includes('resuelto')
? 'Resuelto'
: 'Pendiente revisión';
}
function normalizeReports(input: unknown, country: CountryConfig): HistoricalReport[] {
const payload = input as Record<string, unknown> | unknown[] | null | undefined;
const data = (!Array.isArray(payload) && payload ? payload : {}) as Record<string, unknown>;
const rawReports = Array.isArray(payload)
? payload
: Array.isArray(data.reports)
? data.reports
: [];
return rawReports
.map((raw, index) => {
const report = raw as Record<string, unknown>;
const discrepancies = toNumber(report.discrepancias ?? report.discrepancies);
const bankWithoutPayroll = toNumber(
report.bancoSinNomina ??
report.banco_sin_nomina ??
report.bankWithoutPayroll
);
const bankWithoutBamboo = toNumber(
report.bancoSinBamboo ??
report.banco_sin_bamboo ??
report.bankWithoutBamboo
);
const payrollWithoutAccount = toNumber(
report.nominaSinCuenta ?? report.nomina_sin_cuenta ?? report.payrollWithoutAccount
);
const nameDifferences = toNumber(
report.diferenciasNombreBanco ??
report.diferencias_nombre_banco ??
report.nameDifferences
);
const totalPending =
discrepancies +
bankWithoutPayroll +
bankWithoutBamboo +
payrollWithoutAccount +
nameDifferences;
const rawCountry = String(report.country ?? report.pais ?? report.country_code ?? country.code).toUpperCase();
const bankFiles = Array.isArray(report.archivosBanco)
? report.archivosBanco.map(String)
: Array.isArray(report.bank_file_names)
? report.bank_file_names.map(String)
: [];
const rawExecutionDate = report.fechaEjecucion ?? report.ejecutado_en ?? report.created_at;
return {
id: String(report.id ?? `reporte-${index + 1}`),
country: (rawCountry === 'TT' ? 'TT' : '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'),
payrollFile: String(report.archivoNomina ?? report.payroll_file_name ?? report.payrollFile ?? ''),
bankFiles,
matches: toNumber(report.coincidencias ?? report.matches),
discrepancies,
bankWithoutPayroll,
bankWithoutBamboo,
payrollWithoutAccount,
nameDifferences,
totalPending,
totalPayroll: toNumber(report.totalNomina ?? report.total_nomina),
totalBank: toNumber(report.totalBanco ?? report.total_banco),
totalDifference: toNumber(report.diferenciaTotal ?? report.diferencia_total),
status: normalizeStatus(report.estado ?? report.status ?? report.estadoLabel),
reportUrl: report.reportUrl || report.report_url ? String(report.reportUrl ?? report.report_url) : undefined,
spreadsheetId: report.spreadsheetId || report.spreadsheet_id
? String(report.spreadsheetId ?? report.spreadsheet_id)
: undefined,
executedBy: report.ejecutadoPorNombre || report.ejecutado_por_nombre
? String(report.ejecutadoPorNombre ?? report.ejecutado_por_nombre)
: undefined,
executedByEmail: report.ejecutadoPorEmail || report.ejecutado_por_email
? String(report.ejecutadoPorEmail ?? report.ejecutado_por_email)
: undefined,
resolvedBy: report.resueltoPorNombre || report.resuelto_por_nombre
? String(report.resueltoPorNombre ?? report.resuelto_por_nombre)
: undefined,
resolvedByEmail: report.resueltoPorEmail || report.resuelto_por_email
? String(report.resueltoPorEmail ?? report.resuelto_por_email)
: undefined,
resolvedAt: formatDateTime(report.resueltoEn ?? report.resuelto_en, country.locale),
resolutionComment: report.comentarioResolucion || report.comentario_resolucion
? String(report.comentarioResolucion ?? report.comentario_resolucion)
: undefined,
_sortTime: new Date(String(rawExecutionDate ?? report.period_end ?? '')).getTime() || 0,
} as HistoricalReport & { _sortTime: number };
})
.filter((report) => report.country === country.code)
.sort((a, b) => b._sortTime - a._sortTime)
.map(({ _sortTime: _ignored, ...report }) => report);
}
function statusClass(status: HistoricalReport['status']) {
if (status === 'Resuelto') return 'bg-blue-50 text-blue-800 border-blue-200';
return 'bg-amber-50 text-amber-800 border-amber-200';
}
function statusText(status: HistoricalReport['status']) {
return status === 'Resuelto' ? 'RESUELTO' : 'PENDIENTE REVISIÓN';
}
export default function History({
onShowToast,
onOpenSidebar,
operatorName,
operatorEmail,
country,
}: HistoryProps) {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'todos' | 'pendiente' | 'resuelto'>('todos');
const [reports, setReports] = useState<HistoricalReport[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [isSavingResolution, setIsSavingResolution] = useState(false);
const [loadError, setLoadError] = useState('');
const [selectedReport, setSelectedReport] = useState<HistoricalReport | null>(null);
const [resolutionComment, setResolutionComment] = useState('');
const filteredReports = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
return reports.filter((report) => {
const matchesStatus =
statusFilter === 'todos' ||
(statusFilter === 'pendiente' && report.status === 'Pendiente revisión') ||
(statusFilter === 'resuelto' && report.status === 'Resuelto');
const matchesSearch =
!normalizedSearch ||
report.period.toLowerCase().includes(normalizedSearch) ||
report.payrollFile.toLowerCase().includes(normalizedSearch) ||
report.id.toLowerCase().includes(normalizedSearch) ||
(report.resolvedBy ?? '').toLowerCase().includes(normalizedSearch) ||
(report.executedBy ?? '').toLowerCase().includes(normalizedSearch);
return matchesStatus && matchesSearch;
});
}, [reports, searchTerm, statusFilter]);
const totalPages = Math.max(1, Math.ceil(filteredReports.length / REPORTS_PER_PAGE));
const paginatedReports = filteredReports.slice(
(currentPage - 1) * REPORTS_PER_PAGE,
currentPage * REPORTS_PER_PAGE
);
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter, country.code]);
useEffect(() => {
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages]);
const loadReports = async () => {
const historyUrl = getHistoryUrl(country.code);
if (!historyUrl) {
setReports([]);
setLoadError(`Los reportes de ${country.name} estarán disponibles próximamente.`);
return;
}
setIsLoading(true);
setLoadError('');
try {
const url = new URL(historyUrl);
url.searchParams.set('country', country.code);
const response = await fetch(url.toString(), { method: 'GET' });
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok === false) {
throw new Error('No se pudieron cargar los reportes históricos.');
}
setReports(normalizeReports(payload, country));
setCurrentPage(1);
} catch {
setLoadError('No se pudieron cargar los reportes históricos. Intenta nuevamente.');
onShowToast('No se pudieron cargar los reportes históricos.', 'error');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadReports();
}, [country.code]);
const handleViewReport = (report: HistoricalReport) => {
if (report.reportUrl) {
window.open(report.reportUrl, '_blank', 'noopener,noreferrer');
return;
}
onShowToast('Este reporte aún no tiene un Google Sheet disponible.', 'info');
};
const handleResolveReport = (report: HistoricalReport) => {
if (report.status === 'Resuelto') {
onShowToast('Este reporte ya fue marcado como resuelto.', 'info');
return;
}
setSelectedReport(report);
setResolutionComment('');
};
const handleConfirmResolve = async () => {
if (!selectedReport) return;
const trimmedComment = resolutionComment.trim();
if (trimmedComment.length < 10) {
onShowToast('Debes escribir un comentario de resolución más descriptivo.', 'error');
return;
}
const markResolvedUrl = getMarkResolvedUrl(country.code);
if (!markResolvedUrl) {
onShowToast(`La resolución de reportes para ${country.name} estará disponible próximamente.`, 'info');
return;
}
setIsSavingResolution(true);
try {
const response = await fetch(markResolvedUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reportId: selectedReport.id,
comentarioResolucion: trimmedComment,
resueltoPorNombre: operatorName,
resueltoPorEmail: operatorEmail,
country: country.code,
countryName: country.name,
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok === false) {
const message = Array.isArray(payload?.errors)
? payload.errors.join('\n')
: payload?.message || 'No se pudo marcar el reporte como resuelto.';
throw new Error(message);
}
const updated = payload.report ?? {};
const resolvedAt = formatDateTime(updated.resueltoEn ?? new Date().toISOString(), country.locale);
setReports((currentReports) =>
currentReports.map((item) =>
item.id === selectedReport.id
? {
...item,
status: 'Resuelto',
resolvedBy: String(updated.resueltoPorNombre ?? operatorName),
resolvedByEmail: String(updated.resueltoPorEmail ?? operatorEmail),
resolvedAt,
resolutionComment: String(updated.comentarioResolucion ?? trimmedComment),
}
: item
)
);
setSelectedReport(null);
setResolutionComment('');
onShowToast('Reporte marcado como resuelto correctamente.', 'success');
} catch (error) {
const message = error instanceof Error ? error.message : 'No se pudo marcar el reporte como resuelto.';
onShowToast(message, 'error');
} finally {
setIsSavingResolution(false);
}
};
return (
<div className="flex-1 flex flex-col min-h-screen bg-neutral-50 text-neutral-800 font-sans">
<header className="bg-white border-b-4 border-[#6CC24A] flex justify-between items-center w-full px-4 sm:px-8 h-[84px] sticky top-0 z-40 shadow-sm gap-x-4">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<button
onClick={onOpenSidebar}
className="md:hidden p-2 -ml-2 text-neutral-600 hover:text-neutral-800 hover:bg-neutral-100 transition-colors flex-shrink-0"
aria-label="Abrir menú"
>
<Menu className="w-6 h-6" />
</button>
<h1 className="text-xs sm:text-sm md:text-lg lg:text-2xl font-black text-[#4F758B] tracking-tight uppercase flex items-center gap-1.5 sm:gap-2 min-w-0">
<span className="truncate">Reportes Históricos - {country.name}</span>
</h1>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<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>
<button
onClick={loadReports}
disabled={isLoading}
className="hidden sm:inline-flex items-center gap-2 border border-[#4F758B]/20 text-[#4F758B] px-3 py-2 text-xs font-bold hover:border-[#4F758B]/50 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</button>
</div>
</header>
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 py-8 flex flex-col gap-6">
<div className="bg-white border border-[#D0D0D0] p-4 flex flex-col lg:flex-row items-center justify-between gap-4 shadow-sm">
<div className="relative w-full lg:max-w-md">
<Search className="absolute left-3.5 top-1/2 transform -translate-y-1/2 text-neutral-400 w-4 h-4" />
<input
type="text"
placeholder="Buscar por período, reporte, archivo o resolutor..."
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
className="w-full pl-10 pr-4 py-2 border border-[#D0D0D0] text-xs font-medium placeholder-neutral-400 focus:outline-none focus:border-[#4F758B] transition-colors"
/>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 w-full lg:w-auto">
<div className="flex flex-wrap gap-2">
{[
['todos', 'Todos'],
['pendiente', 'Pendiente revisión'],
['resuelto', 'Resuelto'],
].map(([value, label]) => (
<button
key={value}
onClick={() => setStatusFilter(value as typeof statusFilter)}
className={`px-3 py-1.5 text-[10px] uppercase tracking-wider font-black border transition-colors ${
statusFilter === value
? 'bg-[#4F758B] text-white border-[#4F758B]'
: 'bg-white text-neutral-500 border-neutral-300 hover:border-[#4F758B] hover:text-[#4F758B]'
}`}
>
{label}
</button>
))}
</div>
<div className="flex items-center gap-2 text-xs font-bold text-neutral-500">
<Filter className="w-4 h-4 text-neutral-400" />
<span>Mostrando: {filteredReports.length} de {reports.length} reportes</span>
</div>
</div>
</div>
{loadError && (
<div className="border border-red-200 bg-red-50 text-[#ba1a1a] px-4 py-3 text-xs font-bold flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{loadError}
</div>
)}
<div className="bg-white border border-[#D0D0D0] shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[960px] xl:min-w-full">
<thead>
<tr className="bg-[#4F758B] text-white text-xs font-bold uppercase tracking-wider">
<th className="py-3 px-3 xl:px-4">Fecha</th>
<th className="py-3 px-3 xl:px-4">Período</th>
<th className="py-3 px-3 xl:px-4">Archivo de Nómina</th>
<th className="py-3 px-3 xl:px-4 text-center">Coincidencias</th>
<th className="py-3 px-3 xl:px-4 text-center">Banco sin Bamboo</th>
<th className="py-3 px-3 xl:px-4 text-center">Pendientes</th>
<th className="py-3 px-3 xl:px-4 text-center">Estado</th>
<th className="py-3 px-3 xl:px-4">Resuelto por</th>
<th className="py-3 px-3 xl:px-4 text-center">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 text-xs">
{isLoading ? (
<tr>
<td colSpan={9} className="py-12 text-center text-neutral-400 font-medium">
<div className="inline-flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Cargando reportes históricos...
</div>
</td>
</tr>
) : paginatedReports.length > 0 ? (
paginatedReports.map((report) => (
<motion.tr
key={report.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="hover:bg-neutral-50/70 transition-colors"
>
<td className="py-3 px-3 xl:px-4 font-medium text-neutral-700">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-neutral-400" />
<div>
<span>{report.date}</span>
{report.executionDate && <p className="text-[9px] text-neutral-400 mt-0.5">{report.executionDate}</p>}
</div>
</div>
</td>
<td className="py-3 px-3 xl:px-4 font-bold text-[#4F758B]">{report.period}</td>
<td className="py-3 px-3 xl:px-4 font-mono text-neutral-500 truncate max-w-[260px]" title={report.payrollFile}>
<div className="flex items-center gap-1.5">
<FileSpreadsheet className="w-4 h-4 text-neutral-400 flex-shrink-0" />
<span className="truncate">{report.payrollFile || 'No especificado'}</span>
</div>
</td>
<td className="py-3 px-3 xl:px-4 text-center font-black text-emerald-800">{report.matches}</td>
<td className="py-3 px-3 xl:px-4 text-center">
<span
className={`inline-block min-w-8 px-2.5 py-1 text-[10px] font-black border ${
report.bankWithoutBamboo > 0
? 'bg-orange-50 text-orange-800 border-orange-200'
: 'bg-neutral-50 text-neutral-400 border-neutral-200'
}`}
>
{report.bankWithoutBamboo}
</span>
</td>
<td className="py-3 px-3 xl:px-4 text-center">
<span
className={`inline-block px-2.5 py-1 text-[10px] font-bold border ${
report.totalPending > 0
? 'bg-red-50 text-[#ba1a1a] border-red-200'
: 'bg-emerald-50 text-emerald-800 border-emerald-200'
}`}
>
{report.totalPending === 0
? 'Sin pendientes'
: `${report.totalPending} pendiente${report.totalPending > 1 ? 's' : ''}`}
</span>
</td>
<td className="py-3 px-3 xl:px-4 text-center">
<span className={`inline-block px-2.5 py-1 text-[9px] uppercase font-black tracking-wider border ${statusClass(report.status)}`}>
{statusText(report.status)}
</span>
</td>
<td className="py-3 px-3 xl:px-4 text-neutral-600 max-w-[220px]">
{report.resolvedBy ? (
<div title={report.resolutionComment || ''}>
<p className="font-bold text-neutral-700 truncate">{report.resolvedBy}</p>
<p className="text-[10px] text-neutral-400">{report.resolvedAt}</p>
</div>
) : (
<span className="text-neutral-400">Pendiente</span>
)}
</td>
<td className="py-3 px-3 xl:px-4 text-center">
<div className="flex justify-center gap-2">
<button
onClick={() => handleViewReport(report)}
className="inline-flex items-center gap-1.5 text-[#4F758B] hover:text-[#3d6378] font-bold text-xs border border-[#4F758B]/20 hover:border-[#4F758B]/50 px-3 py-1.5 bg-white hover:bg-neutral-50 transition-colors"
>
<Eye className="w-3.5 h-3.5" />
<span>Ver</span>
</button>
<button
onClick={() => handleResolveReport(report)}
disabled={report.status === 'Resuelto'}
className="inline-flex items-center gap-1.5 text-emerald-700 hover:text-emerald-800 font-bold text-xs border border-emerald-200 hover:border-emerald-400 px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<CheckCircle2 className="w-3.5 h-3.5" />
<span>{report.status === 'Resuelto' ? 'Resuelto' : 'Marcar resuelto'}</span>
</button>
</div>
</td>
</motion.tr>
))
) : (
<tr>
<td colSpan={9} className="py-12 text-center text-neutral-400 font-medium">
No se encontraron reportes históricos.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="p-4 border-t border-neutral-200 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs text-neutral-500">
<span>
Mostrando {paginatedReports.length} de {filteredReports.length} reporte(s).
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
disabled={currentPage === 1}
className="px-3 py-1.5 border border-neutral-300 bg-white disabled:opacity-40 disabled:cursor-not-allowed hover:border-[#4F758B] font-bold"
>
Anterior
</button>
<span className="font-bold text-neutral-700">Página {currentPage} de {totalPages}</span>
<button
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1.5 border border-neutral-300 bg-white disabled:opacity-40 disabled:cursor-not-allowed hover:border-[#4F758B] font-bold"
>
Siguiente
</button>
</div>
</div>
</div>
</main>
<AnimatePresence>
{selectedReport && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/45 px-4"
>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
className="w-full max-w-lg bg-white border border-[#D0D0D0] shadow-xl"
>
<div className="border-b border-neutral-200 px-5 py-4 flex items-center justify-between">
<div>
<h2 className="text-base font-black text-[#4F758B] uppercase tracking-tight">Marcar reporte como resuelto</h2>
<p className="text-xs text-neutral-500 mt-1">{selectedReport.period}</p>
</div>
<button
onClick={() => setSelectedReport(null)}
disabled={isSavingResolution}
className="p-2 text-neutral-400 hover:text-neutral-700 disabled:opacity-50"
aria-label="Cerrar"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-4">
<div className="bg-amber-50 border border-amber-200 text-amber-800 p-3 text-xs leading-relaxed">
Antes de resolver, confirma que las diferencias fueron revisadas y explica cómo se cerró el caso.
</div>
<label className="block">
<span className="text-[10px] uppercase tracking-wider font-black text-neutral-500">Comentario de resolución</span>
<textarea
value={resolutionComment}
onChange={(event) => setResolutionComment(event.target.value)}
placeholder="Ejemplo: Se corrigió la cuenta bancaria en nómina y se validó contra el banco."
className="mt-2 w-full min-h-[130px] border border-[#D0D0D0] px-3 py-2 text-sm focus:outline-none focus:border-[#4F758B]"
/>
</label>
</div>
<div className="border-t border-neutral-200 px-5 py-4 flex justify-end gap-3">
<button
onClick={() => setSelectedReport(null)}
disabled={isSavingResolution}
className="border border-neutral-300 bg-white px-4 py-2 text-xs font-bold text-neutral-600 hover:bg-neutral-50 disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={handleConfirmResolve}
disabled={isSavingResolution}
className="bg-[#4F758B] hover:bg-[#41677b] text-white px-4 py-2 text-xs font-black uppercase tracking-wider disabled:opacity-50 inline-flex items-center gap-2"
>
{isSavingResolution && <Loader2 className="w-4 h-4 animate-spin" />}
Confirmar resolución
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { AlertTriangle } from 'lucide-react';
import { getAuthRedirectUrl, isSupabaseConfigured, supabase } from '../lib/supabase';
interface LoginProps {
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
}
export default function Login({ onShowToast }: LoginProps) {
const [isLoggingIn, setIsLoggingIn] = useState(false);
const handleGoogleLogin = async () => {
if (!isSupabaseConfigured || !supabase) {
onShowToast('El acceso con Google aún no está disponible. Contacta a IT.', 'error');
return;
}
setIsLoggingIn(true);
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: getAuthRedirectUrl(),
queryParams: {
access_type: 'offline',
prompt: 'select_account',
},
},
});
if (error) {
setIsLoggingIn(false);
onShowToast('No se pudo iniciar sesión con Google. Intenta nuevamente.', 'error');
}
};
return (
<div className="min-h-screen w-full flex flex-col justify-between bg-[#F5F5F5] relative overflow-hidden font-sans">
<div className="h-1.5 w-full bg-[#4F758B]" />
<div className="flex-1 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="w-full max-w-md bg-white border border-[#D0D0D0] p-8 shadow-xl flex flex-col items-center gap-6"
>
<div className="w-full flex justify-center py-2">
<img
alt="Logo GOMEZLEE MARKETING"
className="h-14 object-contain"
src="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png"
referrerPolicy="no-referrer"
/>
</div>
<div className="text-center w-full">
<h1 className="text-2xl font-bold text-[#4F758B] tracking-tight">
Portal de Cruce de Cuentas GLM
</h1>
</div>
<button
onClick={handleGoogleLogin}
disabled={isLoggingIn || !isSupabaseConfigured}
className="w-full relative overflow-hidden flex items-center justify-center gap-3 bg-[#6CC24A] hover:bg-[#5bb03c] disabled:bg-neutral-300 disabled:cursor-not-allowed text-white font-semibold py-3.5 px-6 transition-all duration-150 transform active:scale-[0.99] select-none text-sm tracking-wide"
>
{isLoggingIn ? (
<div className="flex items-center gap-2">
<svg className="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Redirigiendo a Google...</span>
</div>
) : (
<>
<svg className="w-5 h-5 flex-shrink-0 fill-current text-white" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
<span>Continuar con Google</span>
</>
)}
</button>
{!isSupabaseConfigured && (
<div className="w-full flex items-start gap-2 border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800 leading-relaxed">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>El acceso con Google está pendiente de activación.</span>
</div>
)}
</motion.div>
</div>
</div>
);
}
-121
View File
@@ -1,121 +0,0 @@
import { useState } from "react";
import { motion } from "motion/react";
import { Loader2 } from "lucide-react";
import { IMAGES } from "../data";
interface LoginScreenProps {
onLogin: () => Promise<void> | void;
authError?: string;
isSupabaseConfigured?: boolean;
allowedEmails?: readonly string[];
}
export function LoginScreen({
onLogin,
authError,
isSupabaseConfigured = true,
allowedEmails = [],
}: LoginScreenProps) {
const [isLoggingIn, setIsLoggingIn] = useState(false);
const handleGoogleLogin = async () => {
setIsLoggingIn(true);
try {
await onLogin();
} finally {
setIsLoggingIn(false);
}
};
return (
<div className="min-h-screen w-full flex items-center justify-center p-4 md:p-8 bg-[#f5f3f3]" id="login-screen-bg">
{/* Main Login Card container */}
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="w-full max-w-[480px] bg-white rounded-xl p-8 md:p-12 shadow-lg flex flex-col items-center"
id="login-card"
>
<div className="w-full flex flex-col items-center text-center space-y-8 py-4">
{/* Company Brand Logo */}
<div className="w-full flex justify-center pb-2" id="login-brand-logo-container">
<img
alt="GLM Logo"
className="h-auto max-w-[250px]"
src={IMAGES.loginLogo}
referrerPolicy="no-referrer"
/>
</div>
{/* Title */}
<div className="space-y-1 w-full">
<h1 className="text-[32px] leading-[1.2] font-bold text-[#4f758b]" id="login-h1-title">
Cruce de Cuentas GLM
</h1>
</div>
{/* Google Button */}
<div className="w-full pt-4 pb-2 space-y-3">
<button
onClick={handleGoogleLogin}
disabled={isLoggingIn || !isSupabaseConfigured}
className="w-full flex items-center justify-center gap-3 bg-[#6CC24A] hover:bg-[#5bb03a] text-white py-3 px-4 transition-all text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-[#6CC24A] focus:ring-offset-2 cursor-pointer shadow-none disabled:opacity-60 disabled:cursor-not-allowed"
id="google-signin-btn"
>
{isLoggingIn ? (
<>
<Loader2 className="w-5 h-5 text-white animate-spin" />
<span>Iniciando sesión...</span>
</>
) : (
<>
{/* Reference G white Logo */}
<svg className="w-5 h-5 shrink-0" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#FFFFFF"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#FFFFFF"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FFFFFF"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#FFFFFF"
/>
</svg>
<span className="text-[16px] font-bold text-white">Continuar con Google</span>
</>
)}
</button>
{authError ? (
<p className="text-[12px] leading-[1.45] text-red-600 bg-red-50 border border-red-100 rounded-lg p-3">
{authError}
</p>
) : null}
</div>
{/* Small text footer */}
<div className="w-full pt-4 border-t border-[#D0D0D0] text-center" id="login-footer">
<p className="text-[13px] leading-[1.4] text-slate-500 font-normal">
Acceso restringido. Uso exclusivo para personal autorizado de GLM.
</p>
{allowedEmails.length > 0 ? (
<p className="text-[11px] leading-[1.4] text-slate-400 font-normal mt-2">
Acceso habilitado solo para usuarios autorizados.
</p>
) : null}
</div>
</div>
</motion.div>
</div>
);
}
-140
View File
@@ -1,140 +0,0 @@
import { motion, AnimatePresence } from "motion/react";
import { X, ShieldAlert, ArrowLeft, Layers, Landmark, Info, CheckCircle2, ChevronRight } from "lucide-react";
import { Country } from "../data";
interface NotificationModalProps {
country: Country | null;
onClose: () => void;
}
export function NotificationModal({ country, onClose }: NotificationModalProps) {
if (!country) return null;
const isGuatemala = country.code === "GT";
return (
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-slate-900/60 backdrop-blur-xs"
/>
{/* Modal Content Card */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ type: "spring", duration: 0.4 }}
className={`relative w-full max-w-lg bg-white rounded-xl shadow-2xl overflow-hidden border-t-4 ${isGuatemala ? 'border-[#6CC24A]' : 'border-slate-300'}`}
id={`modal-${country.code.toLowerCase()}`}
>
{/* Close button top right */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors focus:outline-none"
title="Cerrar"
>
<X className="w-5 h-5" />
</button>
{/* Header Banner info */}
<div className="bg-slate-50 p-6 border-b border-slate-100 flex items-center gap-4">
<div className="w-12 h-12 rounded-lg overflow-hidden shrink-0 border border-slate-200">
<img
src={country.flagUrl}
alt={`Bandera de ${country.name}`}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div>
<span className={`text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full uppercase ${isGuatemala ? 'text-[#6CC24A] bg-[#6CC24A]/10' : 'text-slate-500 bg-slate-100'}`}>
{isGuatemala ? 'Iniciado' : 'No Iniciado'}
</span>
<h3 className="text-xl font-bold text-slate-800 leading-tight mt-1">
{country.name} ({country.code})
</h3>
</div>
</div>
<div className="p-6 space-y-6">
{isGuatemala ? (
// Guatemala Particular Content
<div className="space-y-6">
<div className="flex gap-3 bg-emerald-50/50 rounded-lg p-4 border border-emerald-100">
<CheckCircle2 className="w-6 h-6 text-[#6CC24A] shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-slate-800 text-sm">
Módulo en Operación Inicial
</h4>
<p className="text-xs text-slate-600 mt-1 leading-relaxed">
El sistema para Guatemala ha comenzado su fase técnica y de parametrización operativa.
</p>
</div>
</div>
<div className="space-y-3">
<h5 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Siguientes pasos en desarrollo:
</h5>
<div className="grid grid-cols-1 gap-3">
<div className="flex items-center gap-3 p-3.5 bg-yellow-50/40 border border-yellow-200 rounded-lg">
<div className="p-2 bg-yellow-100/50 text-amber-700 rounded-md">
<Layers className="w-5 h-5 animate-pulse" />
</div>
<div>
<h6 className="text-xs font-bold text-slate-800">Falta el estándar de nómina</h6>
<p className="text-[11px] text-slate-600 mt-0.5">Adaptación de los campos reglamentarios locales.</p>
</div>
</div>
<div className="flex items-center gap-3 p-3.5 bg-yellow-50/40 border border-yellow-200 rounded-lg">
<div className="p-2 bg-yellow-100/50 text-amber-700 rounded-md">
<ChevronRight className="w-5 h-5" />
</div>
<div>
<h6 className="text-xs font-bold text-slate-800">Integrar al flujo de n8n</h6>
<p className="text-[11px] text-slate-600 mt-0.5">Conexión de webhooks y procesamiento de conciliación en lote.</p>
</div>
</div>
</div>
</div>
</div>
) : (
// Pending / No Iniciado Content for other countries
<div className="space-y-4 py-4 text-center">
<div className="w-16 h-16 bg-slate-50 border border-slate-200 rounded-full flex items-center justify-center mx-auto text-slate-400">
<ShieldAlert className="w-8 h-8" />
</div>
<div className="space-y-1 max-w-sm mx-auto">
<h4 className="font-bold text-slate-800 text-base">
Módulo No Iniciado
</h4>
<p className="text-xs text-slate-500 leading-relaxed">
Aún no se ha iniciado el proceso operativo ni la conciliación para {country.name}.
</p>
</div>
</div>
)}
</div>
{/* Action buttons footer */}
<div className="bg-slate-50 px-6 py-4 border-t border-slate-100 flex justify-end gap-3">
<button
onClick={onClose}
className="flex items-center gap-2 px-5 py-2.5 bg-[#4F758B] hover:bg-[#365C71] text-white text-xs font-semibold rounded-lg shadow-sm transition-all cursor-pointer"
>
<ArrowLeft className="w-4 h-4" /> Entendido, Volver
</button>
</div>
</motion.div>
</div>
</AnimatePresence>
);
}
-94
View File
@@ -1,94 +0,0 @@
import { useState } from "react";
import { motion } from "motion/react";
import { LogOut, User } from "lucide-react";
import { COUNTRIES, Country, IMAGES } from "../data";
import { CountryCard } from "./CountryCard";
import { NotificationModal } from "./NotificationModal";
interface PortalPrincipalProps {
onLogout: () => void;
userEmail?: string;
}
export function PortalPrincipal({ onLogout, userEmail }: PortalPrincipalProps) {
const [selectedCountry, setSelectedCountry] = useState<Country | null>(null);
return (
<div className="bg-[#fbf9f8] text-[#1b1c1c] font-sans min-h-screen flex flex-col" id="portal-root">
{/* Main Content Canvas */}
<main className="flex-grow pb-16 px-4 md:px-12 py-8 max-w-7xl w-full mx-auto" id="portal-main">
{/* Top Control Bar with Logout and User Profile */}
<div className="flex justify-end mb-8 gap-2" id="portal-controls">
<button
onClick={onLogout}
className="flex items-center gap-2 px-4 py-2 bg-[#4F758B] hover:bg-[#365C71] text-white transition-all text-xs font-semibold rounded-lg shadow-sm cursor-pointer border border-[#4F758B]/10"
title="Cerrar sesión"
id="logout-btn"
>
<LogOut className="w-4 h-4 text-white" />
<span className="text-white">Cerrar sesión</span>
</button>
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center text-[#4F758B] border border-slate-300" title={userEmail ? `Sesión activa: ${userEmail}` : "Usuario de GomezLee Marketing"}>
<User className="w-5 h-5" />
</div>
</div>
{/* Hero Section (Asymmetric) */}
<section className="grid grid-cols-1 md:grid-cols-12 gap-8 my-16">
<div className="md:col-span-8 flex flex-col justify-center">
<h1 className="text-[48px] leading-[1.1] tracking-tight font-bold text-[#365c71] mb-4">
Cruce de Cuentas GLM
</h1>
<p className="text-[16px] leading-[1.6] text-slate-600 max-w-2xl">
Plataforma centralizada para la conciliación de cuentas a nivel regional. Seleccione un país para comenzar el proceso operativo.
</p>
</div>
<div className="md:col-span-4 hidden md:flex items-center justify-end">
<div className="w-full flex items-center justify-end">
<img
alt="GLM Logo"
className="max-w-full h-auto object-contain"
src={IMAGES.portalLogo}
referrerPolicy="no-referrer"
/>
</div>
</div>
<div className="col-span-1 md:col-span-12 border-t border-[#6CC24A] mt-8"></div>
</section>
{/* Country Grid Section */}
<section className="mb-24" id="portales-por-pais">
<h2 className="text-[28px] md:text-[32px] leading-[1.2] font-bold text-[#365c71] mb-8">
Portales por país
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{COUNTRIES.map((country) => (
<CountryCard
key={country.code}
country={country}
onSelect={setSelectedCountry}
/>
))}
</div>
</section>
</main>
{/* Footer */}
<footer className="bg-white border-t border-slate-200 py-6 text-center text-xs text-slate-400 font-medium">
<p>© {new Date().getFullYear()} GomezLee Marketing. Todos los derechos reservados.</p>
</footer>
{/* Interactive Country Modal */}
<NotificationModal
country={selectedCountry}
onClose={() => setSelectedCountry(null)}
/>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
import React, { useState, useRef, useEffect } from 'react';
import { ArrowLeftRight, Globe2, History, LogOut, Plus, UserCircle, X, ChevronDown } from 'lucide-react';
import { AppUser, CountryCode, CountryConfig, ViewType } from '../types';
import { COUNTRIES } from '../config/countries';
interface SidebarProps {
currentView: ViewType;
onViewChange: (view: ViewType) => void;
onNewCruce: () => void;
isSidebarOpen?: boolean;
onCloseSidebar?: () => void;
user?: AppUser;
isAuthEnabled?: boolean;
onLogout?: () => void;
country: CountryConfig;
onCountryChange: (country: CountryCode) => void;
onChooseAnotherCountry: () => void;
}
const LOGO_URL = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';
export default function Sidebar({
currentView,
onViewChange,
onNewCruce,
isSidebarOpen = false,
onCloseSidebar,
user,
isAuthEnabled = false,
onLogout,
country,
onCountryChange,
onChooseAnotherCountry,
}: SidebarProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<aside
className={`bg-neutral-100 h-screen w-64 fixed left-0 top-0 flex flex-col z-50 pb-6 min-h-screen font-sans transition-transform duration-300 md:translate-x-0 ${
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="absolute right-0 top-0 h-[80px] w-px bg-[#D0D0D0] pointer-events-none z-10 hidden md:block" />
<div className="absolute right-0 top-[84px] bottom-0 w-px bg-[#D0D0D0] pointer-events-none z-10 hidden md:block" />
<div className="h-[84px] px-4 flex justify-between md:justify-center items-center bg-white border-b-4 border-[#6CC24A] relative">
<img
alt="GomezLee Marketing logo"
className="h-10 object-contain selection:bg-transparent"
src={LOGO_URL}
/>
<button
onClick={onCloseSidebar}
className="md:hidden p-2 text-neutral-500 hover:text-neutral-800 transition-colors"
aria-label="Cerrar menú"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-4 pt-5">
<label className="block">
<span className="text-[9px] uppercase tracking-wider font-black text-neutral-400 flex items-center gap-1.5 mb-1.5">
<Globe2 className="w-3.5 h-3.5" />
País
</span>
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="w-full border border-neutral-300 bg-white px-3 py-2 text-xs font-bold text-[#4F758B] focus:outline-none focus:border-[#4F758B] flex items-center justify-between transition-colors hover:bg-neutral-50"
>
<div className="flex items-center gap-2">
<img src={country.flag} alt="" className="w-5 h-3.5 object-cover shadow-sm border border-neutral-200" />
<span>{country.name}</span>
</div>
<ChevronDown className={`w-4 h-4 text-[#4F758B] transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{isDropdownOpen && (
<ul className="absolute left-0 right-0 top-full mt-1 bg-white border border-neutral-300 shadow-lg z-50 py-1">
{COUNTRIES.map((item) => (
<li key={item.code}>
<button
type="button"
disabled={!item.enabled}
onClick={() => {
onCountryChange(item.code);
setIsDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2.5 text-xs font-bold flex items-center gap-2.5 transition-colors ${
item.enabled
? 'hover:bg-neutral-100 text-[#4F758B]'
: 'opacity-60 cursor-not-allowed text-neutral-500 bg-neutral-50'
}`}
>
<img src={item.flag} alt="" className="w-6 h-4 object-cover shadow-sm border border-neutral-200 bg-white flex-shrink-0" />
<span className="min-w-0 text-left leading-tight">
<span className="block">{item.name}</span>
{!item.enabled && (
<span className="block mt-1 text-[10px] text-neutral-400 font-medium italic">
Próximamente
</span>
)}
</span>
</button>
</li>
))}
</ul>
)}
</div>
</label>
<button
type="button"
onClick={onChooseAnotherCountry}
className="mt-2 text-[9px] font-bold uppercase tracking-wider text-neutral-400 hover:text-[#4F758B]"
>
Ver países disponibles
</button>
</div>
<nav className="flex-1 flex flex-col mt-5">
<ul className="space-y-1">
<li>
<button
onClick={() => onViewChange('cruce')}
className={`w-full flex items-center gap-3 px-6 py-3.5 transition-all duration-150 text-left font-bold text-sm tracking-wide ${
currentView === 'cruce'
? 'bg-[#4F758B] text-white'
: 'text-neutral-600 hover:bg-neutral-200 hover:text-neutral-800'
}`}
>
<ArrowLeftRight className={`w-4 h-4 ${currentView === 'cruce' ? 'text-[#6CC24A]' : ''}`} />
<span>Cruce de Cuentas</span>
</button>
</li>
<li>
<button
onClick={() => onViewChange('historial')}
className={`w-full flex items-center gap-3 px-6 py-3.5 transition-all duration-150 text-left font-bold text-sm tracking-wide ${
currentView === 'historial'
? 'bg-[#4F758B] text-white'
: 'text-neutral-600 hover:bg-neutral-200 hover:text-neutral-800'
}`}
>
<History className={`w-4 h-4 ${currentView === 'historial' ? 'text-[#6CC24A]' : ''}`} />
<span>Reportes Históricos</span>
</button>
</li>
</ul>
</nav>
<div className="px-4 space-y-3 pt-4 border-t border-[#D0D0D0] bg-neutral-100">
{user && (
<div className="border border-neutral-200 bg-white px-3 py-3 flex items-center gap-2">
<UserCircle className="w-4 h-4 text-[#4F758B] flex-shrink-0" />
<div className="min-w-0">
<p className="text-[11px] font-black text-neutral-700 truncate" title={user.name}>{user.name}</p>
<p className="text-[9px] text-neutral-400 truncate" title={user.email}>{user.email}</p>
</div>
</div>
)}
<button
onClick={onNewCruce}
className="w-full bg-[#6CC24A] hover:bg-[#5bb03c] text-white py-3 px-4 font-bold text-xs uppercase tracking-wider flex items-center justify-center gap-2 transition-all duration-150 shadow-sm active:scale-98"
>
<Plus className="w-4 h-4" />
<span>Nuevo Cruce</span>
</button>
{isAuthEnabled && onLogout && (
<button
onClick={onLogout}
className="w-full border border-neutral-300 bg-white hover:bg-neutral-50 text-neutral-600 py-2.5 px-4 font-bold text-[11px] uppercase tracking-wider flex items-center justify-center gap-2 transition-all duration-150"
>
<LogOut className="w-4 h-4" />
<span>Cerrar sesión</span>
</button>
)}
</div>
</aside>
);
}
+54
View File
@@ -0,0 +1,54 @@
import React, { useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { CheckCircle2, AlertCircle, X, Info } from 'lucide-react';
interface ToastProps {
message: string;
type: 'success' | 'error' | 'info';
onClose: () => void;
}
export default function Toast({ message, type, onClose }: ToastProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose();
}, 2000);
return () => clearTimeout(timer);
}, [onClose]);
const bgColors = {
success: 'bg-[#6CC24A] text-white border-l-4 border-emerald-700',
error: 'bg-[#ba1a1a] text-white border-l-4 border-red-800',
info: 'bg-[#4F758B] text-white border-l-4 border-slate-700',
};
const Icon = {
success: CheckCircle2,
error: AlertCircle,
info: Info,
}[type];
return (
<div className="fixed bottom-6 right-6 z-50 max-w-sm w-full">
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className={`flex items-center gap-3 p-4 shadow-lg rounded-md border border-neutral-200/10 ${bgColors[type]}`}
>
<span className="flex-shrink-0">
<Icon className="w-5 h-5" />
</span>
<div className="flex-1 font-medium text-sm leading-tight pr-2">
{message}
</div>
<button
onClick={onClose}
className="flex-shrink-0 hover:bg-white/20 p-1 rounded transition-colors text-white"
>
<X className="w-4 h-4" />
</button>
</motion.div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { CountryCode, CountryConfig } from '../types';
export const COUNTRIES: CountryConfig[] = [
{
code: 'GT',
name: 'Guatemala',
flag: import.meta.env.BASE_URL + 'gt-flag.png',
currency: 'GTQ',
locale: 'es-GT',
enabled: true,
},
{
code: 'TT',
name: 'Trinidad y Tobago',
flag: import.meta.env.BASE_URL + 'tt-flag.png',
currency: 'TTD',
locale: 'en-TT',
enabled: false,
},
];
export const DEFAULT_COUNTRY_CODE: CountryCode = 'GT';
export function getCountryConfig(code: CountryCode): CountryConfig {
return COUNTRIES.find((country) => country.code === code) ?? COUNTRIES[0];
}
-99
View File
@@ -1,99 +0,0 @@
export interface Country {
name: string;
code: string;
flagUrl: string;
reconciliationStatus?: 'pending' | 'success' | 'checking';
lastActivity?: string;
}
export const COUNTRIES: Country[] = [
{
name: "República Dominicana",
code: "RD",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuAbFgio9fFr7HYBSPjeTfwPI8GFXnGp_3skeWFL_baovcRdG3WZ2OoKvEwCcBulfJu6jkY82lNHkpDH5qKnpNmSG0cC8vp1okMWse2WB0iE3_ZGE9JgyFh52HKKjTogPQDRFUCPe5yY1hZ9mK47vc0I26flb3J-eBfaB9vw00SI4DNPzJzV7eyvLoljWKE-2cX6cFATt0eC-QrHh6COewzbZFLceN_9dXCnVZkllHX5EVsgmSh1zyWmaWZ7bJ1ytTacvq3a2YkSCFDh",
reconciliationStatus: "success",
lastActivity: "Hace 2 horas"
},
{
name: "México",
code: "MX",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuBDh5QV_WHLXY2gw69AgzFahKNRfHAvxfzAU911jSu-k4GFhR-5lQpXjokfCyn-I2k6wH2mBrEir6PulF8gdAmXQvmC3RKrhkxwt1OneWHyyE3Rl8SNuWpabySiwuCR0CQuKkvVA7fTtugNkGkcuolxod04knCbFXeVGDDYp7E7bkv8OPMzUxoybYFhTkeVk6FuJL9eVkV2CHEnW0oYSyIsyQ2WvVPxmGtvZelV3yk-fTNsrjGFdGrUNAj-0LeE3yP0OkazNrOg0IKI",
reconciliationStatus: "pending",
lastActivity: "Ayer"
},
{
name: "Guatemala",
code: "GT",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuAHvkRR8pLZXEo5FCWvaPHE8oWO1KClQ013s182tamIM4BHJT2h4vE3Q3iwXreUhLBwBJ2NO8cu5YjbkEwRHJqrC2pCiSbAc7iVY9mZGI3EsYzJMiLyuPIjb69MXgEgGoYlLUSzTof2Z02Ydorsb9bQMbzvUCThuzJ62q6tjLW8zNxmgF8_vKZXlE2FUMZup_RLrk5rFfxbBLUKyNZTffWoDc0gCCRmszLpX1eYl9GK_440rwegPXPDfFWQmAraQyukFepcalVEVF1F",
reconciliationStatus: "success",
lastActivity: "Hace 3 días"
},
{
name: "El Salvador",
code: "SV",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuBnMCkUdotqwCWah5GJY6we6HN7JohrfhMUP4OWviKUFHkrzYTEeu-u0_YNZ6rSpwi7uUzwr8CrduO2yUNNUH4BIpBg9z1cBMLcCBRDmw_0_p09vVy0dYQReG_j2xfMzTGVItXO4VAiyfHacxcPyn99m7NKhdFQzGWvPSYuAg9G97OnplzbH41s8SzQVj7Brm-F7XNqbXPgVuOEBjaiYalGEf7Z-RexyMznTH9l_f67UcK5uCmhLAwQOvm3eadnjPls7HKT1p76IKwg",
reconciliationStatus: "pending",
lastActivity: "Hace 1 semana"
},
{
name: "Honduras",
code: "HN",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuCQ7Vzvmn3kzPTSJDG2nwUasogs2adICwKFaVrEK2b26jgzdXuG6vQ2CJIgAyM0muR0_7_oQo4cmg_VQmJUBw_B4c_uusqz_WBN1dXz2n25o2CkHRruystQgUDTrMMRipwDqCaARGG8g838f9Dn_N2o9oJPl39j0QOivdQ6g5X0UFxTa97zx-xu8ABbtk2sBVCeR2yX47C5GBDCKpYhD7T8g-iw0BQ0YvgsE7GlezpZQq4dJHiq_Hm4dvcY-v2DKRqkVqe9Q3vv0ggP",
reconciliationStatus: "success",
lastActivity: "Hace 5 días"
},
{
name: "Nicaragua",
code: "NI",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuCnoSqAalLZksu58PPPnEOHaGz505p_P4KaQP8QX2lucGZmx6a_BtodhrbKW1lIs9NRh7d9ubueOBWoiRuno1kruz7buM2u6HUb1X4qgXTfek_-E5d2DbXMSVE5rjWCFIPWDJYduJrdM2Z1JxEHfu3uqwf8iG9mLC2NE0lLGKnLCPBsu6gWEA-GmsL4syXh56BpXYs7fVFyTVJ-u0kM6P1-XnyOcFBkjIqbq8DsS9jplkfEUf4mm6eVV3kGaP7EkbtC8reV-wLDAGex",
reconciliationStatus: "success",
lastActivity: "Hace 2 semanas"
},
{
name: "Costa Rica",
code: "CR",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuCTOyc-REp3byTH-hJwskzWobb1-XtP1dZfecPSifYuV8qGuFLOMNaAbOj7131fS5Sf4UOR2QJqNQLm90bvEobvU4WBupNuyPRZ_Nj6CYfv2HPedNvMHdqAVmuSoV492KMdGnHYdOPKP074MfDxMwTx4BaDYw8KdBtZI2TKLPj2daWugYxFArTHjP-g_MshvEjYQiY9w7AdPODBRj_QVVizM2hjuuvwmsxRjzjhSjZ1D4TFoDCKYfk9udSbYCWF3qkzYRQvLNXvrpZq",
reconciliationStatus: "pending",
lastActivity: "Hace 6 días"
},
{
name: "Panamá",
code: "PA",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuCRfO8gX-PWWRyjm7mQBFPY-l8vC_NVJnZ1FAYJrdm2QZvqssC6XZ2zbAe1vGfst9lOOJghcis6V5ibunmh23oCl6jYp6G-URvUth0V0hzuxQYoQFfoe0OFY1n-eTa6pCHDLPm6Ko7T9yduVIulm4PTQToAHzB83k7d_2-P79EjguS4A0nqHzQFSu0SUbwlYb0JwtWG3JtBYF6cDIeMcWPyixRGSmbPyNrVAo1TP57dVZ6KbxRFxzwOY-yWsh-v77K_AJ13XsKZqDhL",
reconciliationStatus: "checking",
lastActivity: "En progreso"
},
{
name: "Colombia",
code: "CO",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuBG_GcA4hQ0Y6W9RrG214QNcVA7RLqVdGEHC3r62z-mRBJjlkfiRTqiRQpCYs7c8_JDnnq_83WquYhKdX2hbWZEdY1GqbsTm3qj8Sb4l0zBLYg8CTVU_uas1zPT4i0UjyLkPnRXLIAw9SMJu0v9uiuJr4PqLS8xGjf9Kh6hX47ivq2hGpKBUInK40SB7S_D8aULwsLVuEbLsgC7zKBaWuxrYRXHCccrFlGgILykf1Q36pqCySp7GX04mvvacPcBhD2E7Rei9DDIcxwb",
reconciliationStatus: "success",
lastActivity: "Hace 4 horas"
},
{
name: "Puerto Rico",
code: "PR",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuCeW3pYEOintyivpkLraB5zQjxT7QC4IKgBZ-vecQcE3n83pUbgcBt9_uuQQKFXWl-ObdTA3G5WSjIGIbJ3RwxfKWMgaYYu7qP1i_NWfwD7i8qsfD519cYLZSE4eUY2Le11cYAcUB8AL5mqbW_M5NSw2qQuG6Sy2RyX-4MB9U8BGENLYUosZgtGnNnLqy9SLhKi5axr5rRE_pY11xQ3SInUnTd84lsBNAZ1CGqoIXHelz-rBtSylIUQQiYwOhYf35g9SQMUwjkFYiBB",
reconciliationStatus: "pending",
lastActivity: "Hace 3 semanas"
},
{
name: "Jamaica",
code: "JM",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuA_P_G_Y_37QIBSW3S1meV-z7oY9-a2H5BcMgye9Fl291icbi3xjuZJYDZw6gw1ba9khrdyplzuHh47Ekp8-SrbGtWVb4rKpeCRExyeN7u5yMLYRO4COUm6xeJH_oaArq2Jlm_9D_uSa0KZxRNpZzoazD5pQGD-NQ3I_Uir_Y92sLbdTdQUK1zs8iMfX7leTS-p0-huFuCIpfRPtm1NfoiLx7aUPcnv5D8lt8KDN9yf-sVVqAJ-qq5kb2QE00A3GIRKOnkj-Z_B7q5H",
reconciliationStatus: "success",
lastActivity: "Hace 10 días"
},
{
name: "Trinidad y Tobago",
code: "TT",
flagUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuCiLtOuYmRWakq32f_gdadIAACMfjrXp2AcVhti893m3_SiZ_HsRf5cypDuqtxTrxMxRxmWxAOlMKS0SL-kccU_m5HM8oErMyAt4C_PCeUGxyIAij5ZRhdFS13_4IUMhVPlXGfutBtqekNEW1dTG1V4X9c6KEVzGrLOUl1sIVKJdWMnvGneEWDObhVK-b57DMHCazHgiQ0r5rQ_-flKe0O0ItxgDslabTXCCDIg2cIif7u5xtHUez7Pgb_aYL0w1y62cnG23SHKKHL6",
reconciliationStatus: "success",
lastActivity: "Ayer"
}
];
export const IMAGES = {
loginLogo: "https://lh3.googleusercontent.com/aida-public/AB6AXuB3NT2YzcwdXGZinCF8VMS_mnwo7q8ocY9GQLEECB_Fxmo26w3htpicfjrAw3wFWnwBHo7Ew9QPgu8twLNHD9qDZnvGAO9TEy6zccaPqOzMQBZlOC0HcGr-U6xqwLdQrdnwTNpMrN7l1Innpjy9aHKJeV8puZN-KI2TRj_2x81mkQOLGCTplyBI95AWgjM18vtLlBFPPmOLwO2njvLd5n7REBV3tQlMFeAr55BLJtDCVgJ5N8PTbxMIlxgpRKMu4OS3JnQY01qwp2jY",
portalLogo: "https://lh3.googleusercontent.com/aida-public/AB6AXuDaWPVKf2WugmtyGs6I4JBK2NVDbejr_TrbMWKkDtFUqD_W-qvq3ZkpwYaBshUCgbUgo5AhuKvIWwat8hDL7IUjgSbChiYUCjgsuz7uy94EM0FpbPH5krRwHcv6K-uAqIs4uFAk4GCSLfJMTsGFHYydpl_yLtfWu0e1ULAJQPJ0I9HmHESmcc2kT7DOWr8lapGowWTI4OatRgcngo9ZmKKsAWaaJUy4Fi3Zk6U-4S2dKhsItIPg_PVaxqaDt7tfbCFHu7CY0db4BHCD"
};
+24 -3
View File
@@ -1,7 +1,28 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-display: "Space Grotesk", sans-serif;
--font-sans: "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: "JetBrains Mono", monospace;
--color-azul-glm: #4F758B;
--color-verde-glm: #6CC24A;
--color-verde-claro: #A4D65E;
--color-gris-fondo: #F5F5F5;
--color-border-gray: #D0D0D0;
--color-guatemala-blue: #4997D0;
}
body {
font-family: var(--font-sans);
background-color: var(--color-gris-fondo);
}
/* Custom styling for a highly polished editorial structure */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
+5 -3
View File
@@ -1,10 +1,12 @@
export const ALLOWED_EMAILS = [
"ymadera@gomezleemarketing.com",
"iaracena@gomezleemarketing.com",
'ymadera@gomezleemarketing.com',
'iaracena@gomezleemarketing.com',
'mgomez@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
] as const;
export function normalizeEmail(email?: string | null) {
return (email ?? "").trim().toLowerCase();
return (email ?? '').trim().toLowerCase();
}
export function isAllowedEmail(email?: string | null) {
+5 -1
View File
@@ -1,4 +1,4 @@
import { createClient } from "@supabase/supabase-js";
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string | undefined;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined;
@@ -14,3 +14,7 @@ export const supabase = isSupabaseConfigured
},
})
: null;
export function getAuthRedirectUrl() {
return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
}
+118
View File
@@ -0,0 +1,118 @@
export type PeriodType = 'quincena_15' | 'quincena_30';
export type ViewType = 'cruce' | 'historial';
export type ToastType = 'success' | 'error' | 'info';
export type CountryCode = 'GT' | 'TT';
export interface CountryConfig {
code: CountryCode;
name: string;
flag: string;
currency: string;
locale: string;
enabled: boolean;
}
export type ReconciliationStatus =
| 'Coincidencia'
| 'Riesgo'
| 'No encontrado'
| 'Pendiente revisión'
| 'Resuelto';
export type ReconciliationSource =
| 'nomina_vs_banco'
| 'banco_sin_nomina'
| 'banco_sin_bamboo'
| 'nomina_sin_cuenta'
| 'posible_cuenta_mal_digitada'
| 'diferencia_nombre_banco'
| 'discrepancia'
| 'coincidencia';
export interface UploadedFile {
name: string;
size: string;
type: 'payroll' | 'bank';
rawFile?: File;
}
export interface PeriodRange {
start: string;
end: string;
label: string;
}
export interface ReconciliationRow {
id: string;
employee: string;
employeeNumber: string;
account?: string;
amountPayroll: number | null;
amountBank: number | null;
difference: number | null;
status: ReconciliationStatus;
category?: string;
observation: string;
source?: ReconciliationSource;
}
export interface ReconciliationSummary {
matches: number;
discrepancies: number;
bankWithoutPayroll: number;
bankWithoutBamboo: number;
payrollWithoutAccount: number;
nameDifferences: number;
possibleWrongAccounts: number;
totalPending: number;
totalRows: number;
totalPayroll?: number;
totalBank?: number;
totalDifference?: number;
}
export interface ReconciliationResponse {
ok: boolean;
executionId?: string;
reportUrl?: string;
summary: ReconciliationSummary;
rows: ReconciliationRow[];
message?: string;
}
export interface HistoricalReport {
id: string;
country: CountryCode;
date: string;
executionDate?: string;
period: string;
payrollFile: string;
bankFiles: string[];
matches: number;
discrepancies: number;
bankWithoutPayroll: number;
bankWithoutBamboo: number;
payrollWithoutAccount: number;
nameDifferences: number;
totalPending: number;
totalPayroll?: number;
totalBank?: number;
totalDifference?: number;
status: 'Pendiente revisión' | 'Resuelto';
reportUrl?: string;
spreadsheetId?: string;
executedBy?: string;
executedByEmail?: string;
resolvedBy?: string;
resolvedByEmail?: string;
resolvedAt?: string;
resolutionComment?: string;
}
export interface AppUser {
name: string;
email: string;
}
+21
View File
@@ -1 +1,22 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_N8N_WEBHOOK_URL?: string;
readonly VITE_N8N_GUATEMALA_WEBHOOK_URL?: string;
readonly VITE_N8N_TRINIDAD_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_ANALIZAR_NOMINA_URL?: string;
readonly VITE_ENABLE_SUPABASE_AUTH?: string;
readonly VITE_SUPABASE_URL?: string;
readonly VITE_SUPABASE_ANON_KEY?: string;
readonly VITE_OPERATOR_NAME?: string;
readonly VITE_OPERATOR_EMAIL?: string;
readonly VITE_BASE_PATH?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+5 -6
View File
@@ -1,11 +1,13 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
export default defineConfig(() => {
return {
base: "/cruce-cuentas/",
base: '/cruce-cuentas/',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
@@ -13,10 +15,7 @@ export default defineConfig(() => {
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};