diff --git a/.env.example b/.env.example index e605f8c..7d89ed5 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ AUTH_RESET_URL=http://localhost:3000/reset-password # SMTP_FROM= # SMTP_USER= # SMTP_PASSWORD= +# Production alternative: Resend HTTPS email delivery. +# RESEND_API_KEY=re_... +# AUTH_EMAIL_FROM=ClientFlow # Admin is disabled by default; local development only. ENABLE_DEV_ADMIN=0 # Set false to connect the authentication UI to the backend. diff --git a/README.es.md b/README.es.md index da651a0..f09f648 100644 --- a/README.es.md +++ b/README.es.md @@ -126,6 +126,8 @@ Configura estos valores: | `VITE_BACKEND_URL` | Dirección base de la API, sin `/api` ni `/api/login`. | | `FRONTEND_ORIGIN` | Origen exacto del frontend autorizado por el backend. | | `AUTH_RESET_URL` | Dirección de la página de recuperación de contraseña. | +| `RESEND_API_KEY` | Clave privada de Resend para enviar la recuperación de contraseña en producción. | +| `AUTH_EMAIL_FROM` | Remitente verificado, por ejemplo `ClientFlow `. | | `ENABLE_DEV_ADMIN` | Mantén `0` salvo que habilites expresamente el administrador local de desarrollo. | Genera un secreto JWT localmente: diff --git a/README.md b/README.md index dfd0756..616893f 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ Configure these values: | `VITE_BACKEND_URL` | API server base address, without `/api` or `/api/login`. | | `FRONTEND_ORIGIN` | Exact frontend origin allowed by the backend. | | `AUTH_RESET_URL` | Frontend password-reset page address. | +| `RESEND_API_KEY` | Private Resend key used to deliver password-reset email in production. | +| `AUTH_EMAIL_FROM` | Verified sender, for example `ClientFlow `. | | `ENABLE_DEV_ADMIN` | Keep `0` unless explicitly enabling the local development admin. | Generate a JWT signing secret locally: diff --git a/render.yaml b/render.yaml index 4f4db3a..920e77f 100644 --- a/render.yaml +++ b/render.yaml @@ -38,6 +38,10 @@ services: value: https://clientflow-staging.onrender.com - key: AUTH_RESET_URL value: https://clientflow-staging.onrender.com/reset-password + - key: RESEND_API_KEY + sync: false + - key: AUTH_EMAIL_FROM + sync: false - key: JWT_SECRET_KEY generateValue: true - key: PLAN_SEED_KEY diff --git a/src/api/ai_fallback.py b/src/api/ai_fallback.py new file mode 100644 index 0000000..3164285 --- /dev/null +++ b/src/api/ai_fallback.py @@ -0,0 +1,43 @@ +"""Safe conversational clarifications when the private AI service is unavailable.""" + +import re +import unicodedata + + +def _normalized(value): + text = unicodedata.normalize("NFKD", (value or "").casefold()) + return "".join(char for char in text if not unicodedata.combining(char)) + + +def conversational_fallback(question): + """Ask one useful question without making unsupported company claims.""" + text = _normalized(question) + portuguese = bool(re.search(r"\b(ola|bom dia|boa tarde|boa noite|quero|preciso|consertar)\b", text)) + english = bool(re.search(r"\b(hello|hi|good morning|good afternoon|want|need|repair|install)\b", text)) + service_intent = bool(re.search( + r"\b(cambiar|instalar|comprar|arreglar|reparar|hacer|quiero|necesito|" + r"mudar|instalar|comprar|consertar|reparar|quero|preciso|" + r"change|install|buy|repair|fix|want|need)\b", + text, + )) + + if portuguese: + reply = ("Claro. Para começar, pode me dizer o que deseja fazer e em que localidade será o serviço?" + if service_intent else + "Olá! Como posso ajudar com o seu projeto hoje?") + elif english: + reply = ("Of course. To get started, what would you like done and where will the work take place?" + if service_intent else + "Hello! How can I help with your project today?") + else: + reply = ("Claro. Para empezar, ¿qué quieres hacer y en qué localidad se realizaría el trabajo?" + if service_intent else + "¡Hola! ¿En qué podemos ayudarte con tu proyecto?") + + return { + "status": "pending_review", + "reply": reply, + "sources": [], + "requires_approval": True, + "reason": "local_clarification_fallback", + } diff --git a/src/api/ai_orchestration.py b/src/api/ai_orchestration.py index d00a7c6..fa48a70 100644 --- a/src/api/ai_orchestration.py +++ b/src/api/ai_orchestration.py @@ -3,6 +3,7 @@ import os from api.ai_context import build_conversation_context +from api.ai_fallback import conversational_fallback from api.ai_prompt import build_agent_message from api.ai_response import validate_agent_reply from api.ai_service import AgentServiceError, request_agent_reply @@ -14,7 +15,7 @@ def generate_reply_draft(company_id, conversation_id, question): try: context = build_conversation_context(company_id, conversation_id, question) except EmbeddingServiceError: - return handoff_result("knowledge_service_unavailable") + return conversational_fallback(question) if context.get("opening_reply"): return { @@ -49,7 +50,7 @@ def generate_reply_draft(company_id, conversation_id, question): try: raw_reply = request_agent_reply(prepared["message"], company_id=company_id) except AgentServiceError: - return handoff_result("agent_service_unavailable") + return conversational_fallback(question) try: answer = validate_agent_reply( diff --git a/src/api/auth.py b/src/api/auth.py index 2de88bb..6a4f790 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -1,5 +1,6 @@ """Authentication and tenant context shared by API modules (ticket #22).""" import hashlib +import json import os import re import secrets @@ -11,6 +12,7 @@ from functools import wraps from pathlib import Path from urllib.parse import urlencode +from urllib.request import Request, urlopen import click from flask import Blueprint, current_app, g, jsonify, request @@ -289,6 +291,7 @@ def me(): "name": membership.company.name, "membership_id": membership.id, "role": membership.role.value, + "primary_colour": membership.company.primary_colour, } for membership in memberships ], @@ -314,6 +317,7 @@ def logout(): def delivery_available(): return (current_app.config.get('AUTH_RESET_SENDER') is not None or (current_app.debug and current_app.config.get('AUTH_RESET_OUTBOX')) + or (os.getenv('RESEND_API_KEY') and os.getenv('AUTH_EMAIL_FROM')) or (os.getenv('SMTP_HOST') and os.getenv('SMTP_FROM'))) @@ -331,6 +335,27 @@ def deliver_reset(email, token): with os.fdopen(fd, 'w') as out: out.write(f'To: {email}\n\n{link}\n') return + if os.getenv('RESEND_API_KEY') and os.getenv('AUTH_EMAIL_FROM'): + message = json.dumps({ + 'from': os.environ['AUTH_EMAIL_FROM'], + 'to': [email], + 'subject': 'Restablecer contraseña — ClientFlow', + 'text': f'Abre este enlace para cambiar tu contraseña (válido 30 minutos):\n{link}', + }).encode('utf-8') + api_request = Request( + 'https://api.resend.com/emails', + data=message, + headers={ + 'Authorization': f"Bearer {os.environ['RESEND_API_KEY']}", + 'Content-Type': 'application/json', + 'User-Agent': 'ClientFlow/1.0', + }, + method='POST', + ) + with urlopen(api_request, timeout=10) as response: + if response.status not in {200, 201, 202}: + raise RuntimeError('Password reset provider rejected the message.') + return message = EmailMessage() message['From'] = os.environ['SMTP_FROM'] message['To'] = email diff --git a/src/api/members.py b/src/api/members.py index 87e5ce3..2f0a13c 100644 --- a/src/api/members.py +++ b/src/api/members.py @@ -169,11 +169,6 @@ def build_invitation(data): @members.route("/members/invitations", methods=["POST"]) @team_admin_required def create_invitation(): - if not current_app.debug: - return jsonify( - message="Invitation delivery is not configured." - ), 503 - data = request.get_json(silent=True) if not isinstance(data, dict): @@ -185,42 +180,50 @@ def create_invitation(): db.session.rollback() return jsonify(message=str(error)), 400 - outbox = Path(current_app.config.get("MEMBER_INVITE_OUTBOX") or - Path(current_app.root_path).parent / ".local" / "invite-outbox") - file_path = outbox / f"{invitation.token_hash}.json" + invitation_url = ( + os.getenv("FRONTEND_ORIGIN", "http://localhost:3000").rstrip("/") + + "/accept-invitation#" + urlencode({"token": raw_token}) + ) + file_path = None try: - outbox.mkdir(parents=True, exist_ok=True, mode=0o700) - - with os.fdopen(os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w", encoding="utf-8") as file: - json.dump( - { - "to": invitation.email, - "subject": "Invitación a ClientFlow", - "token": raw_token, - "url": (os.getenv("FRONTEND_ORIGIN", "http://localhost:3000").rstrip("/") - + "/accept-invitation#" + urlencode({"token": raw_token})), - "expires_at": invitation.expires_at.isoformat(), - }, - file, - ensure_ascii=False, - indent=2, - ) + if current_app.debug: + outbox = Path(current_app.config.get("MEMBER_INVITE_OUTBOX") or + Path(current_app.root_path).parent / ".local" / "invite-outbox") + outbox.mkdir(parents=True, exist_ok=True, mode=0o700) + file_path = outbox / f"{invitation.token_hash}.json" + with os.fdopen(os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w", encoding="utf-8") as file: + json.dump( + { + "to": invitation.email, + "subject": "Invitación a ClientFlow", + "token": raw_token, + "url": invitation_url, + "expires_at": invitation.expires_at.isoformat(), + }, + file, + ensure_ascii=False, + indent=2, + ) db.session.commit() except (OSError, SQLAlchemyError): db.session.rollback() try: - file_path.unlink(missing_ok=True) + if file_path is not None: + file_path.unlink(missing_ok=True) except OSError: pass return jsonify(message="Unable to create the invitation."), 503 return jsonify( - message="Invitation saved to the local outbox.", + message=("Invitation saved to the local outbox." + if current_app.debug else "Invitation created. Share the secure link with the invited member."), invitation_id=invitation.id, + invitation_url=invitation_url, + delivery="local_outbox" if current_app.debug else "manual", expires_at=invitation.expires_at.isoformat(), ), 201 diff --git a/src/api/routes.py b/src/api/routes.py index a88ecfd..45a59aa 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -2320,6 +2320,53 @@ def update_job(job_id): return jsonify({"error": "Unable to update job"}), 503 +@api.route('/jobs//stages', methods=['POST']) +@tenant_required +def create_job_stage(job_id): + job = db.session.scalar( + select(Job).where(Job.id == job_id, Job.company_id == g.company_id) + ) + if job is None: + return jsonify({"error": "Job not found"}), 404 + + body = request.get_json(silent=True) + if not isinstance(body, dict): + return jsonify({"error": "Expected a JSON object"}), 400 + + title = body.get('title') + if not isinstance(title, str) or not title.strip() or len(title.strip()) > 160: + return jsonify({"error": "Invalid stage title"}), 400 + + description = body.get('description') + if description is not None and (not isinstance(description, str) or len(description) > 4000): + return jsonify({"error": "Invalid stage description"}), 400 + + due_at = None + if body.get('due_at') not in (None, ''): + try: + due_at = _job_schedule_value(body['due_at']) + except (TypeError, ValueError): + return jsonify({"error": "Invalid stage due date"}), 400 + + next_position = (db.session.scalar( + select(func.max(JobStage.position)).where(JobStage.job_id == job_id) + ) or 0) + 1 + stage = JobStage( + job_id=job_id, + title=title.strip(), + description=description.strip() if isinstance(description, str) and description.strip() else None, + position=next_position, + status=JobStageStatus.PENDING, + due_at=due_at, + ) + db.session.add(stage) + db.session.commit() + updated_job = db.session.execute( + _job_select().where(Job.id == job_id, Job.company_id == g.company_id) + ).one() + return jsonify(_job_to_dict(updated_job, include_details=True)), 201 + + @api.route('/jobs/', methods=['DELETE']) @tenant_required def delete_job(job_id): diff --git a/src/front/components/Dashboard/InteractiveCharts.jsx b/src/front/components/Dashboard/InteractiveCharts.jsx index 96c3bbe..f8d9861 100644 --- a/src/front/components/Dashboard/InteractiveCharts.jsx +++ b/src/front/components/Dashboard/InteractiveCharts.jsx @@ -86,11 +86,11 @@ export const InteractiveCharts = ({ token, companyId, currentLang = "es" }) => {
{t.jobStatusTitle}
- {data.job_status.length ? data.job_status.map((item) => ) : {t.empty}} + {data.job_status.length ? data.job_status.map((item) => ) : {t.empty}}
{t.leadSourcesTitle}
- {data.lead_sources.length ? data.lead_sources.map((source) => ) : {t.empty}} + {data.lead_sources.length ? data.lead_sources.map((source) => ) : {t.empty}}
diff --git a/src/front/components/Sidebar.jsx b/src/front/components/Sidebar.jsx index e3fc947..597f036 100644 --- a/src/front/components/Sidebar.jsx +++ b/src/front/components/Sidebar.jsx @@ -1,11 +1,14 @@ import { useEffect, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useLanguage } from "../context/LanguageContext"; +import { useApp } from "../context/AppContext"; +import { readApiJson } from "../services/response.mjs"; export const Sidebar = ({ isOpen, onClose }) => { const location = useLocation(); const navigate = useNavigate(); const { t, ui } = useLanguage(); + const { setBrandColour } = useApp(); const [account, setAccount] = useState({ user: null, company: null }); useEffect(() => { @@ -23,11 +26,13 @@ export const Sidebar = ({ isOpen, onClose }) => { }); if (!response.ok) return; - const data = await response.json(); + const data = await readApiJson(response); setAccount({ user: data.user || null, company: data.companies?.[0] || null, }); + const company = data.companies?.[0]; + if (company?.primary_colour) setBrandColour(company.primary_colour); } catch (error) { if (error.name !== "AbortError") { console.error("Unable to load sidebar account context."); @@ -114,7 +119,7 @@ export const Sidebar = ({ isOpen, onClose }) => { {/* Logo y Marca con botón de cierre para móvil integrado */}
-
+
C
diff --git a/src/front/context/AppContext.jsx b/src/front/context/AppContext.jsx index b3cdffc..bd41256 100644 --- a/src/front/context/AppContext.jsx +++ b/src/front/context/AppContext.jsx @@ -2,6 +2,16 @@ import React, { createContext, useContext, useState, useEffect } from "react"; const AppContext = createContext(); +const DEFAULT_BRAND = "#635BFF"; +const normalizeBrand = (value) => /^#[0-9a-f]{6}$/i.test(value || "") + ? value.toUpperCase() + : DEFAULT_BRAND; + +const brandRgb = (value) => { + const colour = normalizeBrand(value).slice(1); + return [0, 2, 4].map((offset) => parseInt(colour.slice(offset, offset + 2), 16)).join(", "); +}; + export const AppProvider = ({ children }) => { const [theme, setTheme] = useState(() => { return localStorage.getItem("theme") || "system"; @@ -13,6 +23,9 @@ export const AppProvider = ({ children }) => { const stored = localStorage.getItem("interface_density"); return stored === "compact" ? "compact" : "comfortable"; }); + const [brandColour, setBrandColourState] = useState(() => + normalizeBrand(localStorage.getItem("brand_colour")) + ); const resolvedTheme = theme === "system" ? systemTheme : theme; @@ -35,6 +48,17 @@ export const AppProvider = ({ children }) => { localStorage.setItem("interface_density", density); }, [density]); + useEffect(() => { + const root = document.documentElement; + root.style.setProperty("--cf-brand", brandColour); + root.style.setProperty("--cf-brand-rgb", brandRgb(brandColour)); + root.style.setProperty("--bs-primary", brandColour); + root.style.setProperty("--bs-primary-rgb", brandRgb(brandColour)); + localStorage.setItem("brand_colour", brandColour); + }, [brandColour]); + + const setBrandColour = (value) => setBrandColourState(normalizeBrand(value)); + const toggleTheme = () => { setTheme(resolvedTheme === "dark" ? "light" : "dark"); }; @@ -45,7 +69,7 @@ export const AppProvider = ({ children }) => { }; return ( - + {children} ); diff --git a/src/front/context/LanguageContext.jsx b/src/front/context/LanguageContext.jsx index 890e341..520331b 100644 --- a/src/front/context/LanguageContext.jsx +++ b/src/front/context/LanguageContext.jsx @@ -6,6 +6,11 @@ import { literalMessage, translateLiteral, validateLiteralTranslations } from ". const LanguageContext = createContext(); const supportedLocales = ["es", "en", "pt"]; const literalOrigins = new WeakMap(); +const genericError = { + en: "Unable to complete the operation. Try again.", + es: "No se pudo completar la operación. Inténtalo de nuevo.", + pt: "Não foi possível concluir a operação. Tente novamente.", +}; export const LanguageProvider = ({ children }) => { // Recupera el idioma guardado o por defecto usa español ('es') @@ -37,6 +42,12 @@ export const LanguageProvider = ({ children }) => { translations = literalMessage(match[2]); if (translations) literalOrigins.set(root, translations); } + if (!translations && match[2].length > 3 && root.parentElement?.closest( + '[role="alert"], .alert-danger, .inbox-error, .knowledge-error, .knowledge-failure, .settings-error, .settings-notice.error' + )) { + translations = genericError; + literalOrigins.set(root, translations); + } const translated = translations?.[locale] || match[2]; if (translated !== match[2]) root.nodeValue = `${match[1]}${translated}${match[3]}`; return; diff --git a/src/front/i18n/literalTranslations.mjs b/src/front/i18n/literalTranslations.mjs index 38a3c03..b97b80d 100644 --- a/src/front/i18n/literalTranslations.mjs +++ b/src/front/i18n/literalTranslations.mjs @@ -173,6 +173,9 @@ export const literalTranslations = [ phrase("Save job", "Guardar Trabajo", "Salvar trabalho"), phrase("Delete this job?", "¿Estás seguro de que deseas eliminar este trabajo?", "Tem certeza de que deseja excluir este trabalho?"), phrase("Unable to load the account.", "No se pudo cargar la cuenta.", "Não foi possível carregar a conta."), + phrase("Unable to load jobs.", "No se pudieron cargar los trabajos.", "Não foi possível carregar os trabalhos."), + phrase("Unable to delete the job.", "No se pudo eliminar el trabajo.", "Não foi possível excluir o trabalho."), + phrase("Unable to create the job.", "No se pudo crear el trabajo.", "Não foi possível criar o trabalho."), phrase("No active company was found.", "No se encontró una empresa activa.", "Nenhuma empresa ativa foi encontrada."), phrase("The delivery date cannot be before the start date.", "La fecha de entrega no puede ser anterior a la fecha de inicio.", "A data de entrega não pode ser anterior à data de início."), phrase("Network error while deleting the job.", "Error de red al intentar eliminar el trabajo.", "Erro de rede ao excluir o trabalho."), @@ -278,6 +281,28 @@ export const literalTranslations = [ phrase("The initial message cannot be empty.", "El mensaje inicial no puede estar vacío.", "A mensagem inicial não pode estar vazia."), phrase("Unable to connect to the server.", "No se pudo conectar con el servidor.", "Não foi possível conectar ao servidor."), phrase("Unable to complete the operation. Try again.", "No se pudo completar la operación. Inténtalo de nuevo.", "Não foi possível concluir a operação. Tente novamente."), + phrase("Saving...", "Guardando...", "Salvando..."), + phrase("Draft", "Borrador", "Rascunho"), + phrase("Scheduled", "Programado", "Agendado"), + phrase("Review", "Revisión", "Revisão"), + phrase("Cancelled", "Cancelado", "Cancelado"), + phrase("Add stage", "Añadir etapa", "Adicionar etapa"), + phrase("Due date", "Fecha límite", "Data limite"), + phrase("Unable to update the job status.", "No se pudo actualizar el estado del trabajo.", "Não foi possível atualizar o status do trabalho."), + phrase("Unable to create the stage.", "No se pudo crear la etapa.", "Não foi possível criar a etapa."), + phrase("Invitation created. Copy and share the secure link.", "Invitación creada. Copia y comparte el enlace seguro.", "Convite criado. Copie e compartilhe o link seguro."), + phrase("Secure invitation link", "Enlace seguro de invitación", "Link seguro do convite"), + phrase("Copy link", "Copiar enlace", "Copiar link"), + phrase("Invitation created. Share the secure link with the invited member.", "Invitación creada. Comparte el enlace seguro con la persona invitada.", "Convite criado. Compartilhe o link seguro com a pessoa convidada."), + phrase("Recovery is temporarily unavailable.", "Recuperación no disponible temporalmente.", "Recuperação temporariamente indisponível."), + phrase("Enter a valid email.", "Introduce un email válido.", "Informe um email válido."), + phrase("A valid email is required.", "Se requiere un email válido.", "É necessário um email válido."), + phrase("Invalid role.", "Rol no válido.", "Função inválida."), + phrase("You cannot invite a member with this role.", "No puedes invitar a un miembro con este rol.", "Você não pode convidar um membro com esta função."), + phrase("This user already belongs to the company.", "Este usuario ya pertenece a la empresa.", "Este usuário já pertence à empresa."), + phrase("Unable to create the invitation.", "No se pudo crear la invitación.", "Não foi possível criar o convite."), + phrase("Only owners and administrators can manage the team.", "Solo propietarios y administradores pueden gestionar el equipo.", "Somente proprietários e administradores podem gerenciar a equipe."), + phrase("A JSON object is required.", "Se requiere un objeto JSON.", "É necessário um objeto JSON."), ]; const reverseCatalogue = new Map(); diff --git a/src/front/pages/AcceptInvitation.jsx b/src/front/pages/AcceptInvitation.jsx index 458c0ba..5795c59 100644 --- a/src/front/pages/AcceptInvitation.jsx +++ b/src/front/pages/AcceptInvitation.jsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import "../styles/members.css"; import { useLanguage } from "../context/LanguageContext"; import { WorkspacePreferences } from "../components/WorkspacePreferences"; +import { readApiJson } from "../services/response.mjs"; export const AcceptInvitation = () => { const { ui } = useLanguage(); @@ -22,9 +23,7 @@ export const AcceptInvitation = () => { method: "POST", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body), }); - const data = await response.json(); - if (!response.ok) throw new Error(data.message || ui.invitationAcceptError); - return data; + return readApiJson(response, ui.invitationAcceptError); }; try { if (existing) { diff --git a/src/front/pages/Agenda.jsx b/src/front/pages/Agenda.jsx index e98d53b..e32b381 100644 --- a/src/front/pages/Agenda.jsx +++ b/src/front/pages/Agenda.jsx @@ -7,7 +7,7 @@ import { useLanguage } from "../context/LanguageContext"; const localTime = (date) => `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; const localStamp = (value) => { const date = new Date(value); return `${dateKey(date)}T${localTime(date)}`; }; -const statusColors = { scheduled: "#635bff", confirmed: "#198754", completed: "#495057", cancelled: "#6c757d", no_show: "#a64b00" }; +const statusColors = { scheduled: "var(--cf-brand)", confirmed: "#198754", completed: "#495057", cancelled: "#6c757d", no_show: "#a64b00" }; export const Agenda = () => { const { locale, ui } = useLanguage(); @@ -135,7 +135,7 @@ export const Agenda = () => {
@@ -217,8 +217,8 @@ export const Agenda = () => { className="h-100 d-flex flex-column justify-content-between p-2 rounded-2 position-relative bg-white" style={{ cursor: "pointer", - border: isSelected ? "2px solid #635bff" : "1px solid #dee2e6", - boxShadow: isSelected ? "0 0 0 1px #635bff" : "none", + border: isSelected ? "2px solid var(--cf-brand)" : "1px solid #dee2e6", + boxShadow: isSelected ? "0 0 0 1px var(--cf-brand)" : "none", transition: "all 0.15s ease-in-out" }} > @@ -226,13 +226,13 @@ export const Agenda = () => { {dayNum} - {dayApps.length > 0 && {dayApps.length}} + {dayApps.length > 0 && {dayApps.length}}
{dayApps.map(app => { const timeStr = app.starts_at ? app.starts_at.split('T')[1].substring(0, 5) : ""; return ( -
+
{timeStr} {app.title}
); @@ -278,8 +278,8 @@ export const Agenda = () => { key={dateStr} style={{ minHeight: "320px", - border: isSelected ? "2px solid #635bff" : "1px solid #dee2e6", - boxShadow: isSelected ? "0 0 0 1px #635bff" : "none", + border: isSelected ? "2px solid var(--cf-brand)" : "1px solid #dee2e6", + boxShadow: isSelected ? "0 0 0 1px var(--cf-brand)" : "none", transition: "all 0.15s ease-in-out" }} > @@ -295,7 +295,7 @@ export const Agenda = () => { {dayApps.map(app => { const timeStr = app.starts_at ? app.starts_at.split('T')[1].substring(0, 5) : ""; return ( -
+
{timeStr} {app.title}
); @@ -316,9 +316,9 @@ export const Agenda = () => {
- {ui.dayAppointments} + {ui.dayAppointments}
- {selectedDateStr} + {selectedDateStr}
{selectedDayAppointments.length === 0 ? ( @@ -338,7 +338,7 @@ export const Agenda = () => { className={`p-3 rounded-3 border-0 shadow-xs bg-light position-relative`} key={app.id} style={{ - borderLeft: `4px solid ${isHighlighted ? "#198754" : (choices.services.find(service => service.id === app.service_type_id)?.colour || "#635bff")}` + borderLeft: `4px solid ${isHighlighted ? "#198754" : (choices.services.find(service => service.id === app.service_type_id)?.colour || "var(--cf-brand)")}` }} >
@@ -349,7 +349,7 @@ export const Agenda = () => {
- {timeStr} + {timeStr}
- +
diff --git a/src/front/pages/Agents.css b/src/front/pages/Agents.css index fea898c..1401686 100644 --- a/src/front/pages/Agents.css +++ b/src/front/pages/Agents.css @@ -43,8 +43,8 @@ } .agents-page .form-control:focus { background: var(--cf-surface-muted); color: var(--cf-text); border-color: #7859df; box-shadow: 0 0 0 3px #7859df22; } .agents-page textarea { min-height: 180px; resize: vertical; } -.agents-page fieldset { background: var(--cf-surface-muted); border: 1px solid var(--cf-border); border-radius: 12px; padding: 18px; margin-top: 8px; } -.agents-page legend { float: none; width: auto; font-size: 15px; font-weight: 650; margin-bottom: 10px; color: var(--cf-text); } +.agents-page fieldset { background: var(--cf-surface-muted); border: 1px solid var(--cf-border); border-radius: 12px; padding: 18px; margin: 0 0 20px; } +.agents-page legend { float: none; width: auto; padding: 0 6px; font-size: 14px; font-weight: 550; margin: 0 0 10px -6px; color: var(--cf-text); } .agents-page fieldset label { display: flex !important; gap: 10px; align-items: flex-start; padding: 8px 0; overflow-wrap: anywhere; } .agents-page input[type="checkbox"] { width: 18px; height: 18px; margin-top: 2px; flex-shrink: 0; accent-color: #7357d9; } .agents-page fieldset p { color: var(--cf-text-muted); font-size: 14px; margin: 0; } diff --git a/src/front/pages/ClientDetail.jsx b/src/front/pages/ClientDetail.jsx index 9062247..632f661 100644 --- a/src/front/pages/ClientDetail.jsx +++ b/src/front/pages/ClientDetail.jsx @@ -2,6 +2,14 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { clientService } from "../services/clientService"; import { useLanguage } from "../context/LanguageContext"; +import { translateLiteral } from "../i18n/literalTranslations.mjs"; +import { readApiJson } from "../services/response.mjs"; + +const defaultActionForm = () => { + const date = new Date(Date.now() + 24 * 60 * 60 * 1000); + const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return { title: "", description: "", dueAt: localDate.toISOString().slice(0, 16) }; +}; export const ClientDetail = () => { @@ -16,6 +24,12 @@ export const ClientDetail = () => { const [recentActivity, setRecentActivity] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [requestOptions, setRequestOptions] = useState(null); + const [currentResponsibleName, setCurrentResponsibleName] = useState(""); + const [showActionForm, setShowActionForm] = useState(false); + const [actionForm, setActionForm] = useState(defaultActionForm); + const [savingAction, setSavingAction] = useState(false); + const [actionError, setActionError] = useState(""); useEffect(() => { const loadClient = async () => { @@ -31,8 +45,7 @@ export const ClientDetail = () => { }, }); - if (!response.ok) throw new Error(ui.accountLoadError); - const account = await response.json(); + const account = await readApiJson(response, ui.accountLoadError); const current = account.companies?.[0]; if (!current) { @@ -42,7 +55,12 @@ export const ClientDetail = () => { const options = { token, companyId: current.id, + membershipId: current.membership_id, }; + setRequestOptions(options); + setCurrentResponsibleName( + `${account.user?.first_name || ""} ${account.user?.last_name || ""}`.trim() + ); const [ clientData, @@ -80,6 +98,31 @@ export const ClientDetail = () => { loadClient(); }, [id, ui.accountLoadError, ui.noCompany]); + const createNextAction = async (event) => { + event.preventDefault(); + if (!requestOptions?.membershipId) { + setActionError(translateLiteral("Unable to identify the current responsible person.", locale)); + return; + } + try { + setSavingAction(true); + setActionError(""); + const created = await clientService.createNextAction(requestOptions, id, { + title: actionForm.title.trim(), + description: actionForm.description.trim() || null, + due_at: new Date(actionForm.dueAt).toISOString(), + assigned_membership_id: requestOptions.membershipId, + }); + setNextActions((current) => [...current, created].sort((a, b) => new Date(a.due_at) - new Date(b.due_at))); + setActionForm(defaultActionForm()); + setShowActionForm(false); + } catch (failure) { + setActionError(failure.message || translateLiteral("Unable to create the next action.", locale)); + } finally { + setSavingAction(false); + } + }; + if (loading) { return
Cargando cliente...
; } @@ -157,17 +200,45 @@ export const ClientDetail = () => { {/* Próxima Acción (Next Action) */}
-
+
Próxima Acción Programada
+
Due: {nextAction?.due_at ? new Date(nextAction.due_at).toLocaleDateString(locale) : "Sin fecha"} + +
+ {showActionForm && ( +
+
+
+ + setActionForm((current) => ({ ...current, title: event.target.value }))} /> +
+
+ + setActionForm((current) => ({ ...current, dueAt: event.target.value }))} /> +
+
+ + +
+
+ {actionError &&
{actionError}
} +
+ + +
+
+ )}
{nextAction?.title || "No hay próxima acción programada"} @@ -179,7 +250,9 @@ export const ClientDetail = () => { Propietario:{" "} - {nextAction?.assigned_membership_id || "Sin asignar"} + {nextAction?.assigned_membership_id === requestOptions?.membershipId + ? currentResponsibleName || translateLiteral("Responsible", locale) + : translateLiteral("Unassigned", locale)} @@ -192,7 +265,12 @@ export const ClientDetail = () => {
Estado:{" "} - {nextAction?.status || "Sin acción programada"} + {nextAction?.status + ? translateLiteral( + nextAction.status.charAt(0).toUpperCase() + nextAction.status.slice(1), + locale + ) + : translateLiteral("No scheduled action", locale)}
diff --git a/src/front/pages/Clients.jsx b/src/front/pages/Clients.jsx index a8c3a6e..c76d117 100644 --- a/src/front/pages/Clients.jsx +++ b/src/front/pages/Clients.jsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import { clientService } from "../services/clientService"; import { useLanguage } from "../context/LanguageContext"; import { translateLiteral } from "../i18n/literalTranslations.mjs"; +import { readApiJson } from "../services/response.mjs"; const EMPTY_ADDRESS = { label: "Principal", @@ -53,11 +54,7 @@ export const Clients = () => { signal: controller.signal, }); - if (!response.ok) { - throw new Error(ui.accountLoadError); - } - - const account = await response.json(); + const account = await readApiJson(response, ui.accountLoadError); const current = account.companies?.[0]; if (!current) { @@ -283,7 +280,7 @@ export const Clients = () => {
ClientFlow · Clientes @@ -300,7 +297,7 @@ export const Clients = () => { onClick={openNewClient} disabled={!options} className="btn btn-primary d-flex align-items-center gap-2 shadow-sm px-3 py-2" - style={{ backgroundColor: "#635bff", border: "none" }} + style={{ backgroundColor: "var(--cf-brand)", border: "none" }} > Nuevo cliente @@ -402,7 +399,7 @@ export const Clients = () => { style={{ width: "50px", height: "50px", - background: "linear-gradient(135deg, #635bff, #8b5cf6)", + background: "linear-gradient(135deg, var(--cf-brand), #8b5cf6)", }} > {fullName @@ -482,10 +479,10 @@ export const Clients = () => { aria-modal="true" aria-labelledby="client-form-title" > -
+
@@ -494,7 +491,7 @@ export const Clients = () => { style={{ width: "44px", height: "44px", - background: "linear-gradient(135deg, #635bff, #8b5cf6)", + background: "linear-gradient(135deg, var(--cf-brand), #8b5cf6)", }} > @@ -517,11 +514,12 @@ export const Clients = () => { >
-
-
+
@@ -573,7 +571,7 @@ export const Clients = () => {
- +
Dirección principal

@@ -661,7 +659,7 @@ export const Clients = () => { )}

-
+
@@ -232,34 +292,45 @@ export const JobDetail = () => { {/* Columna Principal Izquierda: Etapas del Proyecto */}
-
+
Etapas del Proyecto
+
+ {showStageForm && +
+
setStageForm((current) => ({ ...current, title: event.target.value }))} />
+
setStageForm((current) => ({ ...current, dueAt: event.target.value }))} />
+
+
+
+ }
- +
- - - - - + + + + + {job.stages && job.stages.length > 0 ? ( job.stages.map((stage, index) => ( - - - - + + + -
#Nombre de la EtapaEstado ActualAcciones / Marcar
#Nombre de la EtapaEstado ActualAcciones / Marcar
0{index + 1}{stage.name} +
0{index + 1}{stage.name} {getStageText(stage.status)} +
@@ -454,7 +456,7 @@ export const Jobs = () => {
- +
diff --git a/src/front/pages/Knowledge.jsx b/src/front/pages/Knowledge.jsx index a485b2a..dedfdea 100644 --- a/src/front/pages/Knowledge.jsx +++ b/src/front/pages/Knowledge.jsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { knowledgeService } from "../services/knowledgeService"; import "../styles/knowledge.css"; import { useLanguage } from "../context/LanguageContext"; +import { readApiJson } from "../services/response.mjs"; export const Knowledge = () => { const { locale, ui } = useLanguage(); @@ -32,8 +33,7 @@ export const Knowledge = () => { const response = await fetch(`${base}/api/me`, { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal, }); - if (!response.ok) throw new Error(ui.accountLoadError); - const account = await response.json(); + const account = await readApiJson(response, ui.accountLoadError); const current = account.companies?.[0]; if (!current) throw new Error(ui.noCompany); const result = await knowledgeService.list({ token, companyId: current.id, signal: controller.signal }, page); diff --git a/src/front/pages/Leads.jsx b/src/front/pages/Leads.jsx index c7a3728..4428c54 100644 --- a/src/front/pages/Leads.jsx +++ b/src/front/pages/Leads.jsx @@ -3,6 +3,7 @@ import { leadService } from "../services/leadService"; import "../styles/leads.css"; import { useLanguage } from "../context/LanguageContext"; import { translateLiteral } from "../i18n/literalTranslations.mjs"; +import { readApiJson } from "../services/response.mjs"; const STATUS_LABELS = { new: "Nuevo", @@ -183,11 +184,10 @@ export const Leads = () => { signal: controller.signal, }); - if (!accountResponse.ok) { - throw new Error("No se pudo verificar tu cuenta."); - } - - const account = await accountResponse.json(); + const account = await readApiJson( + accountResponse, + translateLiteral("Unable to verify your account.", locale) + ); const currentCompany = account.companies?.[0]; if (!currentCompany) { @@ -474,16 +474,15 @@ export const Leads = () => { return (
CLIENTFLOW {companyName ? `· ${companyName.toUpperCase()}` : ""} @@ -495,7 +494,7 @@ export const Leads = () => {
-
@@ -820,7 +819,7 @@ export const Leads = () => {
- {notice &&

{notice}

} + {invitationLink &&
+ {translateLiteral("Secure invitation link", locale)} +
+ + +
+
}
{ setSearch(event.target.value); setPage(1); }} /> diff --git a/src/front/pages/PlanSelection.jsx b/src/front/pages/PlanSelection.jsx index ceff09b..2bf1f83 100644 --- a/src/front/pages/PlanSelection.jsx +++ b/src/front/pages/PlanSelection.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { AuthLayout } from "../components/AuthLayout"; import { useLanguage } from "../context/LanguageContext"; +import { readApiJson } from "../services/response.mjs"; export const PlanSelection = () => { @@ -43,8 +44,7 @@ export const PlanSelection = () => { navigate("/login", { replace: true }); return null; } - if (!response.ok) throw new Error(ui.accountLoadError); - return response.json(); + return readApiJson(response, ui.accountLoadError); }) .then((account) => { if (!account || controller.signal.aborted) return; @@ -78,11 +78,7 @@ export const PlanSelection = () => { { signal: controller.signal } ); - if (!response.ok) { - throw new Error(ui.plansError); - } - - const data = await response.json(); + const data = await readApiJson(response, ui.plansError); if (!Array.isArray(data)) { throw new Error(ui.invalidPlans); diff --git a/src/front/pages/Settings.css b/src/front/pages/Settings.css index 04d3904..de3a816 100644 --- a/src/front/pages/Settings.css +++ b/src/front/pages/Settings.css @@ -1,6 +1,6 @@ .settings-page { max-width: 1180px; margin: 0 auto; padding: 0 0 3rem; color: var(--cf-text); } .settings-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 2rem; margin-bottom: 2rem; } -.settings-header p { margin: 0 0 .45rem; color: #635bff; font-size: .72rem; font-weight: 800; letter-spacing: .14em; } +.settings-header p { margin: 0 0 .45rem; color: var(--cf-brand); font-size: .72rem; font-weight: 800; letter-spacing: .14em; } .settings-header h1 { margin: 0; font-size: clamp(2rem, 4vw, 3rem); font-weight: 800; } .settings-header span { color: var(--cf-text-muted); } .settings-company-mark { width: 54px; height: 54px; border-radius: 16px; display: grid; place-items: center; color: white; font-size: 1.4rem; font-weight: 800; box-shadow: 0 12px 30px rgba(99,91,255,.22); } @@ -10,7 +10,7 @@ .settings-tabs button { border: 0; background: transparent; color: var(--cf-text-muted); border-radius: 12px; text-align: left; padding: .8rem .9rem; display: flex; align-items: center; gap: .75rem; font-weight: 700; } .settings-tabs button i { width: 18px; text-align: center; } .settings-tabs button:hover { background: var(--cf-surface-muted); color: var(--cf-text); } -.settings-tabs button.active { background: rgba(99,91,255,.12); color: #635bff; } +.settings-tabs button.active { background: rgba(var(--cf-brand-rgb),.12); color: var(--cf-brand); } .settings-panel { padding: clamp(1.25rem, 3vw, 2rem); min-height: 520px; } .settings-panel-title { border-bottom: 1px solid var(--cf-border); padding-bottom: 1.1rem; margin-bottom: 1.4rem; } .settings-panel-title h2 { margin: 0 0 .35rem; font-size: 1.45rem; font-weight: 800; } @@ -20,11 +20,11 @@ .settings-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } .settings-field { display: grid; gap: .45rem; color: var(--cf-text); font-weight: 700; font-size: .88rem; } .settings-field input, .settings-field select { width: 100%; min-height: 44px; border-radius: 10px; border: 1px solid var(--cf-border); background: var(--cf-surface-muted); color: var(--cf-text); padding: .7rem .8rem; outline: 0; } -.settings-field input:focus, .settings-field select:focus { border-color: #635bff; box-shadow: 0 0 0 3px rgba(99,91,255,.15); } +.settings-field input:focus, .settings-field select:focus { border-color: var(--cf-brand); box-shadow: 0 0 0 3px rgba(var(--cf-brand-rgb),.15); } .colour-field { display: grid; grid-template-columns: 52px 1fr; gap: .5rem; } .colour-field input[type="color"] { padding: .25rem; } .settings-primary-button, .settings-link-button, .settings-secondary-button, .settings-danger-button { min-height: 43px; border-radius: 10px; padding: .7rem 1rem; font-weight: 800; display: inline-flex; align-items: center; justify-content: center; gap: .55rem; text-decoration: none; margin-top: 1.35rem; } -.settings-primary-button, .settings-link-button { border: 1px solid #635bff; background: #635bff; color: white; } +.settings-primary-button, .settings-link-button { border: 1px solid var(--cf-brand); background: var(--cf-brand); color: white; } .settings-primary-button:disabled { opacity: .65; } .settings-secondary-button { border: 1px solid var(--cf-border); background: var(--cf-surface); color: var(--cf-text); } .settings-danger-button { border: 1px solid #dc3545; background: transparent; color: #dc3545; } @@ -37,18 +37,18 @@ .settings-error button { border: 0; border-radius: 8px; padding: .6rem 1rem; } .settings-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } .settings-metric { display: grid; grid-template-columns: auto auto 1fr; align-items: center; gap: .75rem; border: 1px solid var(--cf-border); border-radius: 14px; padding: 1.15rem; background: var(--cf-surface-muted); } -.settings-metric i { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; color: #635bff; background: rgba(99,91,255,.12); } +.settings-metric i { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; color: var(--cf-brand); background: rgba(var(--cf-brand-rgb),.12); } .settings-metric strong { font-size: 1.6rem; } .settings-metric span { color: var(--cf-text-muted); } .integration-list { display: grid; gap: 1rem; } .integration-card { border: 1px solid var(--cf-border); border-radius: 15px; padding: 1.1rem; background: var(--cf-surface-muted); } .integration-heading { display: flex; justify-content: space-between; align-items: center; gap: 1rem; } .integration-heading > div { display: flex; align-items: center; gap: .8rem; } -.integration-heading > div > i { width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; background: var(--cf-surface); color: #635bff; } +.integration-heading > div > i { width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; background: var(--cf-surface); color: var(--cf-brand); } .integration-heading h3 { margin: 0; font-size: 1rem; font-weight: 800; } .integration-heading div span { color: var(--cf-text-muted); font-size: .82rem; } .settings-switch { display: flex; align-items: center; gap: .45rem; font-weight: 700; } -.settings-switch input { width: 18px; height: 18px; accent-color: #635bff; } +.settings-switch input { width: 18px; height: 18px; accent-color: var(--cf-brand); } .integration-fields { margin-top: 1rem; } .security-list { display: grid; gap: .2rem; border: 1px solid var(--cf-border); border-radius: 14px; overflow: hidden; } .security-list div { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; background: var(--cf-surface-muted); border-bottom: 1px solid var(--cf-border); } diff --git a/src/front/pages/Settings.jsx b/src/front/pages/Settings.jsx index ea274f6..6612744 100644 --- a/src/front/pages/Settings.jsx +++ b/src/front/pages/Settings.jsx @@ -14,7 +14,7 @@ const integrationDefaults = { export const Settings = () => { const token = localStorage.getItem("access_token"); - const { theme, setTheme, density, setDensity } = useApp(); + const { theme, setTheme, density, setDensity, setBrandColour } = useApp(); const { locale, setLocale } = useLanguage(); const text = settingsMessages[locale] || settingsMessages.en; const [tab, setTab] = useState("company"); @@ -41,6 +41,7 @@ export const Settings = () => { const result = await settingsService.load(token, signal); setData(result); setCompany(result.company); + setBrandColour(result.company.primary_colour || "#635BFF"); setPreferences((current) => ({ ...current, theme: result.company.theme || current.theme, @@ -93,7 +94,10 @@ export const Settings = () => { timezone: company.timezone, primary_colour: company.primary_colour || null, })); - if (result) window.dispatchEvent(new CustomEvent("clientflow:company-updated")); + if (result) { + setBrandColour(company.primary_colour || "#635BFF"); + window.dispatchEvent(new CustomEvent("clientflow:company-updated")); + } }; const savePreferences = async (event) => { @@ -118,6 +122,11 @@ export const Settings = () => { })); }; + const changeCompanyColour = (value) => { + setCompany((current) => ({ ...current, primary_colour: value })); + if (/^#[0-9A-Fa-f]{6}$/.test(value)) setBrandColour(value); + }; + const saveIntegration = async (event, provider) => { event.preventDefault(); const result = await run(provider, () => settingsService.saveIntegration(options, provider, integrationForms[provider])); @@ -159,7 +168,7 @@ export const Settings = () => { setCompany({ ...company, email: event.target.value })} /> setCompany({ ...company, phone: event.target.value })} /> setCompany({ ...company, timezone: event.target.value })} /> -
setCompany({ ...company, primary_colour: event.target.value })} /> setCompany({ ...company, primary_colour: event.target.value })} />
+
changeCompanyColour(event.target.value)} /> changeCompanyColour(event.target.value)} />
{canManage && {text.saveCompany}} } diff --git a/src/front/pages/WebChatPage.jsx b/src/front/pages/WebChatPage.jsx index 8d0f349..e1e801b 100644 --- a/src/front/pages/WebChatPage.jsx +++ b/src/front/pages/WebChatPage.jsx @@ -2,6 +2,7 @@ import { useRef, useState } from "react"; import { WebChat } from "../components/WebChat"; import { webChatService } from "../services/webChatService"; import { useLanguage } from "../context/LanguageContext"; +import { readApiJson } from "../services/response.mjs"; export function WebChatPage() { const { ui } = useLanguage(); @@ -34,11 +35,7 @@ export function WebChatPage() { cache: "no-store", }); - if (!response.ok) { - throw new Error(ui.accountLoadError); - } - - const account = await response.json(); + const account = await readApiJson(response, ui.accountLoadError); const company = account.companies?.[0]; if (!company) { diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 7d05306..c11ae60 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -22,6 +22,7 @@ import { ResetPassword } from "./pages/ResetPassword"; import { Settings } from "./pages/Settings"; import { WebChatPage } from "./pages/WebChatPage"; import { useLanguage } from "./context/LanguageContext"; +import { readApiJson } from "./services/response.mjs"; const LocalizedNotFound = () => { const { ui } = useLanguage(); @@ -56,11 +57,7 @@ const ProtectedRoute = ({ children }) => { return; } - if (!meResponse.ok) { - throw new Error(ui.accountLoadError); - } - - const account = await meResponse.json(); + const account = await readApiJson(meResponse, ui.accountLoadError); const company = account.companies?.[0]; if (!company) { @@ -80,14 +77,16 @@ const ProtectedRoute = ({ children }) => { return; } - const data = await response.json(); - - if (!response.ok) { + let data; + try { + data = await readApiJson(response, ui.subscriptionVerifyError); + } catch (failure) { + data = { code: failure.code }; if (data.code === "subscription_required") { setStatus("subscription_required"); return; } - throw new Error(data.message || ui.subscriptionVerifyError); + throw failure; } setStatus("allowed"); diff --git a/src/front/services/authService.js b/src/front/services/authService.js index 7dbbe1a..4bcfa5a 100644 --- a/src/front/services/authService.js +++ b/src/front/services/authService.js @@ -1,5 +1,6 @@ const USE_MOCK_API = import.meta.env.VITE_USE_MOCK_API !== "false"; const API_URL = (import.meta.env.VITE_BACKEND_URL || "").replace(/\/$/, ""); +import { readApiJson } from "./response.mjs"; export const authService = { login: async (email, password) => { @@ -9,10 +10,7 @@ export const authService = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); - const data = await response.json(); - if (!response.ok) - throw new Error(data.message || "Error al iniciar sesión"); - return data; + return readApiJson(response, "Error al iniciar sesión"); } else { return new Promise((resolve, reject) => { setTimeout(() => { @@ -36,9 +34,7 @@ export const authService = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData), }); - const data = await response.json(); - if (!response.ok) throw new Error(data.message || "Error al registrar"); - return data; + return readApiJson(response, "Error al registrar"); } else { return new Promise((resolve, reject) => { setTimeout(() => { @@ -62,10 +58,7 @@ export const authService = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); - const data = await response.json(); - if (!response.ok) - throw new Error(data.message || "Error al enviar correo"); - return data; + return readApiJson(response, "Error al enviar correo"); } else { return new Promise((resolve) => { setTimeout(() => { @@ -82,10 +75,7 @@ export const authService = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, password, password_confirmation }), }); - const data = await response.json(); - if (!response.ok) - throw new Error(data.message || "Error al actualizar contraseña"); - return data; + return readApiJson(response, "Error al actualizar contraseña"); } else { return new Promise((resolve, reject) => { setTimeout(() => { @@ -98,4 +88,4 @@ export const authService = { }); } }, -}; \ No newline at end of file +}; diff --git a/src/front/services/clientService.js b/src/front/services/clientService.js index a75743b..1805d87 100644 --- a/src/front/services/clientService.js +++ b/src/front/services/clientService.js @@ -94,6 +94,14 @@ export const clientService = { return request(`/clients/${clientId}/next-actions`, options); }, + createNextAction(options, clientId, body) { + return request(`/clients/${clientId}/next-actions`, { + ...options, + method: "POST", + body, + }); + }, + getJobs(options, clientId) { return request(`/clients/${clientId}/jobs`, options); }, diff --git a/src/front/services/knowledgeService.js b/src/front/services/knowledgeService.js index 7a417da..d887a3f 100644 --- a/src/front/services/knowledgeService.js +++ b/src/front/services/knowledgeService.js @@ -1,11 +1,11 @@ const base = (import.meta.env.VITE_BACKEND_URL || "").replace(/\/$/, ""); +import { readApiJson } from "./response.mjs"; async function request(path, { token, companyId, signal, method = "GET", body }) { const response = await fetch(`${base}/api/knowledge/documents${path}`, { method, signal, body, headers: { Authorization: `Bearer ${token}`, "X-Company-ID": String(companyId) }, }); - const data = await response.json(); if (!response.ok) { const messages = { 401: "Tu sesión ha caducado. Vuelve a iniciar sesión.", @@ -16,9 +16,13 @@ async function request(path, { token, companyId, signal, method = "GET", body }) 422: "No se pudo procesar el documento. Comprueba el archivo y el servicio de IA.", 503: "El servicio no está disponible. Inténtalo de nuevo.", }; - throw new Error(messages[response.status] || "No se pudo completar la operación. Comprueba el formato y el tamaño del archivo."); + try { + return await readApiJson(response, messages[response.status]); + } catch { + throw new Error(messages[response.status] || "No se pudo completar la operación. Comprueba el formato y el tamaño del archivo."); + } } - return data; + return readApiJson(response, "El servidor devolvió una respuesta no válida."); } export const knowledgeService = { diff --git a/src/front/services/leadService.js b/src/front/services/leadService.js index b78d3c9..096e4a4 100644 --- a/src/front/services/leadService.js +++ b/src/front/services/leadService.js @@ -1,6 +1,7 @@ const API_URL = ( import.meta.env.VITE_BACKEND_URL || "" ).replace(/\/$/, ""); +import { readApiJson } from "./response.mjs"; async function request( path, @@ -30,17 +31,7 @@ async function request( : {}), }); - const data = await response.json(); - - if (!response.ok) { - throw new Error( - data.message || - data.error || - "Unable to complete the lead operation." - ); - } - - return data; + return readApiJson(response, "Unable to complete the lead operation."); } export const leadService = { diff --git a/src/front/services/memberService.js b/src/front/services/memberService.js index 6e3e54a..0495fa8 100644 --- a/src/front/services/memberService.js +++ b/src/front/services/memberService.js @@ -19,12 +19,15 @@ async function request(path, { token, companyId, signal, method = "GET", body }) ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); - const data = await response.json(); + const contentType = response.headers.get("content-type") || ""; + const data = contentType.includes("application/json") ? await response.json() : null; if (!response.ok) { - throw new Error(data.message || "No se pudo completar la operación."); + throw new Error(data?.message || "No se pudo completar la operación."); } + if (!data) throw new Error("El servidor devolvió una respuesta no válida."); + return data; } diff --git a/src/front/services/response.mjs b/src/front/services/response.mjs new file mode 100644 index 0000000..6113087 --- /dev/null +++ b/src/front/services/response.mjs @@ -0,0 +1,17 @@ +export async function readApiJson(response, fallbackMessage) { + const contentType = response.headers?.get?.("content-type") || "application/json"; + if (!contentType.includes("application/json")) { + await response.text(); + const error = new Error(fallbackMessage || "The server returned an invalid response."); + error.status = response.status; + throw error; + } + const data = await response.json(); + if (!response.ok) { + const error = new Error(data.message || data.error || fallbackMessage || "Unable to complete the operation."); + error.status = response.status; + error.code = data.code; + throw error; + } + return data; +} diff --git a/src/front/services/subscriptionService.mjs b/src/front/services/subscriptionService.mjs index 013bf74..3e3db55 100644 --- a/src/front/services/subscriptionService.mjs +++ b/src/front/services/subscriptionService.mjs @@ -1,4 +1,5 @@ const API_URL = (import.meta.env?.VITE_BACKEND_URL || "").replace(/\/$/, ""); +import { readApiJson } from "./response.mjs"; export const subscriptionService = { activate: async ({ token, companyId, planId, signal }) => { @@ -15,12 +16,6 @@ export const subscriptionService = { }), signal, }); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || "Unable to activate the plan."); - } - - return data; + return readApiJson(response, "Unable to activate the plan."); }, }; diff --git a/src/front/theme.css b/src/front/theme.css index ce87cc7..645dcef 100644 --- a/src/front/theme.css +++ b/src/front/theme.css @@ -1,4 +1,6 @@ :root { + --cf-brand: #635bff; + --cf-brand-rgb: 99, 91, 255; --cf-workspace-bg: #f8f9fa; --cf-surface: #ffffff; --cf-surface-muted: #f1f3f5; @@ -8,6 +10,17 @@ --cf-control-shadow: 0 6px 22px rgba(15, 23, 42, 0.08); } +.btn-primary { + --bs-btn-bg: var(--cf-brand); + --bs-btn-border-color: var(--cf-brand); + --bs-btn-hover-bg: color-mix(in srgb, var(--cf-brand) 86%, #000); + --bs-btn-hover-border-color: color-mix(in srgb, var(--cf-brand) 82%, #000); +} + +.text-primary { color: var(--cf-brand) !important; } +.bg-primary { background-color: var(--cf-brand) !important; } +.border-primary { border-color: var(--cf-brand) !important; } + :root[data-bs-theme="dark"] { --cf-workspace-bg: #0b1120; --cf-surface: #111827; @@ -81,7 +94,7 @@ body, .theme-control:hover, .language-control:hover { - border-color: #635bff; + border-color: var(--cf-brand); } .workspace-preferences-compact .language-control, @@ -155,6 +168,20 @@ body, border-color: var(--cf-border) !important; } +:root[data-bs-theme="dark"] .main-content-area .btn-light, +:root[data-bs-theme="dark"] .main-content-area .alert-light, +:root[data-bs-theme="dark"] .main-content-area .dropdown-menu, +:root[data-bs-theme="dark"] .main-content-area .dashboard-breakdown-item { + color: var(--cf-text) !important; + background-color: var(--cf-surface-muted) !important; + border-color: var(--cf-border) !important; +} + +:root[data-bs-theme="dark"] .main-content-area .btn-light:hover, +:root[data-bs-theme="dark"] .main-content-area .dashboard-breakdown-item:hover { + background-color: color-mix(in srgb, var(--cf-surface-muted) 82%, var(--cf-brand)) !important; +} + :root[data-bs-theme="dark"] .main-content-area .form-control::placeholder, :root[data-bs-theme="dark"] .main-content-area textarea::placeholder { color: var(--cf-text-muted); diff --git a/tests/frontend/productionQa.test.mjs b/tests/frontend/productionQa.test.mjs index 8c42c15..d1784b5 100644 --- a/tests/frontend/productionQa.test.mjs +++ b/tests/frontend/productionQa.test.mjs @@ -52,3 +52,35 @@ test("job scheduling uses API field names without timezone shifts", () => { assert.match(jobDetail, /displayDate\(job\.scheduled_start\)/); assert.match(jobDetail, /displayDate\(job\.scheduled_end\)/); }); + +test("client creation keeps actions visible and client details can create next actions", () => { + const clients = source("src/front/pages/Clients.jsx"); + const detail = source("src/front/pages/ClientDetail.jsx"); + const service = source("src/front/services/clientService.js"); + assert.match(clients, /maxHeight: "calc\(100vh - 1rem\)"/); + assert.match(clients, /modal-footer[^\n]*flex-shrink-0/); + assert.match(detail, /createNextAction/); + assert.match(service, /\/clients\/\$\{clientId\}\/next-actions/); +}); + +test("job details expose overall status and stage creation", () => { + const detail = source("src/front/pages/JobDetail.jsx"); + assert.match(detail, /id="job-status"/); + assert.match(detail, /\/api\/jobs\/\$\{id\}\/stages/); + assert.match(detail, /createStage/); +}); + +test("company brand and dark dashboard controls use shared theme tokens", () => { + const app = source("src/front/context/AppContext.jsx"); + const theme = source("src/front/theme.css"); + const dashboard = source("src/front/components/Dashboard/InteractiveCharts.jsx"); + assert.match(app, /--cf-brand/); + assert.match(theme, /dashboard-breakdown-item/); + assert.match(dashboard, /dashboard-breakdown-item/); +}); + +test("production invitations return a shareable secure link", () => { + const members = source("src/front/pages/Members.jsx"); + assert.match(members, /invitation_url/); + assert.match(members, /navigator\.clipboard\.writeText/); +}); diff --git a/tests/frontend/response.test.mjs b/tests/frontend/response.test.mjs new file mode 100644 index 0000000..8d8dd16 --- /dev/null +++ b/tests/frontend/response.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { readApiJson } from "../../src/front/services/response.mjs"; + +test("API parsing replaces HTML responses with a useful error", async () => { + const response = new Response("Not found", { + status: 404, + headers: { "content-type": "text/html" }, + }); + + await assert.rejects( + readApiJson(response, "Unable to load the requested data."), + (error) => error.message === "Unable to load the requested data." && error.status === 404 + ); +}); + +test("API parsing preserves structured error codes", async () => { + const response = new Response(JSON.stringify({ + message: "Your subscription is inactive or expired.", + code: "subscription_required", + }), { + status: 403, + headers: { "content-type": "application/json" }, + }); + + await assert.rejects( + readApiJson(response), + (error) => error.code === "subscription_required" && error.status === 403 + ); +}); diff --git a/tests/test_ai_fallback.py b/tests/test_ai_fallback.py new file mode 100644 index 0000000..bc14329 --- /dev/null +++ b/tests/test_ai_fallback.py @@ -0,0 +1,27 @@ +import unittest + +from api.ai_fallback import conversational_fallback + + +class AIFallbackTest(unittest.TestCase): + def test_common_service_openings_receive_one_safe_question(self): + for question in ( + "Hola, quiero cambiar mi vestidor", + "Boa tarde, preciso consertar uma porta", + "Hi, I need to install a wardrobe", + ): + with self.subTest(question=question): + result = conversational_fallback(question) + self.assertEqual(result["status"], "pending_review") + self.assertTrue(result["requires_approval"]) + self.assertEqual(result["sources"], []) + self.assertEqual(result["reply"].count("?"), 1) + + def test_fallback_does_not_claim_prices_or_company_facts(self): + result = conversational_fallback("¿Cuánto cuesta un armario?") + self.assertNotIn("€", result["reply"]) + self.assertNotIn("euros", result["reply"].lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ai_orchestration.py b/tests/test_ai_orchestration.py index e80f1cb..dc99ba6 100644 --- a/tests/test_ai_orchestration.py +++ b/tests/test_ai_orchestration.py @@ -4,6 +4,8 @@ from unittest.mock import patch from api.ai_orchestration import generate_reply_draft +from api.ai_service import AgentServiceError +from api.knowledge_embeddings import EmbeddingServiceError class AIOrchestrationTest(unittest.TestCase): @@ -67,3 +69,16 @@ def test_low_similarity_is_not_used_as_evidence(self): result = generate_reply_draft(1,3,'Unknown?') self.assertNotIn('Unrelated.', request.call_args.args[0]) self.assertEqual(result['reason'], 'agent_requested_human') + + def test_service_outages_return_a_reviewable_clarification(self): + context = {"needs_human": False, "sources": [], "history": [], + "agent": {"main_instruction": "Help."}, "question": "Hola, quiero reparar una puerta"} + with patch('api.ai_orchestration.build_conversation_context', return_value=context), \ + patch('api.ai_orchestration.request_agent_reply', side_effect=AgentServiceError('offline')): + result = generate_reply_draft(1, 3, context['question']) + self.assertEqual(result['status'], 'pending_review') + self.assertEqual(result['reason'], 'local_clarification_fallback') + + with patch('api.ai_orchestration.build_conversation_context', side_effect=EmbeddingServiceError('offline')): + result = generate_reply_draft(1, 3, context['question']) + self.assertEqual(result['status'], 'pending_review') diff --git a/tests/test_auth.py b/tests/test_auth.py index 1848b4a..532ed27 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,7 @@ import os import sys import unittest +from unittest.mock import MagicMock, patch from datetime import timedelta, timezone from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -168,6 +169,20 @@ def fail(email, link): self.assertIsNotNone(db.session.scalar(select(PasswordResetToken)).used_at) self.assertNotIn('private', response.get_data(as_text=True)) + def test_resend_delivery_is_available_in_production(self): + self.app.config['AUTH_RESET_SENDER'] = None + self.app.debug = False + provider_response = MagicMock() + provider_response.status = 200 + provider_response.__enter__.return_value = provider_response + with patch.dict(os.environ, { + 'RESEND_API_KEY': 're_test', + 'AUTH_EMAIL_FROM': 'ClientFlow ', + }, clear=False), patch('api.auth.urlopen', return_value=provider_response) as send: + response = self.client.post('/api/forgot-password', json={'email': self.user.email}) + self.assertEqual(response.status_code, 202, response.json) + send.assert_called_once() + def test_rate_limit_is_persistent(self): self.app.config['AUTH_RATE_LIMIT_ENABLED'] = True for _ in range(10): diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 98d4a9a..f83e6e6 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -123,6 +123,20 @@ def test_create_update_stage_and_delete(self): self.assertEqual(updated.json["stages"][0]["status"], "completed") self.assertEqual(updated.json["progress"], 100) + added_stage = self.client.post( + f"/api/jobs/{self.job_id}/stages", + headers=self.auth, + json={ + "title": "Manufacture", + "description": "Build the wardrobe", + "due_at": "2026-10-10", + }, + ) + self.assertEqual(added_stage.status_code, 201, added_stage.json) + self.assertEqual(len(added_stage.json["stages"]), 2) + self.assertEqual(added_stage.json["stages"][1]["name"], "Manufacture") + self.assertEqual(added_stage.json["stages"][1]["position"], 2) + deleted = self.client.delete( f"/api/jobs/{created.json['id']}", headers=self.auth, @@ -156,6 +170,14 @@ def test_jobs_are_company_isolated_and_validate_status(self): ).status_code, 400, ) + self.assertEqual( + self.client.post( + f"/api/jobs/{self.other_job_id}/stages", + headers=self.auth, + json={"title": "Cross-company stage"}, + ).status_code, + 404, + ) self.assertEqual( db.session.scalar( select(Job.title).where(Job.id == self.job_id) diff --git a/tests/test_members.py b/tests/test_members.py index 84290f8..354f6a1 100644 --- a/tests/test_members.py +++ b/tests/test_members.py @@ -135,4 +135,8 @@ def test_invalid_payload_and_production_simulation(self): for payload in ({'is_active':'false'}, {'role': []}, {}, {'company_id':3}, {'role':'owner'}): self.assertIn(self.client.patch(f'/api/members/{member.id}', headers=self.owner, json=payload).status_code, (400,403)) self.app.config['DEBUG'] = False - self.assertEqual(self.client.post('/api/members/invitations', headers=self.owner, json={'email':'new@example.com'}).status_code,503) + response = self.client.post('/api/members/invitations', headers=self.owner, json={'email':'new@example.com'}) + self.assertEqual(response.status_code, 201, response.json) + self.assertEqual(response.json['delivery'], 'manual') + self.assertIn('/accept-invitation#token=', response.json['invitation_url']) + self.assertNotIn('token', response.json)