Initial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/e050747c-a34e-4040-ba6c-436e1b7afe27
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cruce de Cuentas GLM - Guatemala</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Cruce de Cuentas GLM - Guatemala",
|
||||
"description": "Módulo de Cruce de Cuentas de GomezLee Marketing para Guatemala, permitiendo conciliación de nóminas y planillas bancarias.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
Generated
+4284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist server.js",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3",
|
||||
"@types/express": "^4.17.21"
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import Login from './components/Login';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import History from './components/History';
|
||||
import Toast from './components/Toast';
|
||||
import { UploadedFile, ViewType } from './types';
|
||||
|
||||
export default function App() {
|
||||
const userEmail = 'Isaacaracenatoribio@gmail.com';
|
||||
|
||||
// Auth state
|
||||
const [user, setUser] = useState<string | null>(null);
|
||||
|
||||
// Layout view state
|
||||
const [currentView, setCurrentView] = useState<ViewType>('cruce');
|
||||
|
||||
// Mobile sidebar states
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
// Attachment files state
|
||||
const [uploadedPayroll, setUploadedPayroll] = useState<UploadedFile | null>(null);
|
||||
const [uploadedBank, setUploadedBank] = useState<UploadedFile | null>(null);
|
||||
|
||||
// Reconciliation processing status
|
||||
const [isCruceExecuted, setIsCruceExecuted] = useState<boolean>(false);
|
||||
|
||||
// Central Notification state
|
||||
const [toast, setToast] = useState<{
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
} | null>(null);
|
||||
|
||||
const handleShowToast = (message: string, type: 'success' | 'error' | 'info') => {
|
||||
setToast({ message, type });
|
||||
};
|
||||
|
||||
const handleLoginSuccess = (email: string) => {
|
||||
setUser(email);
|
||||
handleShowToast(`Sesión iniciada correctamente como ${email}`, 'success');
|
||||
};
|
||||
|
||||
const handleNewCruce = () => {
|
||||
setUploadedPayroll(null);
|
||||
setUploadedBank(null);
|
||||
setIsCruceExecuted(false);
|
||||
setCurrentView('cruce');
|
||||
handleShowToast('Nuevo cruce iniciado.', 'success');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setUser(null);
|
||||
setUploadedPayroll(null);
|
||||
setUploadedBank(null);
|
||||
setIsCruceExecuted(false);
|
||||
setCurrentView('cruce');
|
||||
handleShowToast('Sesión cerrada correctamente. Hasta pronto.', 'info');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F5F5F5] selection:bg-[#6CC24A] selection:text-white antialiased font-sans flex flex-col">
|
||||
<AnimatePresence mode="wait">
|
||||
{!user ? (
|
||||
<motion.div
|
||||
key="login"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<Login onLoginSuccess={handleLoginSuccess} userEmail={userEmail} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="interface"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="min-h-screen flex w-full relative overflow-x-hidden"
|
||||
>
|
||||
{/* Backdrop overlay for mobile sidebar */}
|
||||
<AnimatePresence>
|
||||
{isSidebarOpen && (
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
className="fixed inset-0 bg-black/45 z-45 md:hidden"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Barra lateral izquierda */}
|
||||
<Sidebar
|
||||
currentView={currentView}
|
||||
onViewChange={(view) => {
|
||||
setCurrentView(view);
|
||||
setIsSidebarOpen(false);
|
||||
}}
|
||||
onNewCruce={() => {
|
||||
handleNewCruce();
|
||||
setIsSidebarOpen(false);
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
onCloseSidebar={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Contenedor Principal Ajustado al margen de la barra lateral */}
|
||||
<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"
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<Dashboard
|
||||
onShowToast={handleShowToast}
|
||||
uploadedPayroll={uploadedPayroll}
|
||||
setUploadedPayroll={setUploadedPayroll}
|
||||
uploadedBank={uploadedBank}
|
||||
setUploadedBank={setUploadedBank}
|
||||
isCruceExecuted={isCruceExecuted}
|
||||
setIsCruceExecuted={setIsCruceExecuted}
|
||||
onOpenSidebar={() => setIsSidebarOpen(true)}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="view-history"
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<History
|
||||
onShowToast={handleShowToast}
|
||||
onOpenSidebar={() => setIsSidebarOpen(true)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Global Toast Alerts */}
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Upload,
|
||||
Play,
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
Database,
|
||||
Trash2,
|
||||
User,
|
||||
Info,
|
||||
Menu
|
||||
} from 'lucide-react';
|
||||
import { UploadedFile, ComparisonResult } from '../types';
|
||||
|
||||
interface DashboardProps {
|
||||
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||
uploadedPayroll: UploadedFile | null;
|
||||
setUploadedPayroll: (file: UploadedFile | null) => void;
|
||||
uploadedBank: UploadedFile | null;
|
||||
setUploadedBank: (file: UploadedFile | null) => void;
|
||||
isCruceExecuted: boolean;
|
||||
setIsCruceExecuted: (val: boolean) => void;
|
||||
onOpenSidebar?: () => void;
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
onShowToast,
|
||||
uploadedPayroll,
|
||||
setUploadedPayroll,
|
||||
uploadedBank,
|
||||
setUploadedBank,
|
||||
isCruceExecuted,
|
||||
setIsCruceExecuted,
|
||||
onOpenSidebar,
|
||||
}: DashboardProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [filterMode, setFilterMode] = useState<'todos' | 'discrepancias' | 'coincidencias'>('todos');
|
||||
|
||||
// Input References
|
||||
const payrollInputRef = useRef<HTMLInputElement>(null);
|
||||
const bankInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Exact discrepancies from prompt text specifications
|
||||
const discrepancies: ComparisonResult[] = [
|
||||
{
|
||||
employee: 'Carlos Méndez / DPI 001',
|
||||
dni: 'DPI 001',
|
||||
amountPayroll: 3500.0,
|
||||
amountBank: 3400.0,
|
||||
status: 'Riesgo',
|
||||
observation: 'Diferencia de GTQ 100.00',
|
||||
},
|
||||
{
|
||||
employee: 'Ana López / DPI 002',
|
||||
dni: 'DPI 002',
|
||||
amountPayroll: 4250.0,
|
||||
amountBank: 4000.0,
|
||||
status: 'Riesgo',
|
||||
observation: 'Diferencia de GTQ 250.00',
|
||||
},
|
||||
{
|
||||
employee: 'Roberto Pérez / DPI 003',
|
||||
dni: 'DPI 003',
|
||||
amountPayroll: 6000.0,
|
||||
amountBank: 6250.0,
|
||||
status: 'Riesgo',
|
||||
observation: 'Diferencia de GTQ 250.00',
|
||||
},
|
||||
];
|
||||
|
||||
// Healthy matched entries to show full visual fidelity
|
||||
const matches: ComparisonResult[] = [
|
||||
{
|
||||
employee: 'María Bolaños / DPI 104',
|
||||
dni: 'DPI 104',
|
||||
amountPayroll: 5000.0,
|
||||
amountBank: 5000.0,
|
||||
status: 'Coincidencia',
|
||||
observation: 'Conciliado correctamente',
|
||||
},
|
||||
{
|
||||
employee: 'Luis Fernando Gómez / DPI 105',
|
||||
dni: 'DPI 105',
|
||||
amountPayroll: 3200.0,
|
||||
amountBank: 3200.0,
|
||||
status: 'Coincidencia',
|
||||
observation: 'Conciliado correctamente',
|
||||
},
|
||||
{
|
||||
employee: 'Gabriela Estévez / DPI 106',
|
||||
dni: 'DPI 106',
|
||||
amountPayroll: 7500.0,
|
||||
amountBank: 7500.0,
|
||||
status: 'Coincidencia',
|
||||
observation: 'Conciliado correctamente',
|
||||
},
|
||||
{
|
||||
employee: 'Julio Castillo / DPI 107',
|
||||
dni: 'DPI 107',
|
||||
amountPayroll: 4800.0,
|
||||
amountBank: 4800.0,
|
||||
status: 'Coincidencia',
|
||||
observation: 'Conciliado correctamente',
|
||||
},
|
||||
];
|
||||
|
||||
const allResults = [...discrepancies, ...matches];
|
||||
const filteredResults = allResults.filter((r) => {
|
||||
if (filterMode === 'discrepancias') return r.status === 'Riesgo';
|
||||
if (filterMode === 'coincidencias') return r.status === 'Coincidencia';
|
||||
return true;
|
||||
});
|
||||
|
||||
// Load demo data helper
|
||||
const handleLoadDemoData = () => {
|
||||
setUploadedPayroll({
|
||||
name: 'Nomina_General_GT_GomezLee_Junio.xlsx',
|
||||
size: '142.5 KB',
|
||||
type: 'payroll',
|
||||
});
|
||||
setUploadedBank({
|
||||
name: 'Banco_Industrial_Consolidado_GT.csv',
|
||||
size: '284.1 KB',
|
||||
type: 'bank',
|
||||
});
|
||||
setIsCruceExecuted(false);
|
||||
onShowToast('Datos de demostración cargados de forma segura.', 'success');
|
||||
};
|
||||
|
||||
// Drag over triggers
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Drag down handles
|
||||
const handlePayrollDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
|
||||
setUploadedPayroll({
|
||||
name: file.name,
|
||||
size: `${(file.size / 1024).toFixed(1)} KB`,
|
||||
type: 'payroll',
|
||||
rawFile: file,
|
||||
});
|
||||
onShowToast('Archivo de Nómina cargado con éxito.', 'success');
|
||||
} else {
|
||||
onShowToast('Formato inválido. Por favor cargue un archivo Excel (.xlsx, .xls).', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBankDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
if (file.name.endsWith('.csv')) {
|
||||
setUploadedBank({
|
||||
name: file.name,
|
||||
size: `${(file.size / 1024).toFixed(1)} KB`,
|
||||
type: 'bank',
|
||||
rawFile: file,
|
||||
});
|
||||
onShowToast('Planilla del Banco cargada con éxito.', 'success');
|
||||
} else {
|
||||
onShowToast('Formato inválido. Por favor cargue un archivo CSV (.csv).', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayrollSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setUploadedPayroll({
|
||||
name: file.name,
|
||||
size: `${(file.size / 1024).toFixed(1)} KB`,
|
||||
type: 'payroll',
|
||||
rawFile: file,
|
||||
});
|
||||
onShowToast('Archivo de Nómina cargado con éxito.', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBankSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setUploadedBank({
|
||||
name: file.name,
|
||||
size: `${(file.size / 1024).toFixed(1)} KB`,
|
||||
type: 'bank',
|
||||
rawFile: file,
|
||||
});
|
||||
onShowToast('Planilla del Banco cargada con éxito.', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteCruce = () => {
|
||||
if (!uploadedPayroll) {
|
||||
onShowToast('Debes cargar la nómina de Guatemala antes de ejecutar el cruce.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!uploadedBank) {
|
||||
onShowToast('Debes cargar al menos una planilla del banco antes de ejecutar el cruce.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setTimeout(() => {
|
||||
setIsProcessing(false);
|
||||
setIsCruceExecuted(true);
|
||||
onShowToast('Cruce de cuentas ejecutado con éxito. Se detectaron 3 discrepancias.', 'success');
|
||||
|
||||
// Auto scroll to results section
|
||||
setTimeout(() => {
|
||||
const elem = document.getElementById('resultados-seccion');
|
||||
if (elem) {
|
||||
elem.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, 200);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleOpenGoogleSheets = () => {
|
||||
onShowToast('Integración con Google Sheets pendiente de configurar.', 'info');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-screen bg-neutral-50 text-neutral-800 font-sans">
|
||||
{/* Header Fijo Superior */}
|
||||
<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">
|
||||
{/* Botón Hamburger en Móvil */}
|
||||
<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">Cruce de Cuentas GLM - Guatemala</span>
|
||||
<img
|
||||
src="https://lh3.googleusercontent.com/aida-public/AB6AXuBXvQ4pukehhE44-3aWRLiMtevkiVFYrNyn0wGL9ZyU8t3g2KRbeaJTA9EwodWoTybn1PlKBQ0aYzKj3qDI3s1MncSK8LpvMyZ2UXCisL2PBUvedp7ilYjxLWaDFfN--lDjgCOkzIELT0Z-Fa8Z8FCu8gJ8AQKospuDRD5QO-Gm9aKoqMrsTHuKpZf0yfI1u1voeFJFzvB61a2C3dsv7_Da0fJT0EIiK3QTRM_cnJNC7qPjJiUMTZxO24m64mEJ40-przxb2GYP0R4"
|
||||
alt="Guatemala Flag"
|
||||
className="h-4 w-7 sm:h-5 sm:w-8 md:h-6 md:w-10 object-cover rounded border border-neutral-300 ml-0.5 inline-block select-none flex-shrink-0"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="bg-neutral-100 p-2.5 rounded-full border border-neutral-200">
|
||||
<User className="w-5 h-5 text-neutral-600" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-6 py-8 flex flex-col gap-8">
|
||||
|
||||
{/* Banner para cargar datos rápidos de prueba */}
|
||||
<div className="bg-gradient-to-r from-[#4F758B]/5 to-sky-600/5 border border-[#4F758B]/20 p-5 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Database className="w-6 h-6 text-[#4F758B] mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-[#4F758B] leading-none">¿No tienes archivos a la mano?</h3>
|
||||
<p className="text-xs text-neutral-500 mt-1 leading-relaxed">
|
||||
Carga nuestro set de datos de prueba preconfigurado de Guatemala para ver el funcionamiento en tiempo real.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLoadDemoData}
|
||||
className="whitespace-nowrap border border-[#4F758B]/30 hover:border-[#4F758B] hover:bg-white text-[#4F758B] text-xs font-bold px-4 py-2 transition-all active:scale-98"
|
||||
>
|
||||
Cargar Datos de Demostración
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zona de Carga de Archivos - Dos Tarjetas */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* Card A: 1. CARGA DE NÓMINA */}
|
||||
<div className="bg-white border border-[#D0D0D0] p-6 flex flex-col justify-between relative shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-[#4F758B]" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<FileSpreadsheet className="w-5 h-5 text-[#4F758B]" />
|
||||
<h2 className="text-sm font-black text-neutral-800 uppercase tracking-wider">
|
||||
1. Carga de Nómina
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Input real escondido */}
|
||||
<input
|
||||
type="file"
|
||||
ref={payrollInputRef}
|
||||
onChange={handlePayrollSelect}
|
||||
accept=".xlsx, .xls"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{uploadedPayroll ? (
|
||||
<div className="border border-[#6CC24A]/30 bg-[#6CC24A]/5 p-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet className="w-8 h-8 text-[#6CC24A]" />
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-xs font-bold text-neutral-700 truncate max-w-[200px]" title={uploadedPayroll.name}>
|
||||
{uploadedPayroll.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-neutral-400 font-mono mt-0.5">
|
||||
{uploadedPayroll.size} • Excel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setUploadedPayroll(null);
|
||||
onShowToast('Nómina removida.', 'info');
|
||||
}}
|
||||
className="p-1.5 text-neutral-400 hover:text-red-500 hover:bg-neutral-100 transition-all rounded"
|
||||
>
|
||||
<Trash2 className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handlePayrollDrop}
|
||||
onClick={() => payrollInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-[#D0D0D0] bg-neutral-50 hover:bg-[#4F758B]/5 hover:border-[#4F758B]/40 transition-colors flex flex-col items-center justify-center py-10 px-4 text-center cursor-pointer min-h-[170px]"
|
||||
>
|
||||
<Upload className="w-8 h-8 text-neutral-300 mb-2" />
|
||||
<p className="text-xs font-bold text-neutral-700">Arrastre y suelte archivo Excel</p>
|
||||
<p className="text-[10px] text-neutral-400 mt-1 font-medium">Formato .xlsx, .xls</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 border border-[#D0D0D0] bg-white px-4 py-1.5 text-[10px] font-bold text-neutral-600 hover:border-[#4F758B] hover:text-[#4F758B] transition-colors shadow-sm uppercase tracking-wide"
|
||||
>
|
||||
Seleccionar Archivo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card B: 2. CARGA DE PLANILLAS DEL BANCO */}
|
||||
<div className="bg-white border border-[#D0D0D0] p-6 flex flex-col justify-between relative shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-[#4F758B]" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<FileText className="w-5 h-5 text-[#4F758B]" />
|
||||
<h2 className="text-sm font-black text-neutral-800 uppercase tracking-wider">
|
||||
2. Carga de planillas del banco
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-400 leading-normal mb-4 font-medium">
|
||||
Puede subir múltiples archivos separados o el archivo consolidado del período.
|
||||
</p>
|
||||
|
||||
{/* Input real escondido */}
|
||||
<input
|
||||
type="file"
|
||||
ref={bankInputRef}
|
||||
onChange={handleBankSelect}
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{uploadedBank ? (
|
||||
<div className="border border-[#6CC24A]/30 bg-[#6CC24A]/5 p-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-[#6CC24A]" />
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-xs font-bold text-neutral-700 truncate max-w-[200px]" title={uploadedBank.name}>
|
||||
{uploadedBank.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-neutral-400 font-mono mt-0.5">
|
||||
{uploadedBank.size} • CSV Banco
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setUploadedBank(null);
|
||||
onShowToast('Planilla de banco removida.', 'info');
|
||||
}}
|
||||
className="p-1.5 text-neutral-400 hover:text-red-500 hover:bg-neutral-100 transition-all rounded"
|
||||
>
|
||||
<Trash2 className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleBankDrop}
|
||||
onClick={() => bankInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-[#D0D0D0] bg-neutral-50 hover:bg-[#4F758B]/5 hover:border-[#4F758B]/40 transition-colors flex flex-col items-center justify-center py-10 px-4 text-center cursor-pointer min-h-[170px]"
|
||||
>
|
||||
<Upload className="w-8 h-8 text-neutral-300 mb-2" />
|
||||
<p className="text-xs font-bold text-neutral-700">Arrastre y suelte archivo CSV</p>
|
||||
<p className="text-[10px] text-neutral-400 mt-1 font-medium">Formatos soportados: .csv</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 border border-[#D0D0D0] bg-white px-4 py-1.5 text-[10px] font-bold text-neutral-600 hover:border-[#4F758B] hover:text-[#4F758B] transition-colors shadow-sm uppercase tracking-wide"
|
||||
>
|
||||
Seleccionar Archivos
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Botón Principal de Ejecución */}
|
||||
<section className="flex flex-col items-center py-4 bg-white border border-[#D0D0D0]">
|
||||
<div className="w-full max-w-md px-4">
|
||||
<button
|
||||
onClick={handleExecuteCruce}
|
||||
disabled={isProcessing}
|
||||
className={`w-full bg-[#4F758B] hover:bg-[#3d6378] disabled:bg-neutral-300 text-white font-bold py-5 px-8 flex items-center justify-center gap-3 transition-all duration-150 transform hover:translate-y-[-1px] active:translate-y-[1px] shadow border-l-8 border-[#6CC24A] relative overflow-hidden`}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<svg className="animate-spin h-6 w-6 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 className="text-sm tracking-wider uppercase font-extrabold">Procesando Conciliación...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5 text-[#6CC24A] fill-current" />
|
||||
<span className="text-sm md:text-base tracking-wider uppercase font-black">
|
||||
EJECUTAR CRUCE DE CUENTAS
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isProcessing && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-xs text-neutral-400 mt-3 font-mono"
|
||||
>
|
||||
Cruzando 1,251 registros mediante algoritmos de inteligencia de cuentas...
|
||||
</motion.p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sección de Resultados del Cruce */}
|
||||
<AnimatePresence>
|
||||
{isCruceExecuted && (
|
||||
<motion.section
|
||||
id="resultados-seccion"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 30 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex flex-col gap-5 border border-[#D0D0D0] bg-white p-6 shadow-sm"
|
||||
>
|
||||
{/* Header de resultados */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between border-b pb-4 gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-[#4F758B] tracking-tight uppercase">
|
||||
Resultados del Cruce
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400 mt-0.5">
|
||||
Análisis cruzado del período de nómina de Guatemala.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs font-semibold">
|
||||
<span className="flex items-center gap-1.5 bg-emerald-50 text-emerald-800 border border-emerald-200 px-3 py-1">
|
||||
<span className="w-2.5 h-2.5 bg-[#6CC24A] rounded-full inline-block" />
|
||||
<span>Coincidencias: 1,248</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 bg-red-50 text-red-800 border border-red-200 px-3 py-1">
|
||||
<span className="w-2.5 h-2.5 bg-[#ba1a1a] rounded-full inline-block" />
|
||||
<span>Discrepancias: 3</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controles de Filtrado */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase font-bold text-neutral-400 tracking-wider">Filtrar tabla:</span>
|
||||
<div className="flex bg-neutral-100 p-0.5 rounded border border-neutral-200 text-[11px] font-bold">
|
||||
<button
|
||||
onClick={() => setFilterMode('todos')}
|
||||
className={`px-3 py-1 rounded transition-colors ${
|
||||
filterMode === 'todos'
|
||||
? 'bg-[#4F758B] text-white'
|
||||
: 'text-neutral-500 hover:text-neutral-800'
|
||||
}`}
|
||||
>
|
||||
Todos ({allResults.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterMode('discrepancias')}
|
||||
className={`px-3 py-1 rounded transition-colors ${
|
||||
filterMode === 'discrepancias'
|
||||
? 'bg-[#ba1a1a] text-white'
|
||||
: 'text-neutral-500 hover:text-red-500'
|
||||
}`}
|
||||
>
|
||||
Solo Discrepancias (3)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterMode('coincidencias')}
|
||||
className={`px-3 py-1 rounded transition-colors ${
|
||||
filterMode === 'coincidencias'
|
||||
? 'bg-[#6CC24A] text-white'
|
||||
: 'text-neutral-500 hover:text-emerald-600'
|
||||
}`}
|
||||
>
|
||||
Solo Coincidencias (4)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabla de resultados */}
|
||||
<div className="overflow-x-auto border border-[#D0D0D0]">
|
||||
<table className="w-full text-left border-collapse min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="bg-[#4F758B] text-white text-xs font-bold uppercase tracking-wider">
|
||||
<th className="py-3.5 px-4">Empleado / ID</th>
|
||||
<th className="py-3.5 px-4 text-right">Monto Nómina (GTQ)</th>
|
||||
<th className="py-3.5 px-4 text-right">Monto Banco (GTQ)</th>
|
||||
<th className="py-3.5 px-4 text-center">Estado</th>
|
||||
<th className="py-3.5 px-4">Observación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 text-xs">
|
||||
{filteredResults.map((row, index) => {
|
||||
const isRisk = row.status === 'Riesgo';
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={`hover:bg-neutral-50 transition-colors ${
|
||||
isRisk
|
||||
? 'bg-red-50/45 border-l-4 border-l-[#ba1a1a]'
|
||||
: 'bg-white border-l-4 border-l-emerald-500'
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4 font-bold text-neutral-800">{row.employee}</td>
|
||||
<td className="py-3 px-4 text-right font-mono font-bold">
|
||||
{row.amountPayroll.toLocaleString('es-GT', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</td>
|
||||
<td className={`py-3 px-4 text-right font-mono font-bold ${isRisk ? 'text-[#ba1a1a]' : 'text-neutral-700'}`}>
|
||||
{row.amountBank.toLocaleString('es-GT', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span
|
||||
className={`inline-block px-2.5 py-1 text-[9px] font-bold border ${
|
||||
isRisk
|
||||
? 'bg-red-100/70 text-[#ba1a1a] border-red-300'
|
||||
: 'bg-emerald-100/70 text-emerald-800 border-emerald-300'
|
||||
}`}
|
||||
>
|
||||
{row.status === 'Riesgo' ? 'RIESGO' : 'COINCIDENCIA'}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`py-3 px-4 font-semibold ${isRisk ? 'text-[#ba1a1a]' : 'text-neutral-500'}`}>
|
||||
{row.observation}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Botón Secundario Google Sheets */}
|
||||
<div className="flex md:justify-end mt-2">
|
||||
<button
|
||||
onClick={handleOpenGoogleSheets}
|
||||
className="w-full md:w-auto flex items-center justify-center gap-2 border border-[#D0D0D0] bg-white text-neutral-700 hover:border-[#6CC24A] hover:text-[#6CC24A] transition-all px-6 py-3 text-xs font-bold uppercase tracking-wider shadow-sm active:scale-98"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-[#6CC24A]" />
|
||||
<span>Abrir Reporte en Google Sheets</span>
|
||||
</button>
|
||||
</div>
|
||||
</motion.section>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Search, Calendar, FileSpreadsheet, Eye, Filter, User, Menu } from 'lucide-react';
|
||||
import { HistoricalReport } from '../types';
|
||||
|
||||
interface HistoryProps {
|
||||
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||
onOpenSidebar?: () => void;
|
||||
}
|
||||
|
||||
export default function History({ onShowToast, onOpenSidebar }: HistoryProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 3 high-fidelity simulated historical records in Guatemala
|
||||
const reports: HistoricalReport[] = [
|
||||
{
|
||||
id: 'REP-082',
|
||||
date: '2026-05-30',
|
||||
period: 'Mayo 2026',
|
||||
payrollFile: 'Nomina_GomezLee_GT_Mayo_Final.xlsx',
|
||||
bankFiles: ['Banco_BI_Mayo_V1.csv'],
|
||||
discrepancies: 3,
|
||||
status: 'Procesado',
|
||||
},
|
||||
{
|
||||
id: 'REP-079',
|
||||
date: '2026-04-28',
|
||||
period: 'Abril 2026',
|
||||
payrollFile: 'Nomina_GomezLee_GT_Abril_v2.xlsx',
|
||||
bankFiles: ['Banco_BI_Abril_Consolidado.csv'],
|
||||
discrepancies: 1,
|
||||
status: 'Procesado',
|
||||
},
|
||||
{
|
||||
id: 'REP-065',
|
||||
date: '2026-03-31',
|
||||
period: 'Marzo 2026',
|
||||
payrollFile: 'Nomina_GomezLee_GT_Marzo_Final_Correcciones.xlsx',
|
||||
bankFiles: ['Banco_BI_Marzo_Completo.csv'],
|
||||
discrepancies: 0,
|
||||
status: 'Procesado',
|
||||
},
|
||||
];
|
||||
|
||||
const filteredReports = reports.filter(
|
||||
(r) =>
|
||||
r.period.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
r.payrollFile.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleViewReport = (report: HistoricalReport) => {
|
||||
onShowToast(
|
||||
`Cargando reporte histórico ${report.id} (${report.period}). Integración de visor de archivos históricos pendiente.`,
|
||||
'info'
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-screen bg-neutral-50 text-neutral-800 font-sans">
|
||||
{/* Header Fijo Superior */}
|
||||
<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">
|
||||
{/* Botón Hamburger en Móvil */}
|
||||
<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 - Guatemala</span>
|
||||
<img
|
||||
src="https://lh3.googleusercontent.com/aida-public/AB6AXuBXvQ4pukehhE44-3aWRLiMtevkiVFYrNyn0wGL9ZyU8t3g2KRbeaJTA9EwodWoTybn1PlKBQ0aYzKj3qDI3s1MncSK8LpvMyZ2UXCisL2PBUvedp7ilYjxLWaDFfN--lDjgCOkzIELT0Z-Fa8Z8FCu8gJ8AQKospuDRD5QO-Gm9aKoqMrsTHuKpZf0yfI1u1voeFJFzvB61a2C3dsv7_Da0fJT0EIiK3QTRM_cnJNC7qPjJiUMTZxO24m64mEJ40-przxb2GYP0R4"
|
||||
alt="Guatemala Flag"
|
||||
className="h-4 w-7 sm:h-5 sm:w-8 md:h-6 md:w-10 object-cover rounded border border-neutral-300 ml-0.5 inline-block select-none flex-shrink-0"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="bg-neutral-100 p-2.5 rounded-full border border-neutral-200">
|
||||
<User className="w-5 h-5 text-neutral-600" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-6 py-8 flex flex-col gap-6">
|
||||
|
||||
{/* Barra de Filtro y Búsqueda */}
|
||||
<div className="bg-white border border-[#D0D0D0] p-4 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-sm">
|
||||
<div className="relative w-full sm: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 o nombre de archivo de nómina..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.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 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>
|
||||
|
||||
{/* Tabla Histórica */}
|
||||
<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-[800px]">
|
||||
<thead>
|
||||
<tr className="bg-[#4F758B] text-white text-xs font-bold uppercase tracking-wider">
|
||||
<th className="py-4 px-5">Fecha de Ejecución</th>
|
||||
<th className="py-4 px-5">Período de Conciliación</th>
|
||||
<th className="py-4 px-5">Archivo de Nómina</th>
|
||||
<th className="py-4 px-5 text-center">Discrepancias</th>
|
||||
<th className="py-4 px-5 text-center">Estado</th>
|
||||
<th className="py-4 px-5 text-right">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 text-xs">
|
||||
{filteredReports.length > 0 ? (
|
||||
filteredReports.map((report) => (
|
||||
<motion.tr
|
||||
key={report.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="hover:bg-neutral-50/70 transition-colors"
|
||||
>
|
||||
{/* Fecha */}
|
||||
<td className="py-4 px-5 font-medium text-neutral-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-neutral-400" />
|
||||
<span>{report.date}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Período */}
|
||||
<td className="py-4 px-5 font-bold text-[#4F758B]">
|
||||
{report.period}
|
||||
</td>
|
||||
|
||||
{/* Archivo de nómina */}
|
||||
<td className="py-4 px-5 font-mono text-neutral-500 truncate max-w-[220px]" 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}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Discrepancias */}
|
||||
<td className="py-4 px-5 text-center">
|
||||
<span
|
||||
className={`inline-block px-2.5 py-1 text-[10px] font-bold ${
|
||||
report.discrepancies > 0
|
||||
? 'bg-red-50 text-[#ba1a1a] border border-red-200'
|
||||
: 'bg-emerald-50 text-emerald-800 border border-emerald-250'
|
||||
}`}
|
||||
>
|
||||
{report.discrepancies === 0
|
||||
? 'Sin discrepancias'
|
||||
: `${report.discrepancies} Discrepancia${report.discrepancies > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Estado */}
|
||||
<td className="py-4 px-5 text-center">
|
||||
<span className="inline-block px-2.5 py-0.5 text-[8.5px] uppercase font-bold tracking-wider bg-neutral-100 text-neutral-600 border border-neutral-300">
|
||||
{report.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Acción */}
|
||||
<td className="py-4 px-5 text-right">
|
||||
<button
|
||||
onClick={() => handleViewReport(report)}
|
||||
className="inline-flex items-center gap-1.5 text-[#4F758B] hover:text-[#3d6378] font-bold text-xs border border-transparent hover:border-[#4F758B]/30 px-3 py-1.5 bg-neutral-100 hover:bg-neutral-200/50 transition-colors"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Ver reporte</span>
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center text-neutral-400 font-medium">
|
||||
No se encontraron reportes históricos que coincidan con la búsqueda.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</main>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { LogIn } from 'lucide-react';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: (email: string) => void;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export default function Login({ onLoginSuccess, userEmail }: LoginProps) {
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
setIsLoggingIn(true);
|
||||
// Mimic authentic authentication delay
|
||||
setTimeout(() => {
|
||||
onLoginSuccess(userEmail || 'personal.autorizado@gomezlee.com');
|
||||
setIsLoggingIn(false);
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col justify-between bg-[#F5F5F5] relative overflow-hidden font-sans">
|
||||
{/* Sutil línea de acento decorativa en el tope */}
|
||||
<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"
|
||||
>
|
||||
{/* Bandera de Guatemala */}
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
alt="Bandera de Guatemala"
|
||||
className="w-12 h-8 object-cover border border-[#D0D0D0] shadow-sm"
|
||||
src="https://lh3.googleusercontent.com/aida-public/AB6AXuAfAWluoyEr9ri3beOz2pjFcBmBeukOKVLdaBA-9ZgzS3VfxLXQYZI-eln2ry8PLseoDcZmUcb4hCXCb52jPAO4kZ9FaZTG3JEoDLqMIHiHn3z-fZ9u2QcIlHHACv5i4U0ml5LFEhkH3jgvfxqU8mIOxU2CeuuFL4My27KQ6FiFC3KRX402JW3wsUpE8hVylZ5fl5lqRAsvfbKCeX0hNGql_lTZ7Qae4ErpGgDdF-XoqJibleuxdt0nxS4DlscXnhZVPztNSptNosI"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo GomezLee Marketing */}
|
||||
<div className="w-full flex justify-center py-2">
|
||||
<img
|
||||
alt="Logo GOMEZLEE MARKETING"
|
||||
className="h-14 object-contain"
|
||||
src="https://lh3.googleusercontent.com/aida-public/AB6AXuD5YsmD8GbUpvSp1rZX56DMuy8KMAqLH7TxyaRYzR-3gcSV4FZwFB_bYZdZeF2LTzvUTq3hMzDKp_VtIJQDM80ir7A54E043B6c8L-HMfgfT-tU8KrYvfkHaS5doUOiuyNdIBi8OqY4-cbkVQh4SiHWkWcr5WmQt_ts0Evmt_QdUpgVsMsvFD2WFweBo2zCAnQR5puSXZcFfq6ejL99G1hmyC_0BUMmXxCVnZRyd2SKS8qu8YPMeg0kWe9OHNfNppGlW_JJlIwDVHA"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center w-full">
|
||||
<h1 className="text-2xl font-bold text-[#4F758B] tracking-tight">
|
||||
Cruce de Cuentas GLM - GT
|
||||
</h1>
|
||||
<p className="text-xs text-neutral-500 mt-2 font-medium">
|
||||
Acceso seguro al módulo de Cruce de Cuentas de Guatemala.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Botón de control Continuar con Google */}
|
||||
<button
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isLoggingIn}
|
||||
className="w-full relative overflow-hidden flex items-center justify-center gap-3 bg-[#6CC24A] hover:bg-[#5bb03c] disabled:bg-[#6CC24A]/70 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>Verificando con 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>
|
||||
|
||||
<p className="text-[10px] uppercase text-neutral-400 tracking-wider text-center mt-2 leading-relaxed">
|
||||
Uso exclusivo para personal autorizado de<br />
|
||||
<strong className="text-neutral-500 font-bold">GOMEZLEE MARKETING.</strong>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Sutil línea verde decorativa en la base */}
|
||||
<div className="h-2 w-full bg-[#6CC24A]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeftRight, History, Plus, LogOut, X } from 'lucide-react';
|
||||
import { ViewType } from '../types';
|
||||
|
||||
interface SidebarProps {
|
||||
currentView: ViewType;
|
||||
onViewChange: (view: ViewType) => void;
|
||||
onNewCruce: () => void;
|
||||
onLogout: () => void;
|
||||
isSidebarOpen?: boolean;
|
||||
onCloseSidebar?: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
currentView,
|
||||
onViewChange,
|
||||
onNewCruce,
|
||||
onLogout,
|
||||
isSidebarOpen = false,
|
||||
onCloseSidebar,
|
||||
}: SidebarProps) {
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{/* Línea vertical divisora superior (sección logo, arriba de la franja verde) */}
|
||||
<div className="absolute right-0 top-0 h-[80px] w-px bg-[#D0D0D0] pointer-events-none z-10 hidden md:block" />
|
||||
|
||||
{/* Línea vertical divisora inferior (comienza debajo de la franja verde de 84px) */}
|
||||
<div className="absolute right-0 top-[84px] bottom-0 w-px bg-[#D0D0D0] pointer-events-none z-10 hidden md:block" />
|
||||
|
||||
{/* Container del Logo y botón de cerrar en móvil */}
|
||||
<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="https://lh3.googleusercontent.com/aida-public/AB6AXuDysoZ3MyoyIDdxfCZZvTzwgxSv0n87ea6ITKUdsjp7CR2DHlEo2MxtX5CybFeL4OiW3N0vfbmFBGkTTivNqHLCfyvhCu36qbXCzmo5cmAfJqN_a7dxfWdy_pJMKi5b8N06wOwEUWTp4F9B6Pt0RiJmD9nxB6M8NX-LJYlZ3jdOOB9GmizSKPEh_VLWBNPQIlgDFnkYYeIdkAvAJPgXWFjaOJvZevJAggzrna89zYoY9byCEeocc2Ew1TCoX16MTnFK78M5K-CujSk"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
|
||||
{/* Botón de cerrar barra lateral en Móvil */}
|
||||
<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>
|
||||
|
||||
{/* Menú de Navegación */}
|
||||
<nav className="flex-1 flex flex-col mt-6">
|
||||
<ul className="space-y-1">
|
||||
{/* Item 1: Cruce de Cuentas */}
|
||||
<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>
|
||||
|
||||
{/* Item 2: Reportes Históricos */}
|
||||
<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>
|
||||
|
||||
{/* Botones inferiores de acción */}
|
||||
<div className="px-4 space-y-3 pt-4 border-t border-[#D0D0D0] bg-neutral-100">
|
||||
{/* Botón Verde: + Nuevo Cruce */}
|
||||
<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>
|
||||
|
||||
{/* Botón de Cerrar Sesión con borde rojo elegante y refinado */}
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full flex items-center justify-center gap-2 border border-red-200/80 bg-red-50/50 hover:bg-red-100/60 hover:border-red-500 text-red-600 px-4 py-2.5 transition-all duration-150 text-xs font-bold uppercase tracking-wider active:scale-98"
|
||||
>
|
||||
<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,28 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
--color-azul-glm: #4F758B;
|
||||
--color-verde-glm: #6CC24A;
|
||||
--color-verde-claro: #A4D65E;
|
||||
--color-gris-fondo: #F5F5F5;
|
||||
--color-border-gray: #D0D0D0;
|
||||
--color-guatemala-blue: #4997D0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-gris-fondo);
|
||||
}
|
||||
|
||||
/* Custom styling for a highly polished editorial structure */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface PayrollRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
dni: string;
|
||||
amountPayroll: number;
|
||||
}
|
||||
|
||||
export interface BankRecord {
|
||||
name: string;
|
||||
dni: string;
|
||||
amountBank: number;
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
employee: string;
|
||||
dni: string;
|
||||
amountPayroll: number;
|
||||
amountBank: number;
|
||||
status: 'Coincidencia' | 'Riesgo';
|
||||
observation: string;
|
||||
}
|
||||
|
||||
export interface HistoricalReport {
|
||||
id: string;
|
||||
date: string;
|
||||
period: string;
|
||||
payrollFile: string;
|
||||
bankFiles: string[];
|
||||
discrepancies: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
name: string;
|
||||
size: string;
|
||||
type: 'payroll' | 'bank';
|
||||
rawFile?: File;
|
||||
}
|
||||
|
||||
export type ViewType = 'cruce' | 'historial';
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
base: "/cruce-cuentas/guatemala/",
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user