DataScope26/WEB
0
1import {2 Building2,3 CreditCard,4 AlertTriangle,5 FileText,6} from "lucide-react";7import { useState, useRef, useEffect, useCallback } from "react";8import "./CompanySettings.css";9import { useAuth } from "../context/AuthContext.jsx";10import { useNavigate } from "react-router-dom";11import { useCompanySettings } from "../hooks/useCompanySettings.jsx";12import ButtonAlert from "../components/ButtonAlert.jsx";13import ButtonPrimary from "../components/ButtonPrimary.jsx";14import ButtonSecondary from "../components/ButtonSecondary.jsx";15import AreaManagementCard from "../components/AreaManagementCard.jsx";16 17const CROP_SIZE = 280;18const OUTPUT_SIZE = 500;19 20// Map tiers to user-facing labels and optional classes21const PLAN_META = {22 basic: { label: "Acceso Anticipado", className: "cs-plan--basic" },23 standard: { label: "Pro", className: "cs-plan--pro" },24 advanced: { label: "Enterprise", className: "cs-plan--enterprise" },25};26 27function CardShell({ icon, title, badge, children, className = "" }) {28 return (29 <section className={`cs-card ${className}`}>30 <div className="cs-card-header">31 <div className="cs-card-header-left">32 <span className="cs-card-icon">{icon}</span>33 <h2 className="cs-card-title">{title}</h2>34 </div>35 36 {badge}37 </div>38 39 {children}40 </section>41 );42}43 44function Field({ label, children }) {45 return (46 <label className="cs-field">47 <span className="cs-field-label">{label}</span>48 {children}49 </label>50 );51}52 53export default function CompanySettings() {54 const { user, selectedCompany, setSelectedCompany, updateCompanyInUser, setUser } = useAuth();55 const navigate = useNavigate();56 const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);57 const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);58 const [discardNotice, setDiscardNotice] = useState("");59 const [showLogoModal, setShowLogoModal] = useState(false);60 const [selectedLogoUrl, setSelectedLogoUrl] = useState(null);61 const [logoScale, setLogoScale] = useState(1);62 const [logoMinScale, setLogoMinScale] = useState(1);63 const [logoOffset, setLogoOffset] = useState({ x: 0, y: 0 });64 const [isSavingLogo, setIsSavingLogo] = useState(false);65 66 const { legalName, setLegalName, saving, deleting, uploadingLogo,67 statusMessage, errorMessage, handleDiscard, handleSave, handleDelete,68 handleLogoUpload, } = useCompanySettings(selectedCompany);69 70 const fileInputRef = useRef(null);71 const logoCanvasRef = useRef(null);72 const logoImgRef = useRef(null);73 const isDraggingRef = useRef(false);74 const lastPosRef = useRef({ x: 0, y: 0 });75 76 const companyLogo = selectedCompany?.logo && selectedCompany.logo.trim() ? selectedCompany.logo : "";77 78 const confirmDiscardChanges = () => {79 handleDiscard();80 setDiscardNotice("Cambios descartados.");81 setShowDiscardConfirm(false);82 navigate("/")83 setTimeout(() => setDiscardNotice(""), 3000);84 };85 86 // Protect route on load/reload: if user has no companies, redirect to overview (dashboard shows create prompt).87 // If user's company list no longer contains the currently selected company (deleted), pick the first available.88 useEffect(() => {89 if (!user) return;90 91 const companies = user.companies || [];92 93 if (companies.length === 0) {94 setSelectedCompany(null);95 navigate("/");96 return;97 }98 99 const selectedId = selectedCompany?.id ?? selectedCompany?.companyId;100 const hasSelected = selectedId && companies.some((c) => String(c.id ?? c.companyId) === String(selectedId));101 102 if (!hasSelected) {103 // pick first available company104 setSelectedCompany(companies[0]);105 }106 }, [user, selectedCompany, setSelectedCompany, navigate]);107 108 const openLogoModal = () => {109 setSelectedLogoUrl(null);110 setLogoScale(1);111 setLogoMinScale(1);112 setLogoOffset({ x: 0, y: 0 });113 setShowLogoModal(true);114 };115 116 const closeLogoModal = () => {117 if (isSavingLogo) return;118 119 if (selectedLogoUrl) {120 URL.revokeObjectURL(selectedLogoUrl);121 }122 123 setSelectedLogoUrl(null);124 setShowLogoModal(false);125 };126 127 const handleLogoChosen = (e) => {128 const file = e.target.files?.[0];129 if (!file) return;130 131 if (!file.type.startsWith("image/")) {132 return;133 }134 135 const url = URL.createObjectURL(file);136 137 if (selectedLogoUrl) {138 URL.revokeObjectURL(selectedLogoUrl);139 }140 141 setSelectedLogoUrl(url);142 setLogoScale(1);143 setLogoMinScale(1);144 setLogoOffset({ x: 0, y: 0 });145 e.target.value = "";146 };147 148 const triggerLogoFileSelect = () => {149 fileInputRef.current?.click();150 };151 152 useEffect(() => {153 if (!selectedLogoUrl) {154 logoImgRef.current = null;155 return;156 }157 158 const img = new Image();159 160 img.onload = () => {161 logoImgRef.current = img;162 163 const coverScale = Math.max(164 CROP_SIZE / img.width,165 CROP_SIZE / img.height166 );167 168 setLogoMinScale(coverScale);169 setLogoScale(coverScale);170 171 const drawWidth = img.width * coverScale;172 const drawHeight = img.height * coverScale;173 174 setLogoOffset({175 x: (CROP_SIZE - drawWidth) / 2,176 y: (CROP_SIZE - drawHeight) / 2,177 });178 };179 180 img.src = selectedLogoUrl;181 }, [selectedLogoUrl]);182 183 const clampOffset = useCallback((nextOffset, currentScale) => {184 const img = logoImgRef.current;185 186 if (!img) return nextOffset;187 188 const drawWidth = img.width * currentScale;189 const drawHeight = img.height * currentScale;190 191 const minX = CROP_SIZE - drawWidth;192 const minY = CROP_SIZE - drawHeight;193 194 return {195 x: Math.min(0, Math.max(minX, nextOffset.x)),196 y: Math.min(0, Math.max(minY, nextOffset.y)),197 };198 }, []);199 200 useEffect(() => {201 const canvas = logoCanvasRef.current;202 const img = logoImgRef.current;203 204 if (!canvas || !img || !selectedLogoUrl) return;205 206 const ctx = canvas.getContext("2d");207 ctx.clearRect(0, 0, CROP_SIZE, CROP_SIZE);208 ctx.drawImage(209 img,210 logoOffset.x,211 logoOffset.y,212 img.width * logoScale,213 img.height * logoScale214 );215 }, [logoScale, logoOffset, selectedLogoUrl]);216 217 const handleLogoZoomChange = (e) => {218 const newScale = parseFloat(e.target.value);219 const center = CROP_SIZE / 2;220 221 // Punto de la imagen que actualmente está en el centro del círculo de recorte.222 const imgCenterX = (center - logoOffset.x) / logoScale;223 const imgCenterY = (center - logoOffset.y) / logoScale;224 225 // Recalculamos el offset para que ese mismo punto siga en el centro226 // después de aplicar el nuevo scale (zoom centrado, no desde la esquina).227 const nextOffset = {228 x: center - imgCenterX * newScale,229 y: center - imgCenterY * newScale,230 };231 232 setLogoScale(newScale);233 setLogoOffset(clampOffset(nextOffset, newScale));234 };235 236 const handleLogoPointerDown = (e) => {237 if (!logoImgRef.current) return;238 239 isDraggingRef.current = true;240 const point = e.touches ? e.touches[0] : e;241 242 lastPosRef.current = {243 x: point.clientX,244 y: point.clientY,245 };246 };247 248 const handleLogoPointerMove = (e) => {249 if (!isDraggingRef.current || !logoImgRef.current) return;250 251 const point = e.touches ? e.touches[0] : e;252 const dx = point.clientX - lastPosRef.current.x;253 const dy = point.clientY - lastPosRef.current.y;254 255 lastPosRef.current = {256 x: point.clientX,257 y: point.clientY,258 };259 260 setLogoOffset((prev) =>261 clampOffset(262 {263 x: prev.x + dx,264 y: prev.y + dy,265 },266 logoScale267 )268 );269 };270 271 const handleLogoPointerUp = () => {272 isDraggingRef.current = false;273 };274 275 const handleSaveCroppedLogo = async () => {276 const img = logoImgRef.current;277 if (!img) return;278 279 setIsSavingLogo(true);280 281 try {282 const outputCanvas = document.createElement("canvas");283 outputCanvas.width = OUTPUT_SIZE;284 outputCanvas.height = OUTPUT_SIZE;285 286 const ctx = outputCanvas.getContext("2d");287 ctx.imageSmoothingEnabled = true;288 ctx.imageSmoothingQuality = "high";289 290 const ratio = OUTPUT_SIZE / CROP_SIZE;291 292 ctx.drawImage(293 img,294 logoOffset.x * ratio,295 logoOffset.y * ratio,296 img.width * logoScale * ratio,297 img.height * logoScale * ratio298 );299 300 const webpBlob = await new Promise((resolve, reject) => {301 outputCanvas.toBlob(302 (blob) => {303 if (!blob) {304 reject(new Error("Failed to create image"));305 return;306 }307 resolve(blob);308 },309 "image/webp",310 0.9311 );312 });313 314 const croppedFile = new File([webpBlob], "company-logo.webp", {315 type: "image/webp",316 });317 318 await handleLogoUpload(croppedFile, (updatedCompany) => {319 updateCompanyInUser(updatedCompany);320 setSelectedCompany((currentCompany) => {321 if (!currentCompany) return updatedCompany;322 323 const currentId = currentCompany.id ?? currentCompany.companyId;324 const updatedId = updatedCompany.id ?? updatedCompany.companyId;325 326 return currentId && updatedId && currentId === updatedId327 ? { ...currentCompany, ...updatedCompany }328 : updatedCompany;329 });330 });331 332 if (selectedLogoUrl) {333 URL.revokeObjectURL(selectedLogoUrl);334 }335 336 setSelectedLogoUrl(null);337 setShowLogoModal(false);338 } catch (err) {339 console.error("Error al guardar el logo de empresa:", err);340 } finally {341 setIsSavingLogo(false);342 }343 };344 345 const onLogoFileChange = (e) => {346 const file = e.target.files?.[0];347 if (!file) return;348 handleLogoUpload(file, (updatedCompany) => {349 updateCompanyInUser(updatedCompany);350 setSelectedCompany((currentCompany) => {351 if (!currentCompany) return updatedCompany;352 353 const currentId = currentCompany.id ?? currentCompany.companyId;354 const updatedId = updatedCompany.id ?? updatedCompany.companyId;355 356 return currentId && updatedId && currentId === updatedId357 ? { ...currentCompany, ...updatedCompany }358 : updatedCompany;359 });360 });361 e.target.value = "";362 };363 364 const planMeta =365 PLAN_META[selectedCompany?.tier] || {366 label: "Personalizado",367 className: "",368 };369 370 return (371 <div className="cs-page">372 <div className="cs-container">373 {!selectedCompany ? (374 <div className="cs-no-company">375 <h2>No hay empresa seleccionada</h2>376 377 <p>378 Seleccioná una empresa en{" "}379 <ButtonPrimary380 type="button"381 className="cs-link"382 onClick={() => navigate("/my-companies")}383 >384 Mis Empresas385 </ButtonPrimary>{" "}386 para editar su configuración.387 </p>388 </div>389 ) : (390 <>391 {/* Header */}392 <header className="cs-header">393 <h1 className="cs-title">394 {selectedCompany.name ||395 "Configuración de Empresa"}396 </h1>397 398 <p className="cs-subtitle">399 {selectedCompany.name400 ? `Configura ${selectedCompany.name} — administra identidad, políticas y permisos.`401 : "Administra los parámetros críticos de tu organización."}402 </p>403 </header>404 405 <div className="cs-stack">406 {/* Row 1: Perfil + Plan y Facturación */}407 <div className="cs-row">408 <CardShell409 icon={<Building2 size={17} />}410 title="Perfil de la Empresa"411 className="cs-col-2"412 >413 <div className="cs-profile-top">414 <ButtonPrimary415 type="button"416 className="cs-logo-button"417 onClick={openLogoModal}418 aria-label={`Cambiar logo de ${selectedCompany.name || "la empresa"}`}419 >420 {companyLogo ? (421 <img src={companyLogo} alt={`Logo de ${selectedCompany.name || "la empresa"}`} className="cs-logo-img" />422 ) : (423 <div className="cs-logo-mark" aria-label={`Sin logo — ${selectedCompany.name || "la empresa"}`}>424 <Building2 size={32} strokeWidth={1.5} />425 </div>426 )}427 </ButtonPrimary>428 429 <div className="cs-profile-fields">430 <Field label="Nombre Legal">431 <input432 type="text"433 value={legalName}434 onChange={(e) => setLegalName(e.target.value)}435 className="cs-input"436 />437 </Field>438 </div>439 </div>440 </CardShell>441 442 <CardShell443 icon={<CreditCard size={17} />}444 title="Plan y Facturación"445 badge={446 <span447 className={`cs-badge ${planMeta.className}`}448 >449 {planMeta.label}450 </span>451 }452 className="cs-col-3"453 >454 <div className="cs-billing-top">455 <div>456 <p className="cs-billing-label">457 Próxima Factura458 </p>459 460 <p className="cs-billing-date">461 1 Enero, 2020462 </p>463 </div>464 465 <p className="cs-billing-price">466 {planMeta.label === "Basic"467 ? "$5"468 : planMeta.label === "Pro" ? "$15"469 : planMeta.label === "Enterprise" ? "$50"470 : "$0"}471 <span className="cs-billing-price-unit">472 /mes473 </span>474 </p>475 </div>476 477 <div className="cs-billing-card">478 <div className="cs-billing-card-left">479 <CreditCard480 size={18}481 className="cs-billing-card-icon"482 />483 484 <div>485 <p className="cs-billing-card-title">486 Visa terminada en 1111487 </p>488 489 <p className="cs-billing-card-sub">490 Vence 1/1491 </p>492 </div>493 </div>494 495 <ButtonSecondary496 type="button"497 className="cs-btn-ghost"498 >499 Gestionar500 </ButtonSecondary>501 </div>502 503 </CardShell>504 </div>505 506 {/* Area Management */}507 <AreaManagementCard companyId={selectedCompany?.id || selectedCompany?.companyId} />508 509 {/* Row 2: Zona de Peligro */}510 <div className="cs-row">511 <CardShell512 icon={513 <AlertTriangle514 size={17}515 className="cs-card-icon--danger"516 />517 }518 title="Zona de Peligro"519 className="cs-col-2 cs-card--danger"520 >521 <p className="cs-danger-text">522 Estas acciones son irreversibles. Al523 eliminar tu cuenta, todos los modelos524 entrenados, logs y configuraciones serán525 borrados permanentemente de nuestros526 clústeres.527 </p>528 529 <div className="cs-danger-actions"> 530 <ButtonAlert531 type="button"532 className="cs-btn-danger-solid"533 onClick={() => setShowDeleteConfirm(true)}534 disabled={deleting}535 >536 {deleting ? "Borrando..." : "Borrar Empresa"}537 </ButtonAlert>538 </div>539 </CardShell>540 </div>541 </div>542 543 {/* Footer actions */}544 <div className="cs-footer">545 <ButtonSecondary546 type="button"547 className="cs-btn-ghost"548 onClick={() => setShowDiscardConfirm(true)}549 disabled={saving}550 >551 Descartar552 </ButtonSecondary>553 554 <ButtonPrimary555 type="button"556 className="cs-btn-primary"557 style={{558 padding: "10px 20px",559 }}560 onClick={() =>561 handleSave((updatedCompany) => {562 563 updateCompanyInUser(updatedCompany);564 setSelectedCompany((currentCompany) => {565 if (!currentCompany) return updatedCompany;566 567 const currentId = currentCompany.id ?? currentCompany.companyId;568 const updatedId = updatedCompany.id ?? updatedCompany.companyId;569 570 return currentId && updatedId && currentId === updatedId571 ? { ...currentCompany, ...updatedCompany }572 : updatedCompany;573 });574 575 navigate("/overview");576 577 })578 }579 disabled={saving}580 >581 {saving582 ? "Guardando..."583 : "Guardar Cambios"}584 </ButtonPrimary>585 </div>586 587 {discardNotice && (588 <p className="cs-status-message" role="status">589 {discardNotice}590 </p>591 )}592 593 {statusMessage && (594 <p className="cs-status-message" role="status">595 {statusMessage}596 </p>597 )}598 599 {errorMessage && (600 <p className="cs-error-message" role="alert">601 {errorMessage}602 </p>603 )}604 605 {showDeleteConfirm && (606 <div className="cs-modal-overlay">607 <div className="cs-modal">608 <h3 className="cs-modal-title">609 ¿Estás completamente seguro?610 </h3>611 612 <p className="cs-modal-description">613 Esta acción es irreversible. Se borrarán permanentemente:614 </p>615 616 <ul className="cs-modal-list">617 <li>La empresa y todos sus datos</li>618 <li>Todos los modelos entrenados</li>619 <li>Todos los logs y configuraciones</li>620 </ul>621 622 <div className="cs-modal-actions">623 <ButtonSecondary624 type="button"625 className="cs-btn-ghost"626 onClick={() => setShowDeleteConfirm(false)}627 disabled={deleting}628 >629 Cancelar630 </ButtonSecondary>631 632 <ButtonAlert633 type="button"634 className="cs-btn-danger-solid"635 onClick={() =>636 handleDelete((response) => {637 // Remove deleted company from user.companies and pick another company if available638 const deletedCompanyId = selectedCompany?.id ?? selectedCompany?.companyId;639 640 setUser((currentUser) => {641 if (!currentUser) return currentUser;642 643 const nextCompanies = (currentUser.companies || []).filter((c) => {644 const id = c.id ?? c.companyId;645 return id && String(id) !== String(deletedCompanyId);646 });647 648 return { ...currentUser, companies: nextCompanies };649 });650 651 // Clear current selected company — AuthContext will pick a new one if any remain652 setSelectedCompany(null);653 654 setShowDeleteConfirm(false);655 navigate("/");656 })657 }658 disabled={deleting}659 >660 {deleting ? "Borrando..." : "Sí, Borrar Permanentemente"}661 </ButtonAlert>662 </div>663 </div>664 </div>665 )}666 667 {showDiscardConfirm && (668 <div className="cs-modal-overlay">669 <div className="cs-modal">670 <h3 className="cs-modal-title">Descartar cambios</h3>671 672 <p className="cs-modal-description">673 Si continúas, se perderán los cambios hechos en la configuración de la empresa.674 </p>675 676 <div className="cs-modal-actions">677 <ButtonSecondary678 type="button"679 className="cs-btn-ghost"680 onClick={() => setShowDiscardConfirm(false)}681 >682 Cancelar683 </ButtonSecondary>684 685 <ButtonAlert686 type="button"687 className="cs-btn-danger-solid"688 onClick={confirmDiscardChanges}689 >690 Sí, descartar691 </ButtonAlert>692 </div>693 </div>694 </div>695 )}696 697 {showLogoModal && (698 <div className="cs-logo-modal-overlay" onClick={closeLogoModal}>699 <div className="cs-logo-modal-content" onClick={(e) => e.stopPropagation()}>700 <h3 className="cs-logo-modal-title">Logo de la empresa</h3>701 702 {!selectedLogoUrl && (703 <>704 <div className="cs-logo-current-frame">705 {companyLogo ? (706 <img className="cs-logo-current-image" src={companyLogo} alt="Logo actual de la empresa" />707 ) : (708 <div className="cs-logo-current-placeholder">709 <Building2 size={96} strokeWidth={1.5} />710</div>711 )}712 </div>713 <ButtonPrimary type="button" className="btn-change-photo" onClick={triggerLogoFileSelect}>714 Cambiar logo715 </ButtonPrimary>716 </>717 )}718 719 {selectedLogoUrl && (720 <>721 <div722 className="cs-logo-crop-container"723 onMouseDown={handleLogoPointerDown}724 onMouseMove={handleLogoPointerMove}725 onMouseUp={handleLogoPointerUp}726 onMouseLeave={handleLogoPointerUp}727 onTouchStart={handleLogoPointerDown}728 onTouchMove={handleLogoPointerMove}729 onTouchEnd={handleLogoPointerUp}730 >731 <canvas ref={logoCanvasRef} width={CROP_SIZE} height={CROP_SIZE} />732 <div className="cs-logo-crop-grid" />733 </div>734 735 <div className="cs-logo-zoom-controls">736 <span className="cs-logo-zoom-label">-</span>737 <input738 className="cs-logo-zoom-slider"739 type="range"740 min={logoMinScale}741 max={logoMinScale * 4}742 step={0.01}743 value={logoScale}744 onChange={handleLogoZoomChange}745 />746 <span className="cs-logo-zoom-label">+</span>747 </div>748 749 <ButtonPrimary type="button" className="btn-change-photo" onClick={triggerLogoFileSelect}>750 Elegir otro logo751 </ButtonPrimary>752 </>753 )}754 755 <input756 ref={fileInputRef}757 type="file"758 accept="image/*"759 onChange={handleLogoChosen}760 style={{ display: "none" }}761 />762 763 <div className="cs-logo-modal-actions">764 <ButtonPrimary765 type="button"766 className="btn-save"767 disabled={!selectedLogoUrl || isSavingLogo || uploadingLogo}768 onClick={handleSaveCroppedLogo}769 >770 {isSavingLogo || uploadingLogo ? "Guardando..." : "Guardar logo"}771 </ButtonPrimary>772 <ButtonAlert773 type="button"774 className="btn-logout"775 onClick={closeLogoModal}776 disabled={isSavingLogo || uploadingLogo}777 >778 Cancelar779 </ButtonAlert>780 </div>781 </div>782 </div>783 )}784 </>785 )}786 </div>787 </div>788 );789}