feat(activos): seccion para guardar y gestionar URLs con descripcion del cliente
This commit is contained in:
287
src/app/api/assets/urls/route.ts
Normal file
287
src/app/api/assets/urls/route.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
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 interface ClientUrlAsset {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Permite autenticación por x-api-key en llamadas directas
|
||||||
|
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 URLs.' },
|
||||||
|
{ 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUrlsForTenant(tenantId: string): Promise<{ docId: string | null; urls: ClientUrlAsset[] }> {
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data: row } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('id, content')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('type', 'client_urls')
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!row) return { docId: null, urls: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = typeof row.content === 'string' ? JSON.parse(row.content) : row.content;
|
||||||
|
const list = Array.isArray(parsed) ? parsed : parsed?.urls || [];
|
||||||
|
return { docId: row.id, urls: list };
|
||||||
|
} catch {
|
||||||
|
return { docId: row.id, urls: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUrlsForTenant(tenantId: string, docId: string | null, urls: ClientUrlAsset[]) {
|
||||||
|
const admin = getAdmin();
|
||||||
|
const payload = {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
type: 'client_urls',
|
||||||
|
title: 'Enlaces y URLs del Cliente',
|
||||||
|
content: JSON.stringify(urls),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (docId) {
|
||||||
|
await admin
|
||||||
|
.from('documents')
|
||||||
|
.update({
|
||||||
|
content: payload.content,
|
||||||
|
updated_at: payload.updated_at,
|
||||||
|
})
|
||||||
|
.eq('id', docId);
|
||||||
|
} else {
|
||||||
|
await admin
|
||||||
|
.from('documents')
|
||||||
|
.insert(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(raw: string): string {
|
||||||
|
let s = (raw || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (!/^https?:\/\//i.test(s)) {
|
||||||
|
s = 'https://' + s;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g && g.error) return g.error;
|
||||||
|
const { tenantId, label } = g as { tenantId: string; label: string };
|
||||||
|
|
||||||
|
const { urls } = await loadUrlsForTenant(tenantId);
|
||||||
|
return NextResponse.json({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
tenant_label: label,
|
||||||
|
urls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g && g.error) return g.error;
|
||||||
|
const { tenantId, user } = g as { tenantId: string; user?: any };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const rawUrl = body.url?.trim();
|
||||||
|
const description = body.description?.trim();
|
||||||
|
const title = body.title?.trim() || '';
|
||||||
|
const category = body.category?.trim() || 'General';
|
||||||
|
|
||||||
|
if (!rawUrl) {
|
||||||
|
return NextResponse.json({ error: 'La URL es obligatoria' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!description) {
|
||||||
|
return NextResponse.json({ error: 'El texto descriptivo es obligatorio' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanUrl = normalizeUrl(rawUrl);
|
||||||
|
|
||||||
|
// Intentar derivar un título limpio si no se proporcionó
|
||||||
|
let finalTitle = title;
|
||||||
|
if (!finalTitle) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(cleanUrl);
|
||||||
|
finalTitle = parsed.hostname.replace(/^www\./, '');
|
||||||
|
if (parsed.pathname && parsed.pathname !== '/') {
|
||||||
|
const segment = parsed.pathname.split('/').filter(Boolean).pop();
|
||||||
|
if (segment) finalTitle += ` / ${segment.replace(/[-_]/g, ' ')}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
finalTitle = 'Enlace web';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAsset: ClientUrlAsset = {
|
||||||
|
id: `url_${Date.now()}_${randomUUID().slice(0, 8)}`,
|
||||||
|
url: cleanUrl,
|
||||||
|
title: finalTitle,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
created_by: user?.email || 'sistema',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { docId, urls } = await loadUrlsForTenant(tenantId);
|
||||||
|
const updatedUrls = [newAsset, ...urls];
|
||||||
|
|
||||||
|
await saveUrlsForTenant(tenantId, docId, updatedUrls);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url: newAsset,
|
||||||
|
urls: updatedUrls,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'Error al guardar la URL' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g && g.error) return g.error;
|
||||||
|
const { tenantId } = g as { tenantId: string };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const id = body.id;
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'ID de URL no proporcionado' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { docId, urls } = await loadUrlsForTenant(tenantId);
|
||||||
|
const index = urls.findIndex((u) => u.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
return NextResponse.json({ error: 'URL no encontrada' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = urls[index];
|
||||||
|
const cleanUrl = body.url ? normalizeUrl(body.url) : current.url;
|
||||||
|
|
||||||
|
const updatedItem: ClientUrlAsset = {
|
||||||
|
...current,
|
||||||
|
url: cleanUrl,
|
||||||
|
title: body.title !== undefined ? body.title.trim() : current.title,
|
||||||
|
description: body.description !== undefined ? body.description.trim() : current.description,
|
||||||
|
category: body.category !== undefined ? body.category.trim() : current.category,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedUrls = [...urls];
|
||||||
|
updatedUrls[index] = updatedItem;
|
||||||
|
|
||||||
|
await saveUrlsForTenant(tenantId, docId, updatedUrls);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url: updatedItem,
|
||||||
|
urls: updatedUrls,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'Error al actualizar la URL' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g && g.error) return g.error;
|
||||||
|
const { tenantId } = g as { tenantId: string };
|
||||||
|
|
||||||
|
const id = req.nextUrl.searchParams.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'ID requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { docId, urls } = await loadUrlsForTenant(tenantId);
|
||||||
|
const updatedUrls = urls.filter((u) => u.id !== id);
|
||||||
|
|
||||||
|
await saveUrlsForTenant(tenantId, docId, updatedUrls);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
urls: updatedUrls,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,9 +11,20 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
|
Globe,
|
||||||
|
Link2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Edit2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Tag,
|
||||||
|
X,
|
||||||
|
Share2,
|
||||||
} 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';
|
||||||
|
|
||||||
type Asset = {
|
type Asset = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -93,7 +104,7 @@ export default function AssetsLibrary({
|
|||||||
tenantLabel: string;
|
tenantLabel: string;
|
||||||
isAgency?: boolean;
|
isAgency?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<'portal' | 'drive'>('drive');
|
const [tab, setTab] = useState<'portal' | 'drive' | 'urls'>('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);
|
||||||
@@ -116,6 +127,37 @@ export default function AssetsLibrary({
|
|||||||
const [party, setParty] = useState<'agency' | 'client'>('client');
|
const [party, setParty] = useState<'agency' | 'client'>('client');
|
||||||
const [collabBusy, setCollabBusy] = useState(false);
|
const [collabBusy, setCollabBusy] = useState(false);
|
||||||
|
|
||||||
|
// URLs del Cliente
|
||||||
|
const [clientUrls, setClientUrls] = useState<ClientUrlAsset[]>([]);
|
||||||
|
const [urlsLoading, setUrlsLoading] = useState(false);
|
||||||
|
const [showUrlForm, setShowUrlForm] = useState(false);
|
||||||
|
const [editingUrlId, setEditingUrlId] = useState<string | null>(null);
|
||||||
|
const [urlInput, setUrlInput] = useState('');
|
||||||
|
const [urlTitle, setUrlTitle] = useState('');
|
||||||
|
const [urlDescription, setUrlDescription] = useState('');
|
||||||
|
const [urlCategory, setUrlCategory] = useState('Estrategia');
|
||||||
|
const [savingUrl, setSavingUrl] = useState(false);
|
||||||
|
const [urlError, setUrlError] = useState<string | null>(null);
|
||||||
|
const [urlSuccess, setUrlSuccess] = useState<string | null>(null);
|
||||||
|
const [copiedUrlId, setCopiedUrlId] = useState<string | null>(null);
|
||||||
|
const [urlSearch, setUrlSearch] = useState('');
|
||||||
|
const [urlFilterCategory, setUrlFilterCategory] = useState<string>('Todas');
|
||||||
|
|
||||||
|
const loadUrls = useCallback(async () => {
|
||||||
|
setUrlsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/assets/urls');
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.urls) {
|
||||||
|
setClientUrls(data.urls);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignorar error de red silencioso
|
||||||
|
} finally {
|
||||||
|
setUrlsLoading(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();
|
||||||
@@ -150,17 +192,22 @@ export default function AssetsLibrary({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
if (tab === 'portal') await loadPortal();
|
if (tab === 'portal') await loadPortal();
|
||||||
|
else if (tab === 'urls') await loadUrls();
|
||||||
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, drivePath]);
|
}, [tab, loadPortal, loadDrive, loadUrls, drivePath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadUrls();
|
||||||
|
}, [loadUrls]);
|
||||||
|
|
||||||
const folders = useMemo(() => {
|
const folders = useMemo(() => {
|
||||||
const s = new Set<string>();
|
const s = new Set<string>();
|
||||||
for (const a of assets) {
|
for (const a of assets) {
|
||||||
@@ -258,6 +305,124 @@ export default function AssetsLibrary({
|
|||||||
await loadPortal();
|
await loadPortal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const URL_CATEGORIES = [
|
||||||
|
'Estrategia',
|
||||||
|
'Web Corporativa',
|
||||||
|
'Documentación',
|
||||||
|
'Presentaciones',
|
||||||
|
'Competencia',
|
||||||
|
'Herramientas',
|
||||||
|
'Otro',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const filteredUrls = useMemo(() => {
|
||||||
|
return clientUrls.filter((item) => {
|
||||||
|
const q = urlSearch.trim().toLowerCase();
|
||||||
|
const matchesSearch =
|
||||||
|
!q ||
|
||||||
|
item.title?.toLowerCase().includes(q) ||
|
||||||
|
item.url.toLowerCase().includes(q) ||
|
||||||
|
item.description?.toLowerCase().includes(q);
|
||||||
|
const matchesCategory =
|
||||||
|
urlFilterCategory === 'Todas' || item.category === urlFilterCategory;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
}, [clientUrls, urlSearch, urlFilterCategory]);
|
||||||
|
|
||||||
|
const categoryCounts = useMemo(() => {
|
||||||
|
const map: Record<string, number> = { Todas: clientUrls.length };
|
||||||
|
for (const c of URL_CATEGORIES) {
|
||||||
|
map[c] = clientUrls.filter((u) => u.category === c).length;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [clientUrls]);
|
||||||
|
|
||||||
|
async function handleSaveUrl(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!urlInput.trim() || !urlDescription.trim()) {
|
||||||
|
setUrlError('La URL y el texto descriptivo son obligatorios.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingUrl(true);
|
||||||
|
setUrlError(null);
|
||||||
|
setUrlSuccess(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isEdit = Boolean(editingUrlId);
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
const body = {
|
||||||
|
id: editingUrlId || undefined,
|
||||||
|
url: urlInput.trim(),
|
||||||
|
title: urlTitle.trim(),
|
||||||
|
description: urlDescription.trim(),
|
||||||
|
category: urlCategory,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('/api/assets/urls', {
|
||||||
|
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 URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
setClientUrls(data.urls || []);
|
||||||
|
setUrlSuccess(isEdit ? 'Enlace actualizado con éxito' : 'Enlace guardado en la biblioteca');
|
||||||
|
setUrlInput('');
|
||||||
|
setUrlTitle('');
|
||||||
|
setUrlDescription('');
|
||||||
|
setEditingUrlId(null);
|
||||||
|
setShowUrlForm(false);
|
||||||
|
setTimeout(() => setUrlSuccess(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setUrlError(err.message || 'Error al guardar');
|
||||||
|
} finally {
|
||||||
|
setSavingUrl(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUrl(id: string) {
|
||||||
|
if (!confirm('¿Seguro que deseas eliminar este enlace de la biblioteca?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/assets/urls?id=${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.urls) {
|
||||||
|
setClientUrls(data.urls);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setUrlError(err.message || 'Error al eliminar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopyUrl(id: string, url: string) {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
setCopiedUrlId(id);
|
||||||
|
setTimeout(() => setCopiedUrlId(null), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditUrl(item: ClientUrlAsset) {
|
||||||
|
setEditingUrlId(item.id);
|
||||||
|
setUrlInput(item.url);
|
||||||
|
setUrlTitle(item.title);
|
||||||
|
setUrlDescription(item.description);
|
||||||
|
setUrlCategory(item.category || 'Estrategia');
|
||||||
|
setShowUrlForm(true);
|
||||||
|
setUrlError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelEdit() {
|
||||||
|
setEditingUrlId(null);
|
||||||
|
setUrlInput('');
|
||||||
|
setUrlTitle('');
|
||||||
|
setUrlDescription('');
|
||||||
|
setShowUrlForm(false);
|
||||||
|
setUrlError(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -265,22 +430,22 @@ export default function AssetsLibrary({
|
|||||||
title="Activos"
|
title="Activos"
|
||||||
description={
|
description={
|
||||||
isAgency
|
isAgency
|
||||||
? `Réplica completa de Drive · carpeta «${tenantLabel}» (bajo Clientes) + subidas del portal.`
|
? `Réplica completa de Drive · carpeta «${tenantLabel}» (bajo Clientes) + subidas del portal + URLs de referencia.`
|
||||||
: `Archivos generados por el Centro de Mando para «${tenantLabel}»: creatividades, calendarios y carpetas de la app. El resto del Drive solo lo ve la agencia.`
|
: `Archivos y enlaces del Centro de Mando para «${tenantLabel}»: creatividades, calendarios, URLs estratégicas y carpetas de la app.`
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
isAgency ? <SyncNowButton forceModule="activos" /> : undefined
|
isAgency ? <SyncNowButton forceModule="activos" /> : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab('drive')}
|
onClick={() => setTab('drive')}
|
||||||
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${
|
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 === 'drive'
|
tab === 'drive'
|
||||||
? 'border-brand-500/40 text-brand-600 bg-brand-50'
|
? 'border-brand-500/40 text-brand-600 bg-brand-50 shadow-xs'
|
||||||
: 'border-gray-200 text-gray-500'
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<HardDrive className="w-3.5 h-3.5" />
|
<HardDrive className="w-3.5 h-3.5" />
|
||||||
@@ -290,14 +455,32 @@ export default function AssetsLibrary({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab('portal')}
|
onClick={() => setTab('portal')}
|
||||||
className={`text-xs px-3 py-1.5 rounded-lg border ${
|
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 === 'portal'
|
tab === 'portal'
|
||||||
? 'border-brand-500/40 text-brand-600 bg-brand-50'
|
? 'border-brand-500/40 text-brand-600 bg-brand-50 shadow-xs'
|
||||||
: 'border-gray-200 text-gray-500'
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<FileText className="w-3.5 h-3.5" />
|
||||||
{isAgency ? 'Subidas portal' : 'Archivos del portal'}
|
{isAgency ? 'Subidas portal' : 'Archivos del portal'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('urls')}
|
||||||
|
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 === 'urls'
|
||||||
|
? 'border-brand-500/40 text-brand-600 bg-brand-50 shadow-xs'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5" />
|
||||||
|
URLs y Enlaces del Cliente
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{warning && (
|
{warning && (
|
||||||
@@ -313,11 +496,11 @@ export default function AssetsLibrary({
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
|
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-[11px] uppercase text-gray-500">
|
<label className="text-[11px] uppercase text-gray-500 font-semibold">
|
||||||
Carpeta
|
Carpeta
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-sm text-gray-900"
|
className="mt-1 w-full rounded-xl bg-white border border-gray-200 px-3.5 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
placeholder="ej. creatividades/meta o vacío = raíz"
|
placeholder="ej. creatividades/meta o vacío = raíz"
|
||||||
value={folder}
|
value={folder}
|
||||||
onChange={(e) => setFolder(e.target.value)}
|
onChange={(e) => setFolder(e.target.value)}
|
||||||
@@ -613,6 +796,343 @@ export default function AssetsLibrary({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'urls' && (
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-brand-600" />
|
||||||
|
URLs y Enlaces de Referencia del Cliente
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Almacena enlaces externos (estrategia en LinkedIn, web corporativa, herramientas, benchmarks o briefs) junto con su descripción.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!showUrlForm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingUrlId(null);
|
||||||
|
setUrlInput('');
|
||||||
|
setUrlTitle('');
|
||||||
|
setUrlDescription('');
|
||||||
|
setShowUrlForm(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 Enlace
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form when open */}
|
||||||
|
{showUrlForm && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSaveUrl}
|
||||||
|
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">
|
||||||
|
<Link2 className="w-4 h-4 text-brand-600" />
|
||||||
|
{editingUrlId ? 'Editar enlace' : 'Nuevo enlace para el cliente'}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="text-gray-400 hover:text-gray-600 p-1"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{urlError && (
|
||||||
|
<p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-xl p-3">
|
||||||
|
{urlError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
URL o Enlace <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Globe className="w-4 h-4 text-gray-400 absolute left-3 top-2.5" />
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={urlInput}
|
||||||
|
onChange={(e) => setUrlInput(e.target.value)}
|
||||||
|
placeholder="https://4ventures.es/estrategia-linkedin.html"
|
||||||
|
required
|
||||||
|
className="w-full text-xs pl-9 pr-3 py-2 rounded-xl border border-gray-200 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
Título o Nombre identificativo (opcional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={urlTitle}
|
||||||
|
onChange={(e) => setUrlTitle(e.target.value)}
|
||||||
|
placeholder="Ej: Estrategia LinkedIn B2B 2026"
|
||||||
|
className="w-full text-xs px-3 py-2 rounded-xl border border-gray-200 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">
|
||||||
|
Categoría
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{URL_CATEGORIES.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUrlCategory(cat)}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-lg border font-medium transition-colors cursor-pointer ${
|
||||||
|
urlCategory === cat
|
||||||
|
? 'bg-brand-600 text-white border-brand-600'
|
||||||
|
: 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
Texto descriptivo de la URL <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={urlDescription}
|
||||||
|
onChange={(e) => setUrlDescription(e.target.value)}
|
||||||
|
placeholder="Explica qué contiene esta URL, su propósito o cómo debe ser tenida en cuenta (ej: Documento de posicionamiento y pilares de contenido para prospección B2B en LinkedIn)..."
|
||||||
|
required
|
||||||
|
className="w-full text-xs p-3 rounded-xl border border-gray-200 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-brand-200/60">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
disabled={savingUrl}
|
||||||
|
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={savingUrl}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{savingUrl ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
Guardando…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
{editingUrlId ? 'Actualizar Enlace' : 'Guardar Enlace'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{urlSuccess && (
|
||||||
|
<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>{urlSuccess}</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={urlSearch}
|
||||||
|
onChange={(e) => setUrlSearch(e.target.value)}
|
||||||
|
placeholder="Buscar por título, enlace o descripción…"
|
||||||
|
className="w-full text-xs pl-9 pr-3 py-2 rounded-xl border border-gray-200 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 items-center">
|
||||||
|
{['Todas', ...URL_CATEGORIES].map((c) => {
|
||||||
|
const count = categoryCounts[c] ?? 0;
|
||||||
|
const active = urlFilterCategory === c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUrlFilterCategory(c)}
|
||||||
|
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>{c}</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 */}
|
||||||
|
{urlsLoading ? (
|
||||||
|
<div className="p-12 text-center text-gray-400 bg-white rounded-2xl border border-gray-200">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-brand-500" />
|
||||||
|
<p className="text-xs text-gray-500">Cargando enlaces del cliente…</p>
|
||||||
|
</div>
|
||||||
|
) : filteredUrls.length === 0 ? (
|
||||||
|
<div className="p-12 text-center bg-white rounded-2xl border border-gray-200">
|
||||||
|
<Globe className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-sm font-semibold text-gray-800">
|
||||||
|
{urlSearch || urlFilterCategory !== 'Todas'
|
||||||
|
? 'No hay URLs que coincidan con la búsqueda'
|
||||||
|
: 'Sin enlaces guardados para este cliente'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">
|
||||||
|
{urlSearch || urlFilterCategory !== 'Todas'
|
||||||
|
? 'Prueba a cambiar los términos de búsqueda o el filtro de categoría.'
|
||||||
|
: 'Añade enlaces relevantes como perfiles de LinkedIn, páginas de competencia o briefs web para tenerlos siempre a mano.'}
|
||||||
|
</p>
|
||||||
|
{!showUrlForm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUrlForm(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 el primer enlace
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{filteredUrls.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="p-4 rounded-2xl border border-gray-200 bg-white hover:border-brand-300 transition-all shadow-xs flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{/* Header: Category + Date + Action buttons */}
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-brand-50 text-brand-700 border border-brand-200">
|
||||||
|
{item.category || 'Estrategia'}
|
||||||
|
</span>
|
||||||
|
{item.created_at && (
|
||||||
|
<span className="text-[10px] text-gray-400">
|
||||||
|
{new Date(item.created_at).toLocaleDateString('es-ES', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyUrl(item.id, item.url)}
|
||||||
|
title="Copiar URL"
|
||||||
|
className="p-1.5 text-gray-400 hover:text-brand-600 hover:bg-brand-50 rounded-lg transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{copiedUrlId === item.id ? (
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEditUrl(item)}
|
||||||
|
title="Editar"
|
||||||
|
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={() => handleDeleteUrl(item.id)}
|
||||||
|
title="Eliminar"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 line-clamp-1">
|
||||||
|
{item.title || item.url}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* URL link */}
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 hover:underline mt-1 font-mono truncate max-w-full"
|
||||||
|
>
|
||||||
|
<span className="truncate">{item.url}</span>
|
||||||
|
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Description Box */}
|
||||||
|
<div className="mt-3 p-3 rounded-xl bg-gray-50 border border-gray-100 text-xs text-gray-700 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card footer */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-gray-400 truncate">
|
||||||
|
{item.created_by ? `Añadido por: ${item.created_by}` : ''}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-brand-600 hover:text-brand-700 transition-colors"
|
||||||
|
>
|
||||||
|
Abrir enlace
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user