Update project files
This commit is contained in:
+31
-5
@@ -1,7 +1,33 @@
|
|||||||
# Supabase Auth con Google
|
# Portal multinacional - Cruce de Cuentas GLM
|
||||||
# Crea un archivo .env.local y coloca tus valores reales.
|
# Copiar como .env.local y ajustar los valores por ambiente.
|
||||||
VITE_SUPABASE_URL="https://TU-PROYECTO.supabase.co"
|
|
||||||
|
# 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"
|
VITE_SUPABASE_ANON_KEY="TU_SUPABASE_ANON_KEY"
|
||||||
|
|
||||||
# URL pública esperada para producción:
|
# Usuario temporal solo si VITE_ENABLE_SUPABASE_AUTH="false".
|
||||||
# https://digitalcompass.agency/cruce-cuentas/
|
VITE_OPERATOR_NAME="Isaac Aracena"
|
||||||
|
VITE_OPERATOR_EMAIL="iaracena@gomezleemarketing.com"
|
||||||
|
|||||||
@@ -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
@@ -1,9 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Cruce de Cuentas GLM",
|
"name": "Portal de Cruce de Cuentas GLM",
|
||||||
"description": "Portal centralizado de Cruce de Cuentas GLM para la conciliación de cuentas regionales.",
|
"description": "Portal multinacional de GomezLee Marketing para conciliación de nóminas y planillas bancarias.",
|
||||||
"requestFramePermissions": [],
|
"requestFramePermissions": [],
|
||||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+546
-2253
File diff suppressed because it is too large
Load Diff
+8
-17
@@ -1,36 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "cruce-de-cuentas-glm",
|
"name": "cruce-cuentas-glm-guatemala",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"clean": "rm -rf dist server.js",
|
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^2.4.0",
|
"@supabase/supabase-js": "^2.86.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
|
||||||
"lucide-react": "^0.546.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",
|
"motion": "^12.23.24",
|
||||||
"@supabase/supabase-js": "^2.77.0"
|
"react": "^19.0.1",
|
||||||
|
"react-dom": "^19.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.3",
|
"vite": "^6.2.3"
|
||||||
"@types/express": "^4.17.21"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 |
+239
-94
@@ -1,145 +1,290 @@
|
|||||||
/**
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
* @license
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
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";
|
const AUTH_ENABLED = import.meta.env.VITE_ENABLE_SUPABASE_AUTH === 'true';
|
||||||
import type { Session } from "@supabase/supabase-js";
|
const COUNTRY_STORAGE_KEY = 'glm_cruce_selected_country';
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { LoginScreen } from "./components/LoginScreen";
|
const TEMPORARY_USER: AppUser = {
|
||||||
import { PortalPrincipal } from "./components/PortalPrincipal";
|
name: (import.meta.env.VITE_OPERATOR_NAME as string | undefined) || 'Usuario GLM',
|
||||||
import { ALLOWED_EMAILS, isAllowedEmail } from "./lib/auth";
|
email: (import.meta.env.VITE_OPERATOR_EMAIL as string | undefined) || 'usuario@gomezleemarketing.com',
|
||||||
import { isSupabaseConfigured, supabase } from "./lib/supabase";
|
};
|
||||||
|
|
||||||
|
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() {
|
export default function App() {
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [currentView, setCurrentView] = useState<ViewType>('cruce');
|
||||||
const [isLoadingSession, setIsLoadingSession] = useState(true);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const [authError, setAuthError] = useState("");
|
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 authReady = useMemo(() => AUTH_ENABLED && isSupabaseConfigured && supabase, []);
|
||||||
const email = currentSession?.user?.email;
|
const selectedCountry = selectedCountryCode ? getCountryConfig(selectedCountryCode) : null;
|
||||||
|
|
||||||
if (!currentSession) {
|
const handleShowToast = (message: string, type: ToastType) => {
|
||||||
setSession(null);
|
setToast({ message, type });
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSupabaseConfigured || !supabase) {
|
if (!AUTH_ENABLED) return;
|
||||||
setAuthError("Falta configurar Supabase en el archivo .env.local.");
|
|
||||||
setIsLoadingSession(false);
|
if (!authReady || !supabase) {
|
||||||
|
setAuthLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
supabase.auth.getSession().then(async ({ data, error }) => {
|
const applySessionUser = async (sessionUser: any) => {
|
||||||
if (!isMounted) return;
|
if (!sessionUser?.email) {
|
||||||
|
setCurrentUser(null);
|
||||||
if (error) {
|
return;
|
||||||
setAuthError("No se pudo validar la sesión. Intenta iniciar sesión nuevamente.");
|
|
||||||
setSession(null);
|
|
||||||
} else {
|
|
||||||
await validateAndSetSession(data.session);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
setCurrentUser({
|
||||||
await validateAndSetSession(currentSession);
|
name: getDisplayNameFromSessionUser(sessionUser),
|
||||||
setIsLoadingSession(false);
|
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 () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
listener.subscription.unsubscribe();
|
listener.subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [authReady]);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const clearCurrentCruce = () => {
|
||||||
setAuthError("");
|
setUploadedPayroll(null);
|
||||||
|
setUploadedBankFiles([]);
|
||||||
if (!isSupabaseConfigured || !supabase) {
|
setIsCruceExecuted(false);
|
||||||
setAuthError("Falta configurar VITE_SUPABASE_URL y VITE_SUPABASE_ANON_KEY en .env.local.");
|
setCruceResponse(null);
|
||||||
return;
|
setCruceResetToken((token) => token + 1);
|
||||||
}
|
|
||||||
|
|
||||||
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 handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setAuthError("");
|
if (supabase) await supabase.auth.signOut();
|
||||||
await supabase?.auth.signOut();
|
setCurrentUser(AUTH_ENABLED ? null : TEMPORARY_USER);
|
||||||
setSession(null);
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[#f5f3f3] flex items-center justify-center text-[#365c71] text-sm font-semibold">
|
<div className="min-h-screen bg-[#F5F5F5] flex items-center justify-center text-[#4F758B] font-black uppercase tracking-wider">
|
||||||
Validando sesión...
|
Validando acceso...
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="min-h-screen bg-[#fbf9f8]" id="app-container">
|
<div className="min-h-screen bg-[#F5F5F5] selection:bg-[#6CC24A] selection:text-white antialiased font-sans flex flex-col">
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{!isLoggedIn ? (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key="login"
|
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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
className="w-full h-full"
|
className="fixed inset-0 bg-black/45 z-45 md:hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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"
|
||||||
>
|
>
|
||||||
<LoginScreen
|
<Dashboard
|
||||||
onLogin={handleLogin}
|
onShowToast={handleShowToast}
|
||||||
authError={authError}
|
uploadedPayroll={uploadedPayroll}
|
||||||
isSupabaseConfigured={isSupabaseConfigured}
|
setUploadedPayroll={setUploadedPayroll}
|
||||||
allowedEmails={ALLOWED_EMAILS}
|
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>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="portal"
|
key={`view-history-${selectedCountry.code}`}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0, x: 10 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0, x: -10 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="w-full h-full"
|
className="flex-1 flex flex-col"
|
||||||
>
|
>
|
||||||
<PortalPrincipal onLogout={handleLogout} userEmail={session?.user.email ?? ""} />
|
<History
|
||||||
|
onShowToast={handleShowToast}
|
||||||
|
onOpenSidebar={() => setIsSidebarOpen(true)}
|
||||||
|
operatorName={activeUser.name}
|
||||||
|
operatorEmail={activeUser.email}
|
||||||
|
country={selectedCountry}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
--font-display: "Space Grotesk", 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
@@ -1,10 +1,12 @@
|
|||||||
export const ALLOWED_EMAILS = [
|
export const ALLOWED_EMAILS = [
|
||||||
"ymadera@gomezleemarketing.com",
|
'ymadera@gomezleemarketing.com',
|
||||||
"iaracena@gomezleemarketing.com",
|
'iaracena@gomezleemarketing.com',
|
||||||
|
'mgomez@gomezleemarketing.com',
|
||||||
|
'jgomez@gomezleemarketing.com',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function normalizeEmail(email?: string | null) {
|
export function normalizeEmail(email?: string | null) {
|
||||||
return (email ?? "").trim().toLowerCase();
|
return (email ?? '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAllowedEmail(email?: string | null) {
|
export function isAllowedEmail(email?: string | null) {
|
||||||
|
|||||||
+5
-1
@@ -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 supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string | undefined;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined;
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined;
|
||||||
@@ -14,3 +14,7 @@ export const supabase = isSupabaseConfigured
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
export function getAuthRedirectUrl() {
|
||||||
|
return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
|
||||||
|
}
|
||||||
|
|||||||
+118
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+21
@@ -1 +1,22 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <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
@@ -1,11 +1,13 @@
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
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 {
|
return {
|
||||||
base: "/cruce-cuentas/",
|
base: '/cruce-cuentas/',
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -13,10 +15,7 @@ export default defineConfig(() => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
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',
|
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 : {},
|
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user