diff --git a/src/api/appointments.py b/src/api/appointments.py
index d301da3..90ebffe 100644
--- a/src/api/appointments.py
+++ b/src/api/appointments.py
@@ -118,7 +118,10 @@ def get_appointments():
raise ValueError('Invalid date range.')
except ValueError as error:
return jsonify(error=str(error)), 400
- query = select(Appointment).where(Appointment.company_id == g.company_id)
+ query = select(Appointment).where(
+ Appointment.company_id == g.company_id,
+ Appointment.status != AppointmentStatus.CANCELLED,
+ )
if start:
query = query.where(Appointment.ends_at > start)
if end:
diff --git a/src/front/i18n/literalTranslations.mjs b/src/front/i18n/literalTranslations.mjs
index b2ae140..38a3c03 100644
--- a/src/front/i18n/literalTranslations.mjs
+++ b/src/front/i18n/literalTranslations.mjs
@@ -18,6 +18,8 @@ export const literalTranslations = [
phrase("Not specified", "No especificado", "Não especificado"),
phrase("No priority", "Sin prioridad", "Sem prioridade"),
phrase("No date", "Sin fecha", "Sem data"),
+ phrase("To be defined", "Por definir", "A definir"),
+ phrase("Try again", "Reintentar", "Tentar novamente"),
phrase("Company", "Empresa", "Empresa"),
phrase("Client", "Cliente", "Cliente"),
phrase("Member", "Miembro", "Membro"),
@@ -90,6 +92,7 @@ export const literalTranslations = [
phrase("Do you want to {action} this client?", "¿Quieres {action} este cliente?", "Deseja {action} este cliente?"),
phrase("No clients found.", "No se encontraron clientes.", "Nenhum cliente encontrado."),
phrase("Client management", "Gestión de Clientes", "Gestão de clientes"),
+ phrase("Customer directory, addresses and commercial activity.", "Directorio de clientes, direcciones y actividad comercial.", "Diretório de clientes, endereços e atividade comercial."),
phrase("New client", "Nuevo cliente", "Novo cliente"),
phrase("View details", "Ver detalles", "Ver detalhes"),
phrase("Deactivate", "Desactivar", "Desativar"),
@@ -105,6 +108,14 @@ export const literalTranslations = [
phrase("Unable to save the client.", "No se pudo guardar el cliente.", "Não foi possível salvar o cliente."),
phrase("Unable to update the client status.", "No se pudo actualizar el estado del cliente.", "Não foi possível atualizar o status do cliente."),
phrase("Main address", "Dirección principal", "Endereço principal"),
+ phrase("Contact details and primary address.", "Datos de contacto y dirección principal.", "Dados de contato e endereço principal."),
+ phrase("Address", "Dirección", "Endereço"),
+ phrase("Label", "Etiqueta", "Etiqueta"),
+ phrase("City", "Ciudad", "Cidade"),
+ phrase("Postcode", "Código postal", "Código postal"),
+ phrase("Notes", "Notas", "Observações"),
+ phrase("Apartment, door or another reference", "Piso, puerta u otra referencia", "Apartamento, porta ou outra referência"),
+ phrase("The address is optional. If you start filling it in, complete the required fields.", "Es opcional. Si empiezas a rellenarla, completa los campos obligatorios.", "O endereço é opcional. Se começar a preenchê-lo, complete os campos obrigatórios."),
phrase("Preferences, context or useful client information", "Preferencias, contexto o información útil del cliente", "Preferências, contexto ou informações úteis do cliente"),
phrase("Loading address", "Cargando dirección", "Carregando endereço"),
phrase("Pagination", "Paginación", "Paginação"),
@@ -144,8 +155,10 @@ export const literalTranslations = [
phrase("Operational control of jobs, stages, materials and deliverables.", "Control operativo de encargos, etapas, materiales y entregables.", "Controle operacional de trabalhos, etapas, materiais e entregas."),
phrase("New job", "Nuevo Trabajo", "Novo trabalho"),
phrase("Pending", "Pendientes", "Pendentes"),
+ phrase("Pending", "Pendiente", "Pendente"),
phrase("In progress", "En curso", "Em andamento"),
phrase("Completed", "Completados", "Concluídos"),
+ phrase("Completed", "Completado", "Concluído"),
phrase("No jobs found", "No se encontraron trabajos", "Nenhum trabalho encontrado"),
phrase("Adjust the search filters or create a new job.", "Intenta ajustar los filtros de búsqueda o crea un nuevo trabajo.", "Ajuste os filtros de pesquisa ou crie um novo trabalho."),
phrase("Stage progress", "Progreso de etapas", "Progresso das etapas"),
@@ -165,6 +178,7 @@ export const literalTranslations = [
phrase("Network error while deleting the job.", "Error de red al intentar eliminar el trabajo.", "Erro de rede ao excluir o trabalho."),
phrase("General client", "Cliente general", "Cliente geral"),
phrase("Address not specified", "Dirección no especificada", "Endereço não especificado"),
+ phrase("Delivery:", "Entrega:", "Entrega:"),
phrase("E.g. Built-in wardrobe", "Ej. Fabricación de Armario Empotrado", "Ex.: Fabricação de armário embutido"),
phrase("Error loading data:", "Error cargando datos:", "Erro ao carregar dados:"),
phrase("Error creating the job:", "Error al crear el trabajo:", "Erro ao criar o trabalho:"),
@@ -255,6 +269,11 @@ export const literalTranslations = [
phrase("Take control before sending a message.", "Asume el control antes de enviar un mensaje.", "Assuma o controle antes de enviar uma mensagem."),
phrase("Conversation not found.", "No se encontró la conversación.", "Conversa não encontrada."),
phrase("Your subscription is inactive or expired.", "Tu suscripción está inactiva o ha caducado.", "Sua assinatura está inativa ou expirou."),
+ phrase("Manage and convert new opportunities.", "Gestiona y convierte nuevas oportunidades.", "Gerencie e converta novas oportunidades."),
+ phrase("Create new", "Crear nuevo", "Criar novo"),
+ phrase("Clear", "Limpiar", "Limpar"),
+ phrase("Status", "Estado", "Status"),
+ phrase("The contact authorized the processing of their data.", "El contacto autorizó el tratamiento de sus datos.", "O contato autorizou o tratamento dos seus dados."),
phrase("Client not found.", "No se encontró el cliente.", "Cliente não encontrado."),
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."),
diff --git a/src/front/pages/Agenda.jsx b/src/front/pages/Agenda.jsx
index 7f1fa45..e98d53b 100644
--- a/src/front/pages/Agenda.jsx
+++ b/src/front/pages/Agenda.jsx
@@ -108,7 +108,10 @@ export const Agenda = () => {
const handleDeleteAppointment = async (id) => {
if (busy || !window.confirm(ui.confirmCancelAppointment)) return;
setBusy(true);
- try { await cancelAppointment(options, id); setRevision(value => value + 1); }
+ try {
+ await cancelAppointment(options, id);
+ setAppointments(current => current.filter(item => item.id !== id));
+ }
catch (error) { setErrorMsg(error.message || ui.appointmentCancelError); }
finally { setBusy(false); }
};
diff --git a/src/front/pages/Agents.css b/src/front/pages/Agents.css
index f70c6d2..fea898c 100644
--- a/src/front/pages/Agents.css
+++ b/src/front/pages/Agents.css
@@ -1,58 +1,57 @@
-/* Scoped light appearance matches the existing Inbox workspace. */
+/* Scoped workspace appearance follows the shared light/dark theme. */
.agents-page {
- color-scheme: light;
- color: #252b40;
- background: #f7f7fc;
+ color: var(--cf-text);
+ background: var(--cf-workspace-bg);
min-height: calc(100vh - 48px);
padding: 32px;
- --bs-body-color: #252b40;
- --bs-body-bg: #ffffff;
- --bs-border-color: #e5e5f0;
+ --bs-body-color: var(--cf-text);
+ --bs-body-bg: var(--cf-surface);
+ --bs-border-color: var(--cf-border);
}
-.agents-page > h1 { font-size: 32px; font-weight: 650; letter-spacing: -1px; margin: 0 0 12px; color: #252b40; }
-.agents-page > p { color: #647087; max-width: 760px; line-height: 1.7; margin-bottom: 32px; }
-.agents-page h2 { font-size: 19px; font-weight: 650; color: #252b40; margin: 0 0 24px; }
+.agents-page > h1 { font-size: 32px; font-weight: 650; letter-spacing: -1px; margin: 0 0 12px; color: var(--cf-text); }
+.agents-page > p { color: var(--cf-text-muted); max-width: 760px; line-height: 1.7; margin-bottom: 32px; }
+.agents-page h2 { font-size: 19px; font-weight: 650; color: var(--cf-text); margin: 0 0 24px; }
.agents-page .row { align-items: flex-start; }
.agents-page form {
- background: #fff;
- border: 1px solid #e5e5f0;
+ background: var(--cf-surface);
+ border: 1px solid var(--cf-border);
border-radius: 18px;
padding: 28px;
box-shadow: 0 8px 30px #252b4005;
}
.agents-page .card {
- background: #fff;
- color: #252b40;
- border: 1px solid #e5e5f0;
+ background: var(--cf-surface);
+ color: var(--cf-text);
+ border: 1px solid var(--cf-border);
border-radius: 16px;
padding: 24px !important;
gap: 10px;
border-left: 4px solid #7859df;
}
.agents-page .card strong { font-size: 17px; overflow-wrap: anywhere; }
-.agents-page .card p { color: #647087; font-size: 14px; margin-bottom: 8px; }
-.agents-page label { font-size: 14px; font-weight: 550; color: #414b61; }
+.agents-page .card p { color: var(--cf-text-muted); font-size: 14px; margin-bottom: 8px; }
+.agents-page label { font-size: 14px; font-weight: 550; color: var(--cf-text); }
.agents-page form > label { margin-bottom: 20px !important; }
.agents-page .form-control {
- background: #fff;
- color: #252b40;
- border: 1px solid #d9deea;
+ background: var(--cf-surface-muted);
+ color: var(--cf-text);
+ border: 1px solid var(--cf-border);
border-radius: 10px;
padding: 12px 14px;
margin-top: 8px;
line-height: 1.6;
}
-.agents-page .form-control:focus { background: #fff; color: #252b40; border-color: #7859df; box-shadow: 0 0 0 3px #7859df22; }
+.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: #f8f7fd; border: 1px solid #e8e4f5; 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: #414b61; }
+.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 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: #647087; font-size: 14px; margin: 0; }
+.agents-page fieldset p { color: var(--cf-text-muted); font-size: 14px; margin: 0; }
.agents-page .btn { border-radius: 10px; padding: 11px 18px; font-size: 14px; font-weight: 600; }
.agents-page .btn-primary { background: #7357d9; border-color: #7357d9; color: #fff; }
.agents-page .btn-primary:hover { background: #6045bf; border-color: #6045bf; }
-.agents-page .btn-outline-primary { color: #6548c5; border-color: #d7cdef; background: #fff; }
+.agents-page .btn-outline-primary { color: #8b73e8; border-color: #7864bd; background: var(--cf-surface); }
.agents-page .btn-outline-primary:hover { color: #5135ae; background: #f0ebfc; }
.agents-page button:focus-visible { outline: 3px solid #aa94ed; outline-offset: 3px; }
.agents-page .btn:disabled { opacity: .55; }
diff --git a/src/front/pages/ClientDetail.jsx b/src/front/pages/ClientDetail.jsx
index 29d170a..9062247 100644
--- a/src/front/pages/ClientDetail.jsx
+++ b/src/front/pages/ClientDetail.jsx
@@ -126,7 +126,6 @@ export const ClientDetail = () => {
.toUpperCase() || "?"}
- ID: {client.id}
{fullName}
Cliente
diff --git a/src/front/pages/Clients.jsx b/src/front/pages/Clients.jsx
index 0a62380..a8c3a6e 100644
--- a/src/front/pages/Clients.jsx
+++ b/src/front/pages/Clients.jsx
@@ -418,7 +418,7 @@ export const Clients = () => {
{fullName}
- {translateLiteral("Cliente", locale)} #{client.id}
+ {translateLiteral("Cliente", locale)}
diff --git a/src/front/pages/Inbox.css b/src/front/pages/Inbox.css
index 59b0b84..fc71a86 100644
--- a/src/front/pages/Inbox.css
+++ b/src/front/pages/Inbox.css
@@ -466,3 +466,64 @@
grid-template-columns: 1fr;
}
}
+
+:root[data-bs-theme="dark"] .inbox-page {
+ color: var(--cf-text);
+ background: var(--cf-workspace-bg);
+}
+:root[data-bs-theme="dark"] .inbox-heading h1,
+:root[data-bs-theme="dark"] .inbox-conversation,
+:root[data-bs-theme="dark"] .inbox-details h2,
+:root[data-bs-theme="dark"] .inbox-empty-chat h2,
+:root[data-bs-theme="dark"] .inbox-modal,
+:root[data-bs-theme="dark"] .inbox-modal label {
+ color: var(--cf-text);
+}
+:root[data-bs-theme="dark"] .inbox-heading p,
+:root[data-bs-theme="dark"] .inbox-conversation-text small,
+:root[data-bs-theme="dark"] .inbox-chat-heading small,
+:root[data-bs-theme="dark"] .inbox-details > p,
+:root[data-bs-theme="dark"] .inbox-details dt,
+:root[data-bs-theme="dark"] .inbox-empty-list p,
+:root[data-bs-theme="dark"] .inbox-empty-chat p,
+:root[data-bs-theme="dark"] .inbox-note,
+:root[data-bs-theme="dark"] .inbox-modal label span {
+ color: var(--cf-text-muted);
+}
+:root[data-bs-theme="dark"] .inbox-workspace,
+:root[data-bs-theme="dark"] .inbox-chat-heading,
+:root[data-bs-theme="dark"] .inbox-message,
+:root[data-bs-theme="dark"] .inbox-composer,
+:root[data-bs-theme="dark"] .inbox-client-select,
+:root[data-bs-theme="dark"] .inbox-pagination button,
+:root[data-bs-theme="dark"] .inbox-control button,
+:root[data-bs-theme="dark"] .inbox-modal {
+ color: var(--cf-text);
+ background: var(--cf-surface);
+ border-color: var(--cf-border);
+}
+:root[data-bs-theme="dark"] .inbox-chat,
+:root[data-bs-theme="dark"] .inbox-secondary-action,
+:root[data-bs-theme="dark"] .inbox-composer textarea,
+:root[data-bs-theme="dark"] .inbox-modal input,
+:root[data-bs-theme="dark"] .inbox-modal select,
+:root[data-bs-theme="dark"] .inbox-modal textarea {
+ color: var(--cf-text);
+ background: var(--cf-surface-muted);
+ border-color: var(--cf-border);
+}
+:root[data-bs-theme="dark"] .inbox-conversations,
+:root[data-bs-theme="dark"] .inbox-chat-heading,
+:root[data-bs-theme="dark"] .inbox-composer,
+:root[data-bs-theme="dark"] .inbox-details,
+:root[data-bs-theme="dark"] .inbox-modal > header {
+ border-color: var(--cf-border);
+}
+:root[data-bs-theme="dark"] .inbox-conversation:hover,
+:root[data-bs-theme="dark"] .inbox-conversation.is-selected {
+ background: rgba(120, 89, 223, 0.2);
+}
+:root[data-bs-theme="dark"] .inbox-error {
+ color: #fecdd3;
+ background: rgba(159, 18, 57, 0.28);
+}
diff --git a/src/front/pages/JobDetail.jsx b/src/front/pages/JobDetail.jsx
index 220a6c9..2d20cac 100644
--- a/src/front/pages/JobDetail.jsx
+++ b/src/front/pages/JobDetail.jsx
@@ -12,6 +12,7 @@ export const JobDetail = () => {
const [job, setJob] = useState(location.state?.job || null);
const [loading, setLoading] = useState(!location.state?.job);
const [error, setError] = useState("");
+ const displayDate = (value) => value ? String(value).slice(0, 10) : translateLiteral("Por definir", locale);
// Helper para obtener la URL base limpia y el token de autenticación
const getBaseUrl = () => (import.meta.env.VITE_BACKEND_URL || "").replace(/\/$/, "");
@@ -176,7 +177,6 @@ export const JobDetail = () => {
-
ID: {job.id}
{job.title}
@@ -196,11 +196,11 @@ export const JobDetail = () => {
Fecha de inicio:
- {job.startDate || job.start_date || "Por definir"}
+ {displayDate(job.scheduled_start)}
Fecha de entrega:
- {job.dueDate || job.due_date || "Por definir"}
+ {displayDate(job.scheduled_end)}
Equipo asignado:
diff --git a/src/front/pages/Jobs.jsx b/src/front/pages/Jobs.jsx
index 74c1b66..42d75cc 100644
--- a/src/front/pages/Jobs.jsx
+++ b/src/front/pages/Jobs.jsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
-import { addDays, defaultJobSchedule } from "../utils/jobSchedule.mjs";
+import { addDays, defaultJobSchedule, jobDate, jobMatchesStatus } from "../utils/jobSchedule.mjs";
import { useLanguage } from "../context/LanguageContext";
import { translateLiteral } from "../i18n/literalTranslations.mjs";
@@ -140,7 +140,7 @@ export const Jobs = () => {
const matchesSearch = jobTitle.toLowerCase().includes(searchTerm.toLowerCase()) ||
clientName.toLowerCase().includes(searchTerm.toLowerCase());
- const matchesStatus = statusFilter === "all" || job.status === statusFilter;
+ const matchesStatus = jobMatchesStatus(job.status, statusFilter);
return matchesSearch && matchesStatus;
});
@@ -175,8 +175,8 @@ export const Jobs = () => {
client_id: newJob.client_id ? Number(newJob.client_id) : null,
quoted_amount: Number(newJob.budget),
description: `Obra en ${newJob.address}`,
- scheduled_start: `${newJob.startDate}T00:00:00`,
- scheduled_end: `${newJob.dueDate}T23:59:59`
+ scheduled_start: newJob.startDate,
+ scheduled_end: newJob.dueDate
})
});
@@ -295,7 +295,6 @@ export const Jobs = () => {
- ID: {job.id}
{job.title}
{getStatusBadge(job.status)}
@@ -331,7 +330,7 @@ export const Jobs = () => {
- Entrega: {job.scheduled_end ? job.scheduled_end.slice(0, 10) : "Por definir"}
+ Entrega: {jobDate(job.scheduled_end) || "Por definir"}
diff --git a/src/front/pages/Leads.jsx b/src/front/pages/Leads.jsx
index d162ca9..c7a3728 100644
--- a/src/front/pages/Leads.jsx
+++ b/src/front/pages/Leads.jsx
@@ -506,8 +506,11 @@ export const Leads = () => {
diff --git a/src/front/styles/knowledge.css b/src/front/styles/knowledge.css
index 43a0546..f49fbf6 100644
--- a/src/front/styles/knowledge.css
+++ b/src/front/styles/knowledge.css
@@ -1,38 +1,38 @@
-.knowledge-page{padding:36px;max-width:1600px;margin:auto;color:#202538}
+.knowledge-page{padding:36px;max-width:1600px;margin:auto;color:var(--cf-text)}
.knowledge-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:28px}
.knowledge-heading h1{font-size:32px;margin:8px 0}
-.knowledge-heading p{color:#7c879f}
+.knowledge-heading p{color:var(--cf-text-muted)}
.knowledge-eyebrow{color:#7357dc!important;letter-spacing:1.5px;font-size:12px;font-weight:700}
-.knowledge-page button{border:1px solid #e3e6ef;background:white;color:#534579;border-radius:9px;padding:9px 13px;cursor:pointer}
+.knowledge-page button{border:1px solid var(--cf-border);background:var(--cf-surface);color:var(--cf-text);border-radius:9px;padding:9px 13px;cursor:pointer}
.knowledge-page button:disabled{opacity:.45;cursor:not-allowed}
.knowledge-page button.knowledge-primary{background:#7357dc;color:#fff;border-color:#7357dc;padding:13px 19px}
-.knowledge-card,.knowledge-preview{background:white;border:1px solid #e7e9f2;border-radius:15px;overflow:hidden}
+.knowledge-card,.knowledge-preview{background:var(--cf-surface);border:1px solid var(--cf-border);border-radius:15px;overflow:hidden}
.knowledge-scroll{overflow-x:auto}
.knowledge-card table{width:100%;border-collapse:collapse;min-width:720px}
-.knowledge-card th{text-align:left;background:#fafbfe;color:#7c879f;font-size:12px;padding:17px 20px}
-.knowledge-card td{border-top:1px solid #edf0f6;padding:20px;vertical-align:middle}
-.knowledge-card small{display:block;color:#7c879f;margin-top:6px}
+.knowledge-card th{text-align:left;background:var(--cf-surface-muted);color:var(--cf-text-muted);font-size:12px;padding:17px 20px}
+.knowledge-card td{border-top:1px solid var(--cf-border);padding:20px;vertical-align:middle;color:var(--cf-text)}
+.knowledge-card small{display:block;color:var(--cf-text-muted);margin-top:6px}
.knowledge-actions{display:flex;gap:8px;flex-wrap:wrap}
.knowledge-status{font-size:12px;border-radius:20px;padding:6px 10px;background:#f0ecff;color:#7457cc;white-space:nowrap}
.knowledge-status.ready{background:#e4f8ef;color:#258767}
.knowledge-status.failed{background:#fdeaea;color:#b33d4b}
.knowledge-status.processing{background:#fff4d9;color:#947010}
-.knowledge-page footer{padding:18px 20px;border-top:1px solid #edf0f6;display:flex;justify-content:space-between;align-items:center;color:#7c879f}
+.knowledge-page footer{padding:18px 20px;border-top:1px solid var(--cf-border);display:flex;justify-content:space-between;align-items:center;color:var(--cf-text-muted)}
.knowledge-page footer div{display:flex;gap:16px;align-items:center}
-.knowledge-empty{padding:48px;text-align:center;color:#7c879f}
-.knowledge-empty h2{font-size:20px;color:#384259}
+.knowledge-empty{padding:48px;text-align:center;color:var(--cf-text-muted)}
+.knowledge-empty h2{font-size:20px;color:var(--cf-text)}
.knowledge-error,.knowledge-notice{padding:14px;border-radius:10px;background:#fdeaea;margin-bottom:20px}
.knowledge-notice{background:#e4f8ef}
.knowledge-failure{max-width:400px;color:#ae3b4b;font-size:13px;margin:8px 0}
-.knowledge-dialog{border:1px solid #e7e9f2;border-radius:16px;padding:28px;width:min(520px,92vw);color:#202538}
+.knowledge-dialog{border:1px solid var(--cf-border);border-radius:16px;padding:28px;width:min(520px,92vw);color:var(--cf-text);background:var(--cf-surface)}
.knowledge-dialog::backdrop{background:#15182c80}
.knowledge-dialog label{display:block;margin:18px 0}
-.knowledge-dialog input{display:block;width:100%;padding:10px;border:1px solid #dce0ed;border-radius:8px;margin-top:6px}
+.knowledge-dialog input{display:block;width:100%;padding:10px;border:1px solid var(--cf-border);border-radius:8px;margin-top:6px;background:var(--cf-surface-muted);color:var(--cf-text)}
.knowledge-preview{padding:24px;margin-top:24px}
.knowledge-preview header{display:flex;justify-content:space-between;gap:16px}
-.knowledge-preview article{border-top:1px solid #edf0f6;padding:18px 0}
+.knowledge-preview article{border-top:1px solid var(--cf-border);padding:18px 0}
.knowledge-preview article p{white-space:pre-wrap;overflow-wrap:anywhere}
-.knowledge-preview small{color:#7c879f}
+.knowledge-preview small{color:var(--cf-text-muted)}
@media(max-width:700px){.knowledge-page{padding:18px}
.knowledge-heading{align-items:flex-start;flex-direction:column}
.knowledge-page footer{gap:12px;flex-wrap:wrap}
diff --git a/src/front/styles/members.css b/src/front/styles/members.css
index a5cc595..d73ebbe 100644
--- a/src/front/styles/members.css
+++ b/src/front/styles/members.css
@@ -1 +1,28 @@
.team-page{color:#22283b;padding:20px 12px;max-width:1700px;margin:auto;font-size:14px}.team-heading{display:flex;justify-content:space-between;align-items:center;gap:24px;margin-bottom:30px}.team-heading h1{font-size:32px;letter-spacing:-1px;margin:12px 0}.team-heading p{color:#8590aa;margin-bottom:0}.team-heading .team-eyebrow{font-size:11px;letter-spacing:1.5px;color:#795bee;font-weight:700}.team-page button,.team-page input,.team-page select{font:inherit}.team-primary{background:#7256df;color:white;border:0;border-radius:11px;padding:14px 22px;box-shadow:0 6px 16px #7256df24;font-weight:600!important}.team-page button:disabled{opacity:.5;cursor:not-allowed}.team-page button:focus-visible,.team-page input:focus-visible,.team-page select:focus-visible{outline:3px solid #a695ef;outline-offset:3px}.team-filters{display:flex;gap:12px;margin-bottom:18px}.team-filters input,.team-filters select{border:1px solid #e5e7f0;border-radius:10px;padding:14px;background:white;color:#58647d}.team-filters input{flex:1;min-width:0}.team-filters input::placeholder{color:#909ab0}.team-card{background:white;border:1px solid #e5e7f0;border-radius:14px;box-shadow:0 4px 16px #26344a03;overflow:hidden}.team-table-scroll{overflow-x:auto}.team-table{width:100%;border-collapse:collapse;white-space:nowrap}.team-table th{font-size:11px;letter-spacing:.7px;background:#fafbfe;color:#8792aa;text-align:left;padding:18px 22px}.team-table td{padding:20px 22px;border-top:1px solid #edf0f6;color:#758098;font-size:13px}.team-person{display:flex;gap:13px;align-items:center}.team-person strong{color:#293147;font-weight:600}.team-avatar{display:grid;place-items:center;min-width:36px;height:36px;border-radius:10px;background:#eee9ff;color:#795bea;font-weight:700}.team-badge{display:inline-block;padding:6px 11px;border-radius:20px;font-size:11px;font-weight:600}.team-badge.active{background:#e4f7ef;color:#287e64}.team-badge.inactive{background:#f0f1f5;color:#697286}.team-more{background:none;border:0;color:#8490a7;padding:8px;letter-spacing:2px}.team-pagination{display:flex;align-items:center;justify-content:space-between;padding:17px 22px;border-top:1px solid #edf0f6;color:#8a94aa;font-size:12px}.team-pagination button{background:white;border:0;color:#7256df;font-size:22px;margin:0 10px}.team-empty{padding:36px;text-align:center;color:#758098}.team-notice{background:#eee9ff;padding:14px;border-radius:10px;color:#53458a}.team-dialog{border:1px solid #e5e7f0;border-radius:16px;padding:30px;width:min(480px,calc(100% - 32px));box-shadow:0 24px 80px #22283b33;color:#293147}.team-dialog::backdrop{background:#171c3266}.team-dialog h2{font-size:24px}.team-dialog p{color:#758098}.team-dialog label{display:block;margin:18px 0;font-size:13px}.team-dialog input,.team-dialog select{display:block;width:100%;padding:12px;border:1px solid #dce0eb;border-radius:8px;margin-top:7px}.team-dialog-actions{display:flex;justify-content:flex-end;gap:12px;margin-top:26px}.team-dialog-actions button:not(.team-primary){background:white;border:1px solid #dce0eb;border-radius:9px;padding:10px 15px}.invite-page{min-height:100vh;display:grid;place-items:center;background:#f6f7fb;padding:24px}.invite-panel{background:white;border:1px solid #e5e7f0;border-radius:16px;padding:32px;width:min(100%,480px)}.invite-panel label{display:block;margin:14px 0}.invite-panel input{width:100%;padding:11px;border:1px solid #dce0eb;border-radius:8px;margin-top:6px}.invite-panel h1{font-size:27px}.invite-panel button{margin-top:12px}.invite-panel .invite-toggle{background:none;border:0;color:#7256df;text-decoration:underline}.invite-panel .team-primary{width:100%}@media(max-width:600px){.team-page{padding:10px 0}.team-heading{align-items:flex-start;flex-direction:column}.team-filters{flex-direction:column}.team-heading h1{font-size:28px}.team-table td,.team-table th{padding:15px}.team-dialog{padding:22px}}
+
+:root[data-bs-theme="dark"] .team-page { color: var(--cf-text); }
+:root[data-bs-theme="dark"] .team-heading p,
+:root[data-bs-theme="dark"] .team-table td,
+:root[data-bs-theme="dark"] .team-pagination,
+:root[data-bs-theme="dark"] .team-empty,
+:root[data-bs-theme="dark"] .team-dialog p { color: var(--cf-text-muted); }
+:root[data-bs-theme="dark"] .team-filters input,
+:root[data-bs-theme="dark"] .team-filters select,
+:root[data-bs-theme="dark"] .team-card,
+:root[data-bs-theme="dark"] .team-pagination button,
+:root[data-bs-theme="dark"] .team-dialog,
+:root[data-bs-theme="dark"] .team-dialog input,
+:root[data-bs-theme="dark"] .team-dialog select,
+:root[data-bs-theme="dark"] .team-dialog-actions button:not(.team-primary) {
+ color: var(--cf-text);
+ background: var(--cf-surface);
+ border-color: var(--cf-border);
+}
+:root[data-bs-theme="dark"] .team-table th {
+ color: var(--cf-text-muted);
+ background: var(--cf-surface-muted);
+}
+:root[data-bs-theme="dark"] .team-table td,
+:root[data-bs-theme="dark"] .team-pagination { border-color: var(--cf-border); }
+:root[data-bs-theme="dark"] .team-person strong { color: var(--cf-text); }
+:root[data-bs-theme="dark"] .team-notice { color: #ddd6fe; background: rgba(114, 86, 223, .2); }
diff --git a/src/front/utils/jobSchedule.mjs b/src/front/utils/jobSchedule.mjs
index cab625c..72a7064 100644
--- a/src/front/utils/jobSchedule.mjs
+++ b/src/front/utils/jobSchedule.mjs
@@ -15,3 +15,14 @@ export const defaultJobSchedule = (today = new Date()) => {
const startDate = localDateValue(today);
return { startDate, dueDate: addDays(startDate, 30) };
};
+
+export const jobMatchesStatus = (jobStatus, selectedStatus) => {
+ if (selectedStatus === "all") return true;
+ const normalized = String(jobStatus || "").toLowerCase();
+ if (selectedStatus === "pending") {
+ return ["draft", "scheduled", "review"].includes(normalized);
+ }
+ return normalized === selectedStatus;
+};
+
+export const jobDate = (value) => value ? String(value).slice(0, 10) : "";
diff --git a/tests/frontend/i18n.test.mjs b/tests/frontend/i18n.test.mjs
index fcfb579..25b15e2 100644
--- a/tests/frontend/i18n.test.mjs
+++ b/tests/frontend/i18n.test.mjs
@@ -47,3 +47,18 @@ test("language preference is persisted and the document language is updated", ()
assert.match(source, /document\.documentElement\.lang = locale/);
assert.match(source, /MutationObserver/);
});
+
+test("core operational screens have English and Portuguese translations", () => {
+ const phrases = {
+ "Gestión de Clientes": ["Client management", "Gestão de clientes"],
+ "Gestiona y convierte nuevas oportunidades.": ["Manage and convert new opportunities.", "Gerencie e converta novas oportunidades."],
+ "Gestión de Trabajos": ["Job management", "Gestão de trabalhos"],
+ "Datos de contacto y dirección principal.": ["Contact details and primary address.", "Dados de contato e endereço principal."],
+ "Pendiente": ["Pending", "Pendente"],
+ };
+
+ for (const [spanish, [english, portuguese]] of Object.entries(phrases)) {
+ assert.equal(translateLiteral(spanish, "en"), english);
+ assert.equal(translateLiteral(spanish, "pt"), portuguese);
+ }
+});
diff --git a/tests/frontend/jobSchedule.test.mjs b/tests/frontend/jobSchedule.test.mjs
index 03d5384..8d4e4c2 100644
--- a/tests/frontend/jobSchedule.test.mjs
+++ b/tests/frontend/jobSchedule.test.mjs
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { addDays, defaultJobSchedule } from "../../src/front/utils/jobSchedule.mjs";
+import { addDays, defaultJobSchedule, jobDate, jobMatchesStatus } from "../../src/front/utils/jobSchedule.mjs";
test("new jobs start today and default to delivery thirty days later", () => {
assert.deepEqual(
@@ -13,3 +13,14 @@ test("new jobs start today and default to delivery thirty days later", () => {
test("job schedules cross month and year boundaries using local dates", () => {
assert.equal(addDays("2026-12-15", 30), "2027-01-14");
});
+
+test("pending job filter includes the draft status used for new jobs", () => {
+ assert.equal(jobMatchesStatus("draft", "pending"), true);
+ assert.equal(jobMatchesStatus("scheduled", "pending"), true);
+ assert.equal(jobMatchesStatus("in_progress", "pending"), false);
+});
+
+test("job dates display the calendar day returned by the API", () => {
+ assert.equal(jobDate("2026-10-05T00:00:00"), "2026-10-05");
+ assert.equal(jobDate(null), "");
+});
diff --git a/tests/frontend/productionQa.test.mjs b/tests/frontend/productionQa.test.mjs
new file mode 100644
index 0000000..8c42c15
--- /dev/null
+++ b/tests/frontend/productionQa.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const source = (path) => readFileSync(new URL(`../../${path}`, import.meta.url), "utf8");
+
+test("registration does not suggest a developer name", () => {
+ const register = source("src/front/pages/Register.jsx");
+ assert.doesNotMatch(register, /placeholder="Carlos"/);
+ assert.doesNotMatch(register, /placeholder="Alberto"/);
+});
+
+test("internal job and client identifiers are not rendered", () => {
+ const jobs = source("src/front/pages/Jobs.jsx");
+ const jobDetail = source("src/front/pages/JobDetail.jsx");
+ const clients = source("src/front/pages/Clients.jsx");
+ const clientDetail = source("src/front/pages/ClientDetail.jsx");
+ for (const page of [jobs, jobDetail, clients, clientDetail]) {
+ assert.doesNotMatch(page, />ID:\s*\{/);
+ }
+ assert.doesNotMatch(clients, /Cliente", locale\)} #\{client\.id\}/);
+});
+
+test("knowledge page uses theme tokens for dark mode", () => {
+ const css = source("src/front/styles/knowledge.css");
+ assert.match(css, /color:var\(--cf-text\)/);
+ assert.match(css, /background:var\(--cf-surface\)/);
+ assert.match(css, /border:1px solid var\(--cf-border\)/);
+});
+
+test("custom workspaces include readable dark theme rules", () => {
+ const agents = source("src/front/pages/Agents.css");
+ const inbox = source("src/front/pages/Inbox.css");
+ const members = source("src/front/styles/members.css");
+ assert.doesNotMatch(agents, /color-scheme:\s*light/);
+ assert.match(agents, /background: var\(--cf-workspace-bg\)/);
+ assert.match(inbox, /data-bs-theme="dark"/);
+ assert.match(members, /data-bs-theme="dark"/);
+});
+
+test("lead search icon has a centered fixed hit area", () => {
+ const leads = source("src/front/pages/Leads.jsx");
+ assert.match(leads, /justify-content-center p-0/);
+ assert.match(leads, /minWidth: "2\.75rem"/);
+});
+
+test("job scheduling uses API field names without timezone shifts", () => {
+ const jobs = source("src/front/pages/Jobs.jsx");
+ const jobDetail = source("src/front/pages/JobDetail.jsx");
+ assert.match(jobs, /scheduled_start: newJob\.startDate/);
+ assert.match(jobs, /scheduled_end: newJob\.dueDate/);
+ assert.match(jobDetail, /displayDate\(job\.scheduled_start\)/);
+ assert.match(jobDetail, /displayDate\(job\.scheduled_end\)/);
+});
diff --git a/tests/test_appointments.py b/tests/test_appointments.py
index 3867320..d20e782 100644
--- a/tests/test_appointments.py
+++ b/tests/test_appointments.py
@@ -50,6 +50,8 @@ def test_create_move_cancel_and_conflict(self):
self.assertEqual(response.status_code, 200, response.json)
self.assertEqual(self.create().status_code, 201)
self.assertEqual(self.client.delete(f'/api/appointments/{identifier}', headers=self.auth).status_code, 200)
+ visible_ids = [item['id'] for item in self.client.get('/api/appointments', headers=self.auth).json]
+ self.assertNotIn(identifier, visible_ids)
def test_validation_and_tenant_options(self):
for changes in [{'client_id': self.other_client}, {'ends_at': 'bad'}, {'ends_at': self.data['starts_at']}, {'status': 'unknown'}, {'assigned_membership_id': True}]: