diff --git a/src/app/api/assets/contacts/route.ts b/src/app/api/assets/contacts/route.ts new file mode 100644 index 0000000..f0dad79 --- /dev/null +++ b/src/app/api/assets/contacts/route.ts @@ -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 }); +} diff --git a/src/app/dashboard/calendario/CalendarClient.tsx b/src/app/dashboard/calendario/CalendarClient.tsx index c81f804..d9f816f 100644 --- a/src/app/dashboard/calendario/CalendarClient.tsx +++ b/src/app/dashboard/calendario/CalendarClient.tsx @@ -340,7 +340,7 @@ export default function CalendarClient({ setMetaToken(e.target.value)} diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx index acd5e22..ad263a9 100644 --- a/src/components/AssetsLibrary.tsx +++ b/src/components/AssetsLibrary.tsx @@ -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([]); const [driveFiles, setDriveFiles] = useState([]); const [driveFolder, setDriveFolder] = useState(null); @@ -143,6 +150,23 @@ export default function AssetsLibrary({ const [urlSearch, setUrlSearch] = useState(''); const [urlFilterCategory, setUrlFilterCategory] = useState('Todas'); + // Directorio de Personas del Cliente + const [contacts, setContacts] = useState([]); + const [contactsLoading, setContactsLoading] = useState(false); + const [showContactForm, setShowContactForm] = useState(false); + const [editingContactId, setEditingContactId] = useState(null); + const [contactName, setContactName] = useState(''); + const [contactRole, setContactRole] = useState(''); + const [contactEmail, setContactEmail] = useState(''); + const [contactPhone, setContactPhone] = useState(''); + const [contactMethods, setContactMethods] = useState(['Email']); + const [contactNotes, setContactNotes] = useState(''); + const [savingContact, setSavingContact] = useState(false); + const [contactError, setContactError] = useState(null); + const [contactSuccess, setContactSuccess] = useState(null); + const [contactSearch, setContactSearch] = useState(''); + const [contactMethodFilter, setContactMethodFilter] = useState('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(); @@ -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 = { 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 (
: undefined @@ -474,13 +637,30 @@ export default function AssetsLibrary({ }`} > - URLs y Enlaces del Cliente + URLs y Enlaces {clientUrls.length > 0 && ( {clientUrls.length} )} +
{warning && ( @@ -1133,6 +1313,512 @@ export default function AssetsLibrary({ )} + {tab === 'contacts' && ( +
+ {/* Header bar with info and Add button */} +
+
+

+ + Directorio de Personas del Cliente +

+

+ Personas de contacto clave de la empresa, sus cargos y canales preferidos de comunicación (Email, Grupo WhatsApp, Teams). +

+
+ {!showContactForm && ( + + )} +
+ + {/* Form when open */} + {showContactForm && ( +
+
+

+ + {editingContactId ? 'Editar Persona' : 'Añadir Nueva Persona al Directorio'} +

+ +
+ + {contactError && ( +

+ {contactError} +

+ )} + +
+
+ + 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" + /> +
+ +
+ +
+ + 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" + /> +
+
+ +
+ +
+ + 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" + /> +
+
+ +
+ +
+ + 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" + /> +
+
+
+ + {/* Método de contacto (pueden ser varios) */} +
+ +
+ + + + + +
+
+ +
+ +