diff --git a/.github/CRMQuejas b/.github/CRMQuejas
new file mode 100644
index 000000000000..ccc87abfd36b
--- /dev/null
+++ b/.github/CRMQuejas
@@ -0,0 +1,660 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { Search, Plus, Download, Upload, Trash2, Pencil, Filter, RefreshCw, X, FileSpreadsheet, CheckCircle2, AlertTriangle, LockKeyhole, ShieldCheck, LogOut, KeyRound } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
+import { toast } from "sonner";
+import * as XLSX from "xlsx";
+import saveAs from "file-saver"; // import default para compatibilidad +esm
+import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
+
+/**
+ * CRM – Quejas Redes (Roles: MASTER y AGENTE)
+ * - Importa XLSX/CSV (hoja "Data").
+ * - Filtros, métricas, gráfica y tabla.
+ * - CRUD para MASTER; AGENTE solo cambia "Estatus" (auto fecha de solución).
+ * - Persistencia local.
+ */
+
+const STORAGE_KEY = "crm_quejas_redes_v3";
+const PINS_KEY = "crm_quejas_redes_pins_v1";
+const ROLE_MASTER = "MASTER";
+const ROLE_AGENT = "AGENTE";
+const RUN_TESTS = true;
+
+type CatalogOptions = Record; // Catálogos (desde hoja NOM)
+const ALL = "__ALL__"; // valor centinela
+
+const DEFAULT_COLUMNS = [
+ "Fecha de contacto",
+ "Dias transcurridos",
+ "Canal de Contacto",
+ "Número de Pedido",
+ "Fecha de liquidación",
+ "Fecha de Entrega",
+ "Motivo de Queja",
+ "Departamento",
+ "Sucursal",
+ "Ejecutivo",
+ "Encargado",
+ "Procedente / Improcedente",
+ "Observaciones",
+ "Estatus",
+ "Fecha Canalizo",
+ "Fecha de solucion",
+ "Comentarios-Escritorio",
+ "Proveedor",
+];
+
+function parseDate(value: any) {
+ if (!value) return null;
+ if (value instanceof Date) return value;
+ const d = new Date(value);
+ return isNaN(d.getTime()) ? null : d;
+}
+function daysBetween(a: any, b: any) {
+ const da = parseDate(a);
+ const db = parseDate(b);
+ if (!da || !db) return null;
+ return Math.round((db.getTime() - da.getTime()) / (1000 * 60 * 60 * 24));
+}
+
+// === Fechas Excel → dd/mm/aaaa ===
+function excelSerialToDateString(n: number) {
+ const base = new Date(Date.UTC(1899, 11, 30));
+ const d = new Date(base.getTime() + n * 24 * 60 * 60 * 1000);
+ const dd = String(d.getUTCDate()).padStart(2, "0");
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const yy = d.getUTCFullYear();
+ return `${dd}/${mm}/${yy}`;
+}
+function looksLikeExcelDate(val: any) {
+ return typeof val === "number" && val > 20000 && val < 60000;
+}
+function isDateColumn(name: string) {
+ return /^fecha/i.test(name.trim());
+}
+
+function csvFromArray(objects: any[], columns: string[]) {
+ const header = columns.join(",");
+ const rows = objects.map((obj) =>
+ columns
+ .map((c) => {
+ const v = obj?.[c] ?? "";
+ const s = typeof v === "string" ? v.replaceAll('"', '""') : v;
+ return `"${s ?? ""}"`;
+ })
+ .join(",")
+ );
+ return [header, ...rows].join("\n");
+}
+
+function toXlsxBlob(data: any[], columns: string[], sheetName = "Data") {
+ const ws = XLSX.utils.json_to_sheet(data, { header: columns });
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, sheetName);
+ const wbout = XLSX.write(wb, { bookType: "xlsx", type: "array" });
+ return new Blob([wbout], { type: "application/octet-stream" });
+}
+
+// Construye catálogos desde hoja 2 (NOM)
+function catalogsFromSheet2(sheet: XLSX.WorkSheet | undefined): CatalogOptions {
+ if (!sheet) return {};
+ const raw = XLSX.utils.sheet_to_json(sheet, { defval: "" });
+ const cols = new Map>();
+ raw.forEach((row) => {
+ Object.keys(row).forEach((k) => {
+ const val = String(row[k] ?? "").trim();
+ if (!val) return;
+ if (!cols.has(k)) cols.set(k, new Set());
+ cols.get(k)!.add(val);
+ });
+ });
+ const out: CatalogOptions = {};
+ cols.forEach((set, key) => (out[key] = Array.from(set.values())));
+ return out;
+}
+
+// Mapea encabezados de NOM (flexible) -> columnas esperadas por la app
+function mapNomToTargets(source: CatalogOptions): CatalogOptions {
+ const norm = (s: string) =>
+ s.toLowerCase()
+ .normalize("NFD").replace(/[̀-ͯ]/g, "")
+ .replace(/[^a-z0-9]+/g, " ")
+ .trim();
+
+ const targets = [
+ "Sucursal",
+ "Departamento",
+ "Encargado",
+ "Motivo de Queja",
+ "Estatus",
+ "Procedente / Improcedente",
+ "Canal de Contacto",
+ "Proveedor",
+ ] as const;
+
+ const tests: Record boolean)[]> = {
+ Sucursal: [(h) => norm(h).includes("sucursal")],
+ Departamento: [(h) => norm(h).includes("area") || norm(h).includes("departamento")],
+ Encargado: [(h) => norm(h).includes("encargado")],
+ "Motivo de Queja": [(h) => norm(h).includes("motivo")],
+ Estatus: [(h) => norm(h).includes("estatus") || norm(h).includes("status")],
+ "Procedente / Improcedente": [(h) => norm(h).includes("procedente") || norm(h).includes("improcedente")],
+ "Canal de Contacto": [(h) => norm(h).includes("canal")],
+ Proveedor: [(h) => norm(h).includes("proveedor")],
+ };
+
+ const result: CatalogOptions = {};
+ targets.forEach((t) => (result[t] = []));
+
+ Object.entries(source).forEach(([header, values]) => {
+ const matches = Object.entries(tests).filter(([, fns]) => fns.some((fn) => fn(header)));
+ if (!matches.length) return;
+ matches.forEach(([target]) => {
+ const list = values.filter((v) => String(v).trim() !== "");
+ result[target] = Array.from(new Set([...(result[target] || []), ...list]));
+ });
+ });
+
+ Object.keys(result).forEach((k) => {
+ if (!result[k]?.length) { delete result[k]; return; }
+ result[k] = Array.from(new Set(result[k]!)).sort((a, b) => a.localeCompare(b as string, "es"));
+ });
+
+ return result;
+}
+
+function useLocalStorageState(key: string, initialValue: T): [T, React.Dispatch>] {
+ const [state, setState] = useState(() => {
+ try {
+ const raw = localStorage.getItem(key);
+ return raw ? (JSON.parse(raw) as T) : initialValue;
+ } catch {
+ return initialValue;
+ }
+ });
+ useEffect(() => {
+ try { localStorage.setItem(key, JSON.stringify(state)); } catch {}
+ }, [key, state]);
+ return [state, setState];
+}
+
+function uuid() {
+ try { return crypto.randomUUID(); } catch { return `${Date.now()}_${Math.floor(Math.random() * 1e6)}`; }
+}
+
+function LogoDEurope() {
+ return (
+
+
+
D’Europe Muebles – CRM Quejas
+
+ );
+}
+
+function RoleBadge({ role, onLogout, onOpenPins }: { role: string | null; onLogout: () => void; onOpenPins: () => void }) {
+ const isMaster = role === ROLE_MASTER;
+ return (
+
+
+
+
+
+ );
+}
+
+function LoginDialog({ open, onOpenChange, onLogin }: { open: boolean; onOpenChange: (v: boolean) => void; onLogin: (role: string, pin: string) => void }) {
+ const [role, setRole] = useState(ROLE_AGENT);
+ const [pin, setPin] = useState("");
+ return (
+
+ );
+}
+
+function PinsDialog({ open, onOpenChange, pins, setPins }: { open: boolean; onOpenChange: (v: boolean) => void; pins: any; setPins: (v: any) => void }) {
+ const [form, setForm] = useState(pins);
+ useEffect(() => setForm(pins), [pins]);
+ return (
+
+ );
+}
+
+function Filters({
+ filters, setFilters, statuses, procedencias, departamentos,
+}: { filters: any; setFilters: (v: any) => void; statuses: string[]; procedencias: string[]; departamentos: string[]; }) {
+ return (
+
+
+
+ setFilters((f: any) => ({ ...f, q: e.target.value }))} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function EditDialog({ open, onOpenChange, columns, initial, onSave, catalogs }: { open: boolean; onOpenChange: (v: boolean) => void; columns: string[]; initial: any; onSave: (v: any) => void; catalogs: CatalogOptions }) {
+ const [form, setForm] = useState(initial || {});
+ useEffect(() => setForm(initial || {}), [initial]);
+ return (
+
+ );
+}
+
+function StatusDialog({ open, onOpenChange, row, onSaveStatus, allowedStatuses }: { open: boolean; onOpenChange: (v: boolean) => void; row: any; onSaveStatus: (v: { status: string; coment: string }) => void; allowedStatuses: string[] }) {
+ const [status, setStatus] = useState("");
+ const [coment, setComent] = useState("");
+ useEffect(() => { setStatus(row?.["Estatus"] ?? ""); setComent(""); }, [row]);
+ return (
+
+ );
+}
+
+export default function App() {
+ const [columns, setColumns] = useState(DEFAULT_COLUMNS);
+ const [rows, setRows] = useLocalStorageState(STORAGE_KEY, []);
+ const [catalogs, setCatalogs] = useState({});
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(10);
+ const [filters, setFilters] = useState({ q: "", status: ALL, proc: ALL, depto: ALL });
+ const [editOpen, setEditOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+
+ const [role, setRole] = useState(null);
+ const [loginOpen, setLoginOpen] = useState(true);
+ const [pins, setPins] = useLocalStorageState(PINS_KEY, { master: "2468", agent: "1357" });
+ const [pinsOpen, setPinsOpen] = useState(false);
+
+ const isMaster = role === ROLE_MASTER;
+
+ function handleLogin(selRole: string, pin: string) {
+ const ok = selRole === ROLE_MASTER ? pin === pins.master : pin === pins.agent;
+ if (!ok) { toast.error("PIN incorrecto"); return; }
+ setRole(selRole); setLoginOpen(false); toast.success(`Sesión iniciada como ${selRole}`);
+ }
+ function handleLogout() { setRole(null); setLoginOpen(true); }
+
+ const statuses = useMemo(() => Array.from(new Set(rows.map((r) => (r["Estatus"] ?? "").toString().trim()).filter(Boolean))).sort(), [rows]);
+ const procedencias = useMemo(() => Array.from(new Set(rows.map((r) => (r["Procedente / Improcedente"] ?? "").toString().trim()).filter(Boolean))).sort(), [rows]);
+ const departamentos = useMemo(() => Array.from(new Set(rows.map((r) => (r["Departamento"] ?? "").toString().trim()).filter(Boolean))).sort(), [rows]);
+
+ const filtered = useMemo(() => {
+ const q = filters.q.toLowerCase();
+ return rows.filter((r) => {
+ if (filters.status !== ALL && (r["Estatus"] ?? "") !== filters.status) return false;
+ if (filters.proc !== ALL && (r["Procedente / Improcedente"] ?? "") !== filters.proc) return false;
+ if (filters.depto !== ALL && (r["Departamento"] ?? "") !== filters.depto) return false;
+ if (!q) return true;
+ const hay = [r["Número de Pedido"], r["Motivo de Queja"], r["Sucursal"], r["Comentarios-Escritorio"], r["Observaciones"], r["Ejecutivo"], r["Encargado"], r["Proveedor"], r["Canal de Contacto"]].map((v) => (v ?? "").toString().toLowerCase());
+ return hay.some((s) => s.includes(q));
+ });
+ }, [rows, filters]);
+
+ const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
+ const pageData = useMemo(() => filtered.slice((page - 1) * pageSize, page * pageSize), [filtered, page, pageSize]);
+ useEffect(() => { if (page > totalPages) setPage(1); }, [totalPages, page]);
+
+ const metrics = useMemo(() => {
+ const total = filtered.length;
+ const proc = filtered.filter((r) => (r["Procedente / Improcedente"] ?? "").toString().toLowerCase().includes("procedente")).length;
+ const improc = filtered.filter((r) => (r["Procedente / Improcedente"] ?? "").toString().toLowerCase().includes("improcedente")).length;
+ const cerrados = filtered.filter((r) => (r["Estatus"] ?? "").toString().toLowerCase().includes("cerr") || (r["Estatus"] ?? "").toString().toLowerCase().includes("resuelto")).length;
+ const abiertos = total - cerrados;
+ const diffs = filtered.map((r) => daysBetween(r["Fecha de contacto"], r["Fecha de solucion"])) as (number | null)[];
+ const nums = diffs.filter((v): v is number => typeof v === "number");
+ const tprom = nums.length ? Math.round(nums.reduce((a, b) => a + b, 0) / nums.length) : null;
+ return { total, proc, improc, abiertos, cerrados, tprom };
+ }, [filtered]);
+
+ const chartData = useMemo(() => {
+ const m = new Map();
+ filtered.forEach((r) => { const key = r["Departamento"] || "(Sin depto)"; m.set(key, (m.get(key) || 0) + 1); });
+ return Array.from(m.entries()).map(([name, value]) => ({ name, value }));
+ }, [filtered]);
+
+ function importExcel(file: File) {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const data = new Uint8Array((e.target as any).result as ArrayBuffer);
+ const wb = XLSX.read(data, { type: "array" });
+ const dataSheet = wb.Sheets["Data"] || wb.Sheets[wb.SheetNames[0]];
+ const sheet2 = wb.Sheets["NOM"] || wb.Sheets[wb.SheetNames[1]]; // catálogos
+ const json: any[] = XLSX.utils.sheet_to_json(dataSheet, { defval: "" });
+ const cols = DEFAULT_COLUMNS.filter((c) => json.some((r) => Object.prototype.hasOwnProperty.call(r, c)));
+ const finalCols = cols.length ? cols : Array.from(new Set(json.flatMap((r) => Object.keys(r))));
+ const withIds = json.map((r) => {
+ const fixed: any = { ...r };
+ Object.keys(fixed).forEach((k) => { if (isDateColumn(k) && looksLikeExcelDate(fixed[k])) fixed[k] = excelSerialToDateString(fixed[k]); });
+ return { __id: uuid(), ...fixed };
+ });
+ setColumns(finalCols); setRows(withIds); setPage(1);
+ const cats = mapNomToTargets(catalogsFromSheet2(sheet2)); setCatalogs(cats);
+ toast.success(`Importados ${withIds.length} registros${Object.keys(cats).length ? " + catálogos" : ""}`);
+ } catch (err) {
+ console.error(err); toast.error("No se pudo leer el archivo. Sube un XLSX/CSV válido.");
+ }
+ };
+ reader.readAsArrayBuffer(file);
+ }
+
+ function exportCSV() { const csv = csvFromArray(filtered, columns); const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); saveAs(blob, `CRM_Quejas_Export_${new Date().toISOString().slice(0, 10)}.csv`); }
+ function exportXLSX() { const blob = toXlsxBlob(filtered, columns, "Data"); saveAs(blob, `CRM_Quejas_Export_${new Date().toISOString().slice(0, 10)}.xlsx`); }
+
+ function onSaveRecord(form: any) {
+ const clean: any = { __id: form.__id || uuid() };
+ columns.forEach((c) => { if (form[c] !== undefined && String(form[c]).trim() !== "") clean[c] = form[c]; });
+ if (!form.__id) { setRows((prev) => [clean, ...prev]); toast.success("Registro agregado"); }
+ else { setRows((prev) => prev.map((r) => (r.__id === form.__id ? { ...r, ...clean } : r))); toast.success("Registro actualizado"); }
+ // mantener catálogos actualizados
+ setCatalogs((prev) => {
+ const next = { ...prev } as CatalogOptions;
+ Object.keys(clean).forEach((k) => { if (!next[k]) return; const val = String(clean[k]); if (val && !next[k].includes(val)) next[k] = [...next[k], val]; });
+ return next;
+ });
+ setEditOpen(false);
+ }
+
+ function onDelete(id: string) { setRows((prev) => prev.filter((r) => r.__id !== id)); toast.message("Registro eliminado", { description: id }); }
+ function clearAll() { setRows([]); toast("Base vaciada (solo local)"); }
+
+ const [statusOpen, setStatusOpen] = useState(false);
+ const [statusRow, setStatusRow] = useState(null);
+ const baseAllowed = useMemo(() => { const pre = new Set(["Resuelto", "No resuelto", "Cerrado", "Abierto", "En proceso"]); statuses.forEach((s) => pre.add(s)); return Array.from(pre); }, [statuses]);
+
+ function saveStatus({ status, coment }: { status: string; coment: string }) {
+ if (!statusRow) return;
+ const cerr = (status || "").toLowerCase();
+ const resolved = cerr.includes("cerr") || cerr.includes("resuelto");
+ const hoy = new Date();
+ setRows((prev) => prev.map((r) => {
+ if (r.__id !== statusRow.__id) return r;
+ const out: any = { ...r, Estatus: status };
+ if (resolved) out["Fecha de solucion"] = hoy.toISOString().slice(0, 10);
+ if (coment) {
+ const prevTxt = (r["Comentarios-Escritorio"] || "").toString();
+ const sep = prevTxt ? " \n" : "";
+ out["Comentarios-Escritorio"] = `${prevTxt}${sep}[${hoy.toISOString().slice(0, 16).replace("T", " ")}] ${coment}`;
+ }
+ return out;
+ }));
+ setStatusOpen(false); toast.success("Estatus actualizado");
+ }
+
+ // Pruebas ligeras
+ useEffect(() => {
+ if (!RUN_TESTS) return;
+ try {
+ const cols = ["A", "B"]; const data = [{ A: 1, B: "x" }, { A: 2, B: "y" }];
+ const csv = csvFromArray(data, cols);
+ console.assert(csv.split("\n").length === 3, "csvFromArray debe tener 3 líneas");
+ console.assert(csv.startsWith("A,B\n"), "Header incorrecto");
+ console.assert(daysBetween("2024-01-01", "2024-01-11") === 10, "daysBetween incorrecto");
+ const csv2 = csvFromArray([{ A: 'He said "hola"', B: 'a,b' } as any], cols);
+ console.assert(csv2.includes('\"He said \"\"hola\"\"\"'), "escape comillas CSV");
+ console.assert(ALL !== "", "ALL no debe ser vacío");
+ console.assert(excelSerialToDateString(45926) === "13/10/2025", "Excel serial → fecha");
+ } catch (e) { console.warn("Pruebas fallaron", e); }
+ }, []);
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ {isMaster && (
+
+ )}
+
+
+ {isMaster && (
+
+ )}
+ {role ?
setPinsOpen(true)} /> : null}
+
+
+
+ {/* Dashboard */}
+
+
Total registros{metrics.total}
+
Procedentes{metrics.proc}
+
Improcedentes{metrics.improc}
+
Abiertos{metrics.abiertos}
+
T. prom. resolución (días){metrics.tprom ?? "—"}
+
+
+ {/* Filtros */}
+
+
+ Filtros
+
+
+
+
+
+
+
+ {/* Chart */}
+
+ Registros por Departamento
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Tabla */}
+
+
+ Registros
+
+
+ {isMaster && ()}
+
+
+
+
+
+
+
+ | Acciones |
+ {columns.map((c) => ({c} | ))}
+
+
+
+ {pageData.map((r) => (
+
+
+
+ {isMaster ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ |
+ {columns.map((c) => {
+ const raw = r[c];
+ const display = isDateColumn(c) && looksLikeExcelDate(raw) ? excelSerialToDateString(raw) : String(raw ?? "");
+ return ({display} | );
+ })}
+
+ ))}
+ {pageData.length === 0 && (
+
+ | Sin datos. {isMaster ? "Importa un Excel o agrega un nuevo registro." : "Pide a un MASTER que cargue datos."} |
+
+ )}
+
+
+
+
+
Mostrando {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, filtered.length)} de {filtered.length}
+
+
+ { e.preventDefault(); setPage((p) => Math.max(1, p - 1)); }} />
+ {Array.from({ length: totalPages }).slice(0, 5).map((_, i) => {
+ const num = i + 1;
+ return ( { e.preventDefault(); setPage(num); }}>{num});
+ })}
+ { e.preventDefault(); setPage((p) => Math.min(totalPages, p + 1)); }} />
+
+
+
+
+
+
+ {/* Modales */}
+
+
+
+
+
+
Cambios guardados en tu navegador. Usa Exportar para compartir.
+
+
+ );
+}