feat(activos): anadir directorio de personas del cliente y resolver fondos oscuros en inputs

This commit is contained in:
Marcos Fargas
2026-10-15 03:02:58 +02:00
parent 4f8ea26938
commit 102c100427
7 changed files with 1085 additions and 18 deletions

View File

@@ -0,0 +1,359 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createClient as createAdminClient } from '@supabase/supabase-js';
import { isAgencyRole, supabaseUrl, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { randomUUID } from 'crypto';
export const dynamic = 'force-dynamic';
export type ContactMethod = 'Email' | 'Grupo WhatsApp' | 'Teams';
export interface ClientContact {
id: string;
name: string;
role: string;
email: string;
phone?: string | null;
contact_methods: ContactMethod[];
notes?: string | null;
created_at: string;
updated_at?: string;
created_by?: string;
}
const DOC_TYPE = 'client_contacts';
function getAdmin() {
const url = supabaseUrl();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createAdminClient(url, key);
}
async function gate(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const apiKey = req.headers.get('x-api-key');
const validApiKey = process.env.CRON_SECRET || '4v-cron-sec-2026-mkt';
const isApiAuth = Boolean(apiKey && apiKey === validApiKey);
if (!user && !isApiAuth) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
const queryTenant =
req.nextUrl.searchParams.get('tenant_id') ||
req.nextUrl.searchParams.get('tenantId');
const role = user ? userRole(user) : 'admin';
const isAgency = user ? isAgencyRole(role) : true;
let tenantId: string | null = null;
let label = 'Cliente';
if ((isAgency || isApiAuth) && queryTenant) {
tenantId = queryTenant;
const admin = getAdmin();
const { data: t } = await admin
.from('tenants')
.select('name')
.eq('id', queryTenant)
.maybeSingle();
if (t?.name) label = t.name;
} else if (user) {
const ctx = await resolveActiveTenant(user);
if (ctx.portfolioWide || !ctx.tenantId) {
return {
error: NextResponse.json(
{ error: 'Selecciona un cliente en Contexto antes de gestionar el directorio.' },
{ status: 400 }
),
};
}
tenantId = ctx.tenantId;
label = ctx.label;
}
if (!tenantId) {
return {
error: NextResponse.json(
{ error: 'No se pudo identificar el tenant objetivo.' },
{ status: 400 }
),
};
}
return { user, tenantId, label, isAgency };
}
/**
* GET /api/assets/contacts
* Lista las personas del directorio del cliente activo.
*/
export async function GET(req: NextRequest) {
const g = await gate(req);
if ('error' in g) return g.error;
const admin = getAdmin();
const { data, error } = await admin
.from('documents')
.select('id, content, updated_at')
.eq('tenant_id', g.tenantId)
.eq('type', DOC_TYPE)
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
let contacts: ClientContact[] = [];
if (data?.content) {
try {
contacts = JSON.parse(data.content);
} catch {
contacts = [];
}
}
return NextResponse.json({ contacts, label: g.label });
}
/**
* POST /api/assets/contacts
* Añade una nueva persona al directorio del cliente.
*/
export async function POST(req: NextRequest) {
const g = await gate(req);
if ('error' in g) return g.error;
const body = await req.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: 'Cuerpo de petición inválido' }, { status: 400 });
}
const { name, role, email, phone, contact_methods, notes } = body;
if (!name || typeof name !== 'string' || !name.trim()) {
return NextResponse.json({ error: 'El Nombre y Apellido es obligatorio.' }, { status: 400 });
}
if (!role || typeof role !== 'string' || !role.trim()) {
return NextResponse.json({ error: 'El Cargo es obligatorio.' }, { status: 400 });
}
if (!email || typeof email !== 'string' || !email.trim()) {
return NextResponse.json({ error: 'El Email es obligatorio.' }, { status: 400 });
}
const validMethods: ContactMethod[] = ['Email', 'Grupo WhatsApp', 'Teams'];
const methods: ContactMethod[] = Array.isArray(contact_methods)
? contact_methods.filter((m: any): m is ContactMethod => validMethods.includes(m))
: [];
const admin = getAdmin();
// Obtener registro existente
const { data: existingDoc } = await admin
.from('documents')
.select('id, content')
.eq('tenant_id', g.tenantId)
.eq('type', DOC_TYPE)
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
let contacts: ClientContact[] = [];
if (existingDoc?.content) {
try {
contacts = JSON.parse(existingDoc.content);
} catch {
contacts = [];
}
}
const now = new Date().toISOString();
const newContact: ClientContact = {
id: randomUUID(),
name: name.trim(),
role: role.trim(),
email: email.trim().toLowerCase(),
phone: phone && typeof phone === 'string' && phone.trim() ? phone.trim() : null,
contact_methods: methods.length > 0 ? methods : ['Email'],
notes: notes && typeof notes === 'string' && notes.trim() ? notes.trim() : null,
created_at: now,
updated_at: now,
created_by: g.user?.email || (g.isAgency ? 'Agencia' : 'Cliente'),
};
contacts.unshift(newContact);
if (existingDoc) {
const { error: updateErr } = await admin
.from('documents')
.update({
content: JSON.stringify(contacts),
title: 'Directorio de Personas del Cliente',
updated_at: now,
})
.eq('id', existingDoc.id);
if (updateErr) {
return NextResponse.json({ error: updateErr.message }, { status: 500 });
}
} else {
const { error: insertErr } = await admin
.from('documents')
.insert({
tenant_id: g.tenantId,
type: DOC_TYPE,
title: 'Directorio de Personas del Cliente',
content: JSON.stringify(contacts),
created_at: now,
updated_at: now,
});
if (insertErr) {
return NextResponse.json({ error: insertErr.message }, { status: 500 });
}
}
return NextResponse.json({ ok: true, contact: newContact, contacts });
}
/**
* PUT /api/assets/contacts
* Actualiza una persona existente.
*/
export async function PUT(req: NextRequest) {
const g = await gate(req);
if ('error' in g) return g.error;
const body = await req.json().catch(() => null);
if (!body || !body.id) {
return NextResponse.json({ error: 'ID de persona obligatorio.' }, { status: 400 });
}
const { id, name, role, email, phone, contact_methods, notes } = body;
if (!name || typeof name !== 'string' || !name.trim()) {
return NextResponse.json({ error: 'El Nombre y Apellido es obligatorio.' }, { status: 400 });
}
if (!role || typeof role !== 'string' || !role.trim()) {
return NextResponse.json({ error: 'El Cargo es obligatorio.' }, { status: 400 });
}
if (!email || typeof email !== 'string' || !email.trim()) {
return NextResponse.json({ error: 'El Email es obligatorio.' }, { status: 400 });
}
const validMethods: ContactMethod[] = ['Email', 'Grupo WhatsApp', 'Teams'];
const methods: ContactMethod[] = Array.isArray(contact_methods)
? contact_methods.filter((m: any): m is ContactMethod => validMethods.includes(m))
: [];
const admin = getAdmin();
const { data: existingDoc } = await admin
.from('documents')
.select('id, content')
.eq('tenant_id', g.tenantId)
.eq('type', DOC_TYPE)
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (!existingDoc?.content) {
return NextResponse.json({ error: 'No se encontró el directorio de contactos' }, { status: 404 });
}
let contacts: ClientContact[] = [];
try {
contacts = JSON.parse(existingDoc.content);
} catch {
contacts = [];
}
const idx = contacts.findIndex((c) => c.id === id);
if (idx === -1) {
return NextResponse.json({ error: 'Contacto no encontrado' }, { status: 404 });
}
const now = new Date().toISOString();
contacts[idx] = {
...contacts[idx],
name: name.trim(),
role: role.trim(),
email: email.trim().toLowerCase(),
phone: phone && typeof phone === 'string' && phone.trim() ? phone.trim() : null,
contact_methods: methods.length > 0 ? methods : ['Email'],
notes: notes && typeof notes === 'string' && notes.trim() ? notes.trim() : null,
updated_at: now,
};
const { error: updateErr } = await admin
.from('documents')
.update({
content: JSON.stringify(contacts),
updated_at: now,
})
.eq('id', existingDoc.id);
if (updateErr) {
return NextResponse.json({ error: updateErr.message }, { status: 500 });
}
return NextResponse.json({ ok: true, contact: contacts[idx], contacts });
}
/**
* DELETE /api/assets/contacts?id=...
* Elimina una persona del directorio.
*/
export async function DELETE(req: NextRequest) {
const g = await gate(req);
if ('error' in g) return g.error;
const url = new URL(req.url);
const id = url.searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'ID de persona obligatorio.' }, { status: 400 });
}
const admin = getAdmin();
const { data: existingDoc } = await admin
.from('documents')
.select('id, content')
.eq('tenant_id', g.tenantId)
.eq('type', DOC_TYPE)
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (!existingDoc?.content) {
return NextResponse.json({ error: 'No se encontraron contactos.' }, { status: 404 });
}
let contacts: ClientContact[] = [];
try {
contacts = JSON.parse(existingDoc.content);
} catch {
contacts = [];
}
const nextContacts = contacts.filter((c) => c.id !== id);
const { error: updateErr } = await admin
.from('documents')
.update({
content: JSON.stringify(nextContacts),
updated_at: new Date().toISOString(),
})
.eq('id', existingDoc.id);
if (updateErr) {
return NextResponse.json({ error: updateErr.message }, { status: 500 });
}
return NextResponse.json({ ok: true, contacts: nextContacts });
}

View File

@@ -340,7 +340,7 @@ export default function CalendarClient({
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="bg-gray-900 border border-gray-200 text-xs text-gray-900 rounded-lg px-2.5 py-1.5 focus:outline-none focus:border-brand-500"
className="bg-white border border-gray-300 text-xs text-gray-900 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
>
<option value="all">Todos</option>
<option value="approved">Aprobados</option>
@@ -476,7 +476,7 @@ export default function CalendarClient({
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Escribe un comentario…"
className="flex-1 rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-xs text-gray-900 placeholder:text-gray-600 focus:outline-none focus:border-brand-500/50"
className="flex-1 rounded-lg bg-white border border-gray-300 px-3 py-2 text-xs text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
/>
<button
type="button"
@@ -546,11 +546,11 @@ function Block({
}) {
return (
<div>
<label className="text-xs uppercase tracking-wider font-bold text-gray-400 block mb-1">
<label className="text-xs uppercase tracking-wider font-bold text-gray-500 block mb-1">
{label}
</label>
<div
className={`p-3.5 rounded-xl bg-gray-900 border border-gray-200 whitespace-pre-wrap ${className}`}
className={`p-3.5 rounded-xl bg-gray-50 border border-gray-200 text-gray-800 text-xs whitespace-pre-wrap ${className}`}
>
{children}
</div>

View File

@@ -38,3 +38,25 @@ body {
text-wrap: balance;
}
}
input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]),
select,
textarea {
color-scheme: light !important;
color: #111827;
}
/* Evitar que el autofill del navegador pinte campos en oscuro */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
-webkit-text-fill-color: #111827 !important;
-webkit-box-shadow: 0 0 0px 1000px #ffffff inset !important;
transition: background-color 5000s ease-in-out 0s;
}

View File

@@ -254,7 +254,7 @@ export default function AdsDiscoverPanel() {
<input
type="password"
autoComplete="off"
className="flex-1 text-xs rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-gray-900 placeholder:text-gray-600"
className="flex-1 text-xs rounded-lg bg-white border border-gray-300 px-3 py-2 text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
placeholder="EAAG… system user token"
value={metaToken}
onChange={(e) => setMetaToken(e.target.value)}

View File

@@ -21,10 +21,17 @@ import {
Tag,
X,
Share2,
Users,
Phone,
Mail,
MessageCircle,
MessageSquare,
Briefcase,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
import type { ClientUrlAsset } from '@/app/api/assets/urls/route';
import type { ClientContact, ContactMethod } from '@/app/api/assets/contacts/route';
type Asset = {
id: string;
@@ -104,7 +111,7 @@ export default function AssetsLibrary({
tenantLabel: string;
isAgency?: boolean;
}) {
const [tab, setTab] = useState<'portal' | 'drive' | 'urls'>('drive');
const [tab, setTab] = useState<'portal' | 'drive' | 'urls' | 'contacts'>('drive');
const [assets, setAssets] = useState<Asset[]>([]);
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
const [driveFolder, setDriveFolder] = useState<string | null>(null);
@@ -143,6 +150,23 @@ export default function AssetsLibrary({
const [urlSearch, setUrlSearch] = useState('');
const [urlFilterCategory, setUrlFilterCategory] = useState<string>('Todas');
// Directorio de Personas del Cliente
const [contacts, setContacts] = useState<ClientContact[]>([]);
const [contactsLoading, setContactsLoading] = useState(false);
const [showContactForm, setShowContactForm] = useState(false);
const [editingContactId, setEditingContactId] = useState<string | null>(null);
const [contactName, setContactName] = useState('');
const [contactRole, setContactRole] = useState('');
const [contactEmail, setContactEmail] = useState('');
const [contactPhone, setContactPhone] = useState('');
const [contactMethods, setContactMethods] = useState<ContactMethod[]>(['Email']);
const [contactNotes, setContactNotes] = useState('');
const [savingContact, setSavingContact] = useState(false);
const [contactError, setContactError] = useState<string | null>(null);
const [contactSuccess, setContactSuccess] = useState<string | null>(null);
const [contactSearch, setContactSearch] = useState('');
const [contactMethodFilter, setContactMethodFilter] = useState<string>('Todos');
const loadUrls = useCallback(async () => {
setUrlsLoading(true);
try {
@@ -158,6 +182,21 @@ export default function AssetsLibrary({
}
}, []);
const loadContacts = useCallback(async () => {
setContactsLoading(true);
try {
const res = await fetch('/api/assets/contacts');
const data = await res.json();
if (res.ok && data.contacts) {
setContacts(data.contacts);
}
} catch {
// Ignorar error de red silencioso
} finally {
setContactsLoading(false);
}
}, []);
const loadPortal = useCallback(async () => {
const res = await fetch('/api/assets');
const data = await res.json();
@@ -193,12 +232,13 @@ export default function AssetsLibrary({
setErr(null);
if (tab === 'portal') await loadPortal();
else if (tab === 'urls') await loadUrls();
else if (tab === 'contacts') await loadContacts();
else {
const current = drivePath[drivePath.length - 1]?.id;
await loadDrive(current);
}
setLoading(false);
}, [tab, loadPortal, loadDrive, loadUrls, drivePath]);
}, [tab, loadPortal, loadDrive, loadUrls, loadContacts, drivePath]);
useEffect(() => {
void load();
@@ -206,7 +246,8 @@ export default function AssetsLibrary({
useEffect(() => {
void loadUrls();
}, [loadUrls]);
void loadContacts();
}, [loadUrls, loadContacts]);
const folders = useMemo(() => {
const s = new Set<string>();
@@ -423,6 +464,128 @@ export default function AssetsLibrary({
setUrlError(null);
}
const ALL_CONTACT_METHODS: ContactMethod[] = ['Email', 'Grupo WhatsApp', 'Teams'];
function toggleContactMethod(method: ContactMethod) {
setContactMethods((prev) =>
prev.includes(method) ? prev.filter((m) => m !== method) : [...prev, method]
);
}
async function handleSaveContact(e: React.FormEvent) {
e.preventDefault();
if (!contactName.trim() || !contactRole.trim() || !contactEmail.trim()) {
setContactError('Nombre y Apellido, Cargo y Email son obligatorios.');
return;
}
if (contactMethods.length === 0) {
setContactError('Selecciona al menos un método de contacto preferido.');
return;
}
setSavingContact(true);
setContactError(null);
setContactSuccess(null);
try {
const isEdit = Boolean(editingContactId);
const method = isEdit ? 'PUT' : 'POST';
const body = {
id: editingContactId || undefined,
name: contactName.trim(),
role: contactRole.trim(),
email: contactEmail.trim(),
phone: contactPhone.trim() || undefined,
contact_methods: contactMethods,
notes: contactNotes.trim() || undefined,
};
const res = await fetch('/api/assets/contacts', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'No se pudo guardar la persona');
}
setContacts(data.contacts || []);
setContactSuccess(isEdit ? 'Persona actualizada con éxito' : 'Persona añadida al directorio');
handleCancelContactEdit();
setTimeout(() => setContactSuccess(null), 3000);
} catch (err: any) {
setContactError(err.message || 'Error al guardar');
} finally {
setSavingContact(false);
}
}
async function handleDeleteContact(id: string) {
if (!confirm('¿Seguro que deseas eliminar a esta persona del directorio del cliente?')) return;
try {
const res = await fetch(`/api/assets/contacts?id=${encodeURIComponent(id)}`, {
method: 'DELETE',
});
const data = await res.json();
if (res.ok && data.contacts) {
setContacts(data.contacts);
}
} catch (err: any) {
setContactError(err.message || 'Error al eliminar');
}
}
function handleEditContact(item: ClientContact) {
setEditingContactId(item.id);
setContactName(item.name);
setContactRole(item.role);
setContactEmail(item.email);
setContactPhone(item.phone || '');
setContactMethods(item.contact_methods || ['Email']);
setContactNotes(item.notes || '');
setShowContactForm(true);
setContactError(null);
}
function handleCancelContactEdit() {
setEditingContactId(null);
setContactName('');
setContactRole('');
setContactEmail('');
setContactPhone('');
setContactMethods(['Email']);
setContactNotes('');
setShowContactForm(false);
setContactError(null);
}
const filteredContacts = useMemo(() => {
return contacts.filter((item) => {
const q = contactSearch.trim().toLowerCase();
const matchesSearch =
!q ||
item.name.toLowerCase().includes(q) ||
item.role.toLowerCase().includes(q) ||
item.email.toLowerCase().includes(q) ||
(item.phone && item.phone.toLowerCase().includes(q)) ||
(item.notes && item.notes.toLowerCase().includes(q));
const matchesMethod =
contactMethodFilter === 'Todos' ||
(item.contact_methods && item.contact_methods.includes(contactMethodFilter as ContactMethod));
return matchesSearch && matchesMethod;
});
}, [contacts, contactSearch, contactMethodFilter]);
const contactMethodCounts = useMemo(() => {
const map: Record<string, number> = { Todos: contacts.length };
for (const m of ALL_CONTACT_METHODS) {
map[m] = contacts.filter((c) => c.contact_methods?.includes(m)).length;
}
return map;
}, [contacts]);
return (
<div className="space-y-6">
<PageHeader
@@ -430,8 +593,8 @@ export default function AssetsLibrary({
title="Activos"
description={
isAgency
? `Réplica completa de Drive · carpeta «${tenantLabel}» (bajo Clientes) + subidas del portal + URLs de referencia.`
: `Archivos y enlaces del Centro de Mando para «${tenantLabel}»: creatividades, calendarios, URLs estratégicas y carpetas de la app.`
? `Réplica completa de Drive · carpeta «${tenantLabel}» (bajo Clientes) + subidas del portal + URLs de referencia + directorio del equipo.`
: `Centro de Mando para «${tenantLabel}»: creatividades, calendarios, URLs estratégicas, directorio de personas y archivos en la nube.`
}
actions={
isAgency ? <SyncNowButton forceModule="activos" /> : undefined
@@ -474,13 +637,30 @@ export default function AssetsLibrary({
}`}
>
<Globe className="w-3.5 h-3.5" />
URLs y Enlaces del Cliente
URLs y Enlaces
{clientUrls.length > 0 && (
<span className="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-brand-100 text-brand-700 font-bold">
{clientUrls.length}
</span>
)}
</button>
<button
type="button"
onClick={() => setTab('contacts')}
className={`inline-flex items-center gap-1.5 text-xs px-3.5 py-2 rounded-xl border font-medium transition-all cursor-pointer ${
tab === 'contacts'
? 'border-brand-500/40 text-brand-600 bg-brand-50 shadow-xs'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<Users className="w-3.5 h-3.5" />
Directorio de Personas
{contacts.length > 0 && (
<span className="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-brand-100 text-brand-700 font-bold">
{contacts.length}
</span>
)}
</button>
</div>
{warning && (
@@ -1133,6 +1313,512 @@ export default function AssetsLibrary({
</div>
)}
{tab === 'contacts' && (
<div className="space-y-6">
{/* Header bar with info and Add button */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-2xl bg-white border border-gray-200 shadow-xs">
<div>
<h2 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<Users className="w-4 h-4 text-brand-600" />
Directorio de Personas del Cliente
</h2>
<p className="text-xs text-gray-500 mt-0.5">
Personas de contacto clave de la empresa, sus cargos y canales preferidos de comunicación (Email, Grupo WhatsApp, Teams).
</p>
</div>
{!showContactForm && (
<button
type="button"
onClick={() => {
setEditingContactId(null);
setContactName('');
setContactRole('');
setContactEmail('');
setContactPhone('');
setContactMethods(['Email']);
setContactNotes('');
setShowContactForm(true);
}}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-brand-600 hover:bg-brand-700 rounded-xl transition-colors shrink-0 shadow-xs cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Añadir Persona
</button>
)}
</div>
{/* Form when open */}
{showContactForm && (
<form
onSubmit={handleSaveContact}
className="p-5 rounded-2xl border border-brand-200 bg-brand-50/40 space-y-4"
>
<div className="flex items-center justify-between border-b border-brand-200/60 pb-3">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<Users className="w-4 h-4 text-brand-600" />
{editingContactId ? 'Editar Persona' : 'Añadir Nueva Persona al Directorio'}
</h3>
<button
type="button"
onClick={handleCancelContactEdit}
className="text-gray-400 hover:text-gray-600 p-1"
>
<X className="w-4 h-4" />
</button>
</div>
{contactError && (
<p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-xl p-3">
{contactError}
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">
Nombre y Apellido <span className="text-red-500">*</span>
</label>
<input
type="text"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="Ej: Laura García Morales"
required
className="w-full text-xs px-3 py-2 rounded-xl border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 shadow-2xs"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">
Cargo <span className="text-red-500">*</span>
</label>
<div className="relative">
<Briefcase className="w-4 h-4 text-gray-400 absolute left-3 top-2.5" />
<input
type="text"
value={contactRole}
onChange={(e) => setContactRole(e.target.value)}
placeholder="Ej: Directora de Operaciones / CEO"
required
className="w-full text-xs pl-9 pr-3 py-2 rounded-xl border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 shadow-2xs"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">
Email de Contacto <span className="text-red-500">*</span>
</label>
<div className="relative">
<Mail className="w-4 h-4 text-gray-400 absolute left-3 top-2.5" />
<input
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="laura@empresa.com"
required
className="w-full text-xs pl-9 pr-3 py-2 rounded-xl border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 shadow-2xs"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">
Teléfono <span className="text-gray-400 font-normal">(no obligatorio)</span>
</label>
<div className="relative">
<Phone className="w-4 h-4 text-gray-400 absolute left-3 top-2.5" />
<input
type="tel"
value={contactPhone}
onChange={(e) => setContactPhone(e.target.value)}
placeholder="+34 600 000 000"
className="w-full text-xs pl-9 pr-3 py-2 rounded-xl border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 shadow-2xs"
/>
</div>
</div>
</div>
{/* Método de contacto (pueden ser varios) */}
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1.5">
Método de contacto <span className="text-gray-400 font-normal">(pueden ser varios)</span> <span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => toggleContactMethod('Email')}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium border transition-all cursor-pointer ${
contactMethods.includes('Email')
? 'bg-sky-50 text-sky-800 border-sky-400 shadow-xs ring-1 ring-sky-300'
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'
}`}
>
<Mail className="w-3.5 h-3.5 text-sky-600" />
<span>Email</span>
{contactMethods.includes('Email') && <span className="text-[10px] font-bold">✓</span>}
</button>
<button
type="button"
onClick={() => toggleContactMethod('Grupo WhatsApp')}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium border transition-all cursor-pointer ${
contactMethods.includes('Grupo WhatsApp')
? 'bg-emerald-50 text-emerald-800 border-emerald-400 shadow-xs ring-1 ring-emerald-300'
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'
}`}
>
<MessageCircle className="w-3.5 h-3.5 text-emerald-600" />
<span>Grupo WhatsApp</span>
{contactMethods.includes('Grupo WhatsApp') && <span className="text-[10px] font-bold">✓</span>}
</button>
<button
type="button"
onClick={() => toggleContactMethod('Teams')}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium border transition-all cursor-pointer ${
contactMethods.includes('Teams')
? 'bg-indigo-50 text-indigo-800 border-indigo-400 shadow-xs ring-1 ring-indigo-300'
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'
}`}
>
<MessageSquare className="w-3.5 h-3.5 text-indigo-600" />
<span>Teams</span>
{contactMethods.includes('Teams') && <span className="text-[10px] font-bold">✓</span>}
</button>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">
Notas o Área de responsabilidad <span className="text-gray-400 font-normal">(opcional)</span>
</label>
<textarea
rows={2}
value={contactNotes}
onChange={(e) => setContactNotes(e.target.value)}
placeholder="Información adicional sobre horarios, departamento o ámbito de decisión de esta persona..."
className="w-full text-xs p-3 rounded-xl border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 leading-relaxed shadow-2xs"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-brand-200/60">
<button
type="button"
onClick={handleCancelContactEdit}
disabled={savingContact}
className="px-3.5 py-2 text-xs font-medium text-gray-600 hover:text-gray-800 bg-white border border-gray-200 rounded-xl transition-colors cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
disabled={savingContact}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-brand-600 hover:bg-brand-700 rounded-xl transition-colors cursor-pointer shadow-xs disabled:opacity-50"
>
{savingContact ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Guardando…
</>
) : (
<>
<Check className="w-3.5 h-3.5" />
{editingContactId ? 'Actualizar Persona' : 'Guardar en Directorio'}
</>
)}
</button>
</div>
</form>
)}
{contactSuccess && (
<div className="p-3 rounded-xl bg-emerald-50 border border-emerald-200 text-xs text-emerald-700 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0" />
<span>{contactSuccess}</span>
</div>
)}
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
<div className="relative flex-1 max-w-sm">
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-2.5" />
<input
type="text"
value={contactSearch}
onChange={(e) => setContactSearch(e.target.value)}
placeholder="Buscar por nombre, cargo, email o teléfono…"
className="w-full text-xs pl-9 pr-3 py-2 rounded-xl border border-gray-300 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 shadow-2xs"
/>
</div>
<div className="flex flex-wrap gap-1.5 items-center">
{['Todos', ...ALL_CONTACT_METHODS].map((m) => {
const count = contactMethodCounts[m] ?? 0;
const active = contactMethodFilter === m;
return (
<button
key={m}
type="button"
onClick={() => setContactMethodFilter(m)}
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border font-medium transition-colors cursor-pointer ${
active
? 'border-brand-500 bg-brand-50 text-brand-700 font-semibold'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<span>{m}</span>
<span
className={`text-[10px] px-1.5 py-0.2 rounded-full font-bold ${
active
? 'bg-brand-600 text-white'
: 'bg-gray-100 text-gray-500'
}`}
>
{count}
</span>
</button>
);
})}
</div>
</div>
{/* Cards listing */}
{contactsLoading ? (
<div className="p-12 text-center text-gray-400 bg-white rounded-2xl border border-gray-200 shadow-xs">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-brand-500" />
<p className="text-xs text-gray-500">Cargando personas del directorio…</p>
</div>
) : filteredContacts.length === 0 ? (
<div className="p-12 text-center bg-white rounded-2xl border border-gray-200 shadow-xs">
<Users className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-sm font-semibold text-gray-800">
{contactSearch || contactMethodFilter !== 'Todos'
? 'No se encontraron personas con ese filtro'
: 'Sin personas registradas en el directorio del cliente'}
</p>
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">
{contactSearch || contactMethodFilter !== 'Todos'
? 'Prueba a cambiar los términos de búsqueda o el canal de contacto.'
: 'Añade a los interlocutores clave de la empresa con sus cargos, teléfonos y canales de contacto preferidos.'}
</p>
{!showContactForm && (
<button
type="button"
onClick={() => setShowContactForm(true)}
className="mt-4 inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-brand-600 hover:bg-brand-700 rounded-xl transition-colors cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Añadir la primera persona
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredContacts.map((c) => {
const initials = c.name
.split(' ')
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase();
const phoneClean = c.phone?.replace(/[^0-9+]/g, '') || '';
const waClean = c.phone?.replace(/[^0-9]/g, '') || '';
return (
<div
key={c.id}
className="p-5 rounded-2xl border border-gray-200 bg-white hover:border-brand-300 transition-all shadow-xs flex flex-col justify-between"
>
<div>
{/* Top row: Avatar + Name + Cargo + Actions */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-brand-500 to-amber-600 text-white flex items-center justify-center font-bold text-xs shrink-0 shadow-xs">
{initials || 'U'}
</div>
<div className="min-w-0">
<h4 className="text-sm font-bold text-gray-900 truncate">
{c.name}
</h4>
<p className="text-xs text-brand-600 font-medium truncate flex items-center gap-1 mt-0.5">
<Briefcase className="w-3 h-3 shrink-0" />
<span>{c.role}</span>
</p>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => handleEditContact(c)}
title="Editar persona"
className="p-1.5 text-gray-400 hover:text-brand-600 hover:bg-brand-50 rounded-lg transition-colors cursor-pointer"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDeleteContact(c.id)}
title="Eliminar del directorio"
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Contact Channels list */}
<div className="space-y-2 py-3 border-y border-gray-100 text-xs">
{/* Email */}
<div className="flex items-center justify-between gap-2">
<a
href={`mailto:${c.email}`}
className="flex items-center gap-2 text-gray-700 hover:text-brand-600 truncate transition-colors"
title={`Enviar email a ${c.email}`}
>
<Mail className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="truncate">{c.email}</span>
</a>
<button
type="button"
onClick={() => handleCopyUrl(`email-${c.id}`, c.email)}
title="Copiar email"
className="text-gray-400 hover:text-brand-600 p-1 shrink-0"
>
{copiedUrlId === `email-${c.id}` ? (
<Check className="w-3 h-3 text-emerald-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</div>
{/* Phone if exists */}
{c.phone && (
<div className="flex items-center justify-between gap-2">
<a
href={`tel:${phoneClean}`}
className="flex items-center gap-2 text-gray-700 hover:text-brand-600 truncate transition-colors"
title={`Llamar a ${c.phone}`}
>
<Phone className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="font-mono text-[11px] truncate">{c.phone}</span>
</a>
{waClean && (
<a
href={`https://wa.me/${waClean}`}
target="_blank"
rel="noopener noreferrer"
title="Abrir WhatsApp directo"
className="inline-flex items-center gap-1 text-[11px] text-emerald-600 hover:text-emerald-700 font-medium shrink-0"
>
<MessageCircle className="w-3 h-3" />
<span>WhatsApp</span>
</a>
)}
</div>
)}
</div>
{/* Preferred Contact Methods Pills */}
<div className="mt-3">
<span className="text-[10px] uppercase font-bold text-gray-400 block mb-1.5 tracking-wider">
Canal(es) de Contacto:
</span>
<div className="flex flex-wrap gap-1.5">
{c.contact_methods?.map((method) => {
if (method === 'Email') {
return (
<span
key={method}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-sky-50 text-sky-700 border border-sky-200"
>
<Mail className="w-2.5 h-2.5 text-sky-600" />
Email
</span>
);
}
if (method === 'Grupo WhatsApp') {
return (
<span
key={method}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200"
>
<MessageCircle className="w-2.5 h-2.5 text-emerald-600" />
Grupo WhatsApp
</span>
);
}
if (method === 'Teams') {
return (
<span
key={method}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200"
>
<MessageSquare className="w-2.5 h-2.5 text-indigo-600" />
Teams
</span>
);
}
return (
<span
key={method}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-gray-100 text-gray-700 border border-gray-200"
>
{method}
</span>
);
})}
</div>
</div>
{/* Notes if any */}
{c.notes && (
<div className="mt-3 p-2.5 rounded-xl bg-gray-50 border border-gray-100 text-[11px] text-gray-600 whitespace-pre-wrap leading-relaxed">
{c.notes}
</div>
)}
</div>
{/* Card Footer actions */}
<div className="mt-4 pt-3 border-t border-gray-100 flex items-center justify-between text-[11px]">
<span className="text-gray-400 truncate text-[10px]">
{c.created_at ? new Date(c.created_at).toLocaleDateString('es-ES') : ''}
</span>
<div className="flex items-center gap-2">
{c.contact_methods?.includes('Email') && (
<a
href={`mailto:${c.email}`}
className="inline-flex items-center gap-1 font-semibold text-brand-600 hover:text-brand-700"
>
<Mail className="w-3 h-3" />
Email
</a>
)}
{c.contact_methods?.includes('Grupo WhatsApp') && c.phone && (
<a
href={`https://wa.me/${waClean}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-semibold text-emerald-600 hover:text-emerald-700"
>
<MessageCircle className="w-3 h-3" />
WhatsApp
</a>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{detailId && (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-lg rounded-2xl border border-gray-200 bg-white overflow-hidden">
@@ -1188,7 +1874,7 @@ export default function AssetsLibrary({
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Comentario…"
className="flex-1 rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-xs text-gray-900"
className="flex-1 rounded-lg bg-white border border-gray-300 px-3 py-2 text-xs text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
/>
<button
type="button"

View File

@@ -360,7 +360,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
placeholder="Ej: Noberplast S.L. o Clínica Sonrisas"
value={companyName}
onChange={(e) => handleNameChange(e.target.value)}
className="w-full pl-10 pr-3.5 py-2.5 bg-gray-50/70 hover:bg-white focus:bg-white border border-gray-200 hover:border-gray-300 focus:border-brand-500 rounded-xl text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-4 focus:ring-brand-500/10 transition-all font-normal"
className="w-full pl-10 pr-3.5 py-2.5 bg-white border border-gray-300 hover:border-gray-400 focus:border-brand-500 rounded-xl text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-4 focus:ring-brand-500/10 transition-all font-normal shadow-2xs"
/>
</div>
</div>
@@ -391,7 +391,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
setAutoSlug(false);
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''));
}}
className="w-full pl-10 pr-3.5 py-2.5 bg-gray-50/70 hover:bg-white focus:bg-white border border-gray-200 hover:border-gray-300 focus:border-brand-500 rounded-xl text-sm font-mono text-brand-600 placeholder:text-gray-400 focus:outline-none focus:ring-4 focus:ring-brand-500/10 transition-all font-medium"
className="w-full pl-10 pr-3.5 py-2.5 bg-white border border-gray-300 hover:border-gray-400 focus:border-brand-500 rounded-xl text-sm font-mono text-brand-600 placeholder:text-gray-400 focus:outline-none focus:ring-4 focus:ring-brand-500/10 transition-all font-medium shadow-2xs"
/>
</div>
<p className="text-[11px] text-gray-400 mt-1 pl-1">
@@ -412,7 +412,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
placeholder="director@cliente.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full pl-10 pr-3.5 py-2.5 bg-gray-50/70 hover:bg-white focus:bg-white border border-gray-200 hover:border-gray-300 focus:border-brand-500 rounded-xl text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-4 focus:ring-brand-500/10 transition-all font-normal"
className="w-full pl-10 pr-3.5 py-2.5 bg-white border border-gray-300 hover:border-gray-400 focus:border-brand-500 rounded-xl text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-4 focus:ring-brand-500/10 transition-all font-normal shadow-2xs"
/>
</div>
</div>
@@ -438,7 +438,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-10 pr-10 py-2.5 bg-gray-50/70 hover:bg-white focus:bg-white border border-gray-200 hover:border-gray-300 focus:border-brand-500 rounded-xl text-sm font-mono text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-4 focus:ring-brand-500/10 transition-all font-medium"
className="w-full pl-10 pr-10 py-2.5 bg-white border border-gray-300 hover:border-gray-400 focus:border-brand-500 rounded-xl text-sm font-mono text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-4 focus:ring-brand-500/10 transition-all font-medium shadow-2xs"
/>
<button
type="button"

View File

@@ -44,7 +44,7 @@ function statusLabel(status: string): string {
}
const inputCls =
'mt-1 w-full rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-sm text-white';
'mt-1 w-full rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500';
type Draft = {
title: string;
@@ -739,7 +739,7 @@ export default function CreativesPipeline() {
{isAgency ? (
<>
<select
className="flex-1 text-[11px] rounded-lg bg-gray-900 border border-gray-200 text-gray-600 px-2 py-1.5"
className="flex-1 text-[11px] rounded-lg bg-white border border-gray-300 text-gray-800 px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
value={normalizeApprovalStatus(c.status)}
disabled={busyId === c.id}
onChange={(e) => setStatus(c.id, e.target.value)}