feat(activos): anadir directorio de personas del cliente y resolver fondos oscuros en inputs
This commit is contained in:
359
src/app/api/assets/contacts/route.ts
Normal file
359
src/app/api/assets/contacts/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -340,7 +340,7 @@ export default function CalendarClient({
|
|||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value as typeof 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="all">Todos</option>
|
||||||
<option value="approved">Aprobados</option>
|
<option value="approved">Aprobados</option>
|
||||||
@@ -476,7 +476,7 @@ export default function CalendarClient({
|
|||||||
value={commentText}
|
value={commentText}
|
||||||
onChange={(e) => setCommentText(e.target.value)}
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
placeholder="Escribe un comentario…"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -546,11 +546,11 @@ function Block({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,3 +38,25 @@ body {
|
|||||||
text-wrap: balance;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export default function AdsDiscoverPanel() {
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
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"
|
placeholder="EAAG… system user token"
|
||||||
value={metaToken}
|
value={metaToken}
|
||||||
onChange={(e) => setMetaToken(e.target.value)}
|
onChange={(e) => setMetaToken(e.target.value)}
|
||||||
|
|||||||
@@ -21,10 +21,17 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
X,
|
X,
|
||||||
Share2,
|
Share2,
|
||||||
|
Users,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
MessageCircle,
|
||||||
|
MessageSquare,
|
||||||
|
Briefcase,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/ui';
|
import { PageHeader } from '@/components/ui';
|
||||||
import SyncNowButton from '@/components/SyncNowButton';
|
import SyncNowButton from '@/components/SyncNowButton';
|
||||||
import type { ClientUrlAsset } from '@/app/api/assets/urls/route';
|
import type { ClientUrlAsset } from '@/app/api/assets/urls/route';
|
||||||
|
import type { ClientContact, ContactMethod } from '@/app/api/assets/contacts/route';
|
||||||
|
|
||||||
type Asset = {
|
type Asset = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -104,7 +111,7 @@ export default function AssetsLibrary({
|
|||||||
tenantLabel: string;
|
tenantLabel: string;
|
||||||
isAgency?: boolean;
|
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 [assets, setAssets] = useState<Asset[]>([]);
|
||||||
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
|
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
|
||||||
const [driveFolder, setDriveFolder] = useState<string | null>(null);
|
const [driveFolder, setDriveFolder] = useState<string | null>(null);
|
||||||
@@ -143,6 +150,23 @@ export default function AssetsLibrary({
|
|||||||
const [urlSearch, setUrlSearch] = useState('');
|
const [urlSearch, setUrlSearch] = useState('');
|
||||||
const [urlFilterCategory, setUrlFilterCategory] = useState<string>('Todas');
|
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 () => {
|
const loadUrls = useCallback(async () => {
|
||||||
setUrlsLoading(true);
|
setUrlsLoading(true);
|
||||||
try {
|
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 loadPortal = useCallback(async () => {
|
||||||
const res = await fetch('/api/assets');
|
const res = await fetch('/api/assets');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -193,12 +232,13 @@ export default function AssetsLibrary({
|
|||||||
setErr(null);
|
setErr(null);
|
||||||
if (tab === 'portal') await loadPortal();
|
if (tab === 'portal') await loadPortal();
|
||||||
else if (tab === 'urls') await loadUrls();
|
else if (tab === 'urls') await loadUrls();
|
||||||
|
else if (tab === 'contacts') await loadContacts();
|
||||||
else {
|
else {
|
||||||
const current = drivePath[drivePath.length - 1]?.id;
|
const current = drivePath[drivePath.length - 1]?.id;
|
||||||
await loadDrive(current);
|
await loadDrive(current);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [tab, loadPortal, loadDrive, loadUrls, drivePath]);
|
}, [tab, loadPortal, loadDrive, loadUrls, loadContacts, drivePath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -206,7 +246,8 @@ export default function AssetsLibrary({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadUrls();
|
void loadUrls();
|
||||||
}, [loadUrls]);
|
void loadContacts();
|
||||||
|
}, [loadUrls, loadContacts]);
|
||||||
|
|
||||||
const folders = useMemo(() => {
|
const folders = useMemo(() => {
|
||||||
const s = new Set<string>();
|
const s = new Set<string>();
|
||||||
@@ -423,6 +464,128 @@ export default function AssetsLibrary({
|
|||||||
setUrlError(null);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -430,8 +593,8 @@ export default function AssetsLibrary({
|
|||||||
title="Activos"
|
title="Activos"
|
||||||
description={
|
description={
|
||||||
isAgency
|
isAgency
|
||||||
? `Réplica completa de Drive · carpeta «${tenantLabel}» (bajo Clientes) + subidas del portal + URLs de referencia.`
|
? `Réplica completa de Drive · carpeta «${tenantLabel}» (bajo Clientes) + subidas del portal + URLs de referencia + directorio del equipo.`
|
||||||
: `Archivos y enlaces del Centro de Mando para «${tenantLabel}»: creatividades, calendarios, URLs estratégicas y carpetas de la app.`
|
: `Centro de Mando para «${tenantLabel}»: creatividades, calendarios, URLs estratégicas, directorio de personas y archivos en la nube.`
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
isAgency ? <SyncNowButton forceModule="activos" /> : undefined
|
isAgency ? <SyncNowButton forceModule="activos" /> : undefined
|
||||||
@@ -474,13 +637,30 @@ export default function AssetsLibrary({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Globe className="w-3.5 h-3.5" />
|
<Globe className="w-3.5 h-3.5" />
|
||||||
URLs y Enlaces del Cliente
|
URLs y Enlaces
|
||||||
{clientUrls.length > 0 && (
|
{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">
|
<span className="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-brand-100 text-brand-700 font-bold">
|
||||||
{clientUrls.length}
|
{clientUrls.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{warning && (
|
{warning && (
|
||||||
@@ -1133,6 +1313,512 @@ export default function AssetsLibrary({
|
|||||||
</div>
|
</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 && (
|
{detailId && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
<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">
|
<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}
|
value={commentText}
|
||||||
onChange={(e) => setCommentText(e.target.value)}
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
placeholder="Comentario…"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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"
|
placeholder="Ej: Noberplast S.L. o Clínica Sonrisas"
|
||||||
value={companyName}
|
value={companyName}
|
||||||
onChange={(e) => handleNameChange(e.target.value)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +391,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
|
|||||||
setAutoSlug(false);
|
setAutoSlug(false);
|
||||||
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''));
|
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>
|
</div>
|
||||||
<p className="text-[11px] text-gray-400 mt-1 pl-1">
|
<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"
|
placeholder="director@cliente.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,7 +438,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
|
|||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function statusLabel(status: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputCls =
|
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 = {
|
type Draft = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -739,7 +739,7 @@ export default function CreativesPipeline() {
|
|||||||
{isAgency ? (
|
{isAgency ? (
|
||||||
<>
|
<>
|
||||||
<select
|
<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)}
|
value={normalizeApprovalStatus(c.status)}
|
||||||
disabled={busyId === c.id}
|
disabled={busyId === c.id}
|
||||||
onChange={(e) => setStatus(c.id, e.target.value)}
|
onChange={(e) => setStatus(c.id, e.target.value)}
|
||||||
|
|||||||
Reference in New Issue
Block a user