Compare commits
2 Commits
e3180ceb51
...
6566b42037
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6566b42037 | ||
|
|
ed18c9d318 |
@@ -24,8 +24,10 @@
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv": "^18.0.5",
|
||||
"postcss": "^8.5.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
38
scripts/blue-parrot-pipeline-seed.json
Normal file
38
scripts/blue-parrot-pipeline-seed.json
Normal file
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{ "id": "oct-01", "format": "carrusel", "month": "2026-10", "category": "aprende inglés", "title": "3 false friends que te delatan" },
|
||||
{ "id": "oct-02", "format": "story", "month": "2026-10", "category": "aprende inglés", "title": "Palabra del día: deadline" },
|
||||
{ "id": "oct-03", "format": "reel", "month": "2026-10", "category": "speaking", "title": "Preséntate en inglés en 30 segundos" },
|
||||
{ "id": "oct-04", "format": "post", "month": "2026-10", "category": "cursos y certificaciones", "title": "Cursos intensivos: certifica tu nivel" },
|
||||
{ "id": "oct-05", "format": "story", "month": "2026-10", "category": "test de nivel", "title": "¿No sabes por dónde empezar?" },
|
||||
{ "id": "oct-06", "format": "carrusel", "month": "2026-10", "category": "cursos y certificaciones", "title": "Aptis, Cambridge o IELTS: ¿cuál te conviene?" },
|
||||
{ "id": "oct-07", "format": "reel", "month": "2026-10", "category": "aprende inglés", "title": "Make o do: el truco para no fallar" },
|
||||
{ "id": "oct-08", "format": "story", "month": "2026-10", "category": "comunidad", "title": "¿Qué idioma quieres aprender?" },
|
||||
{ "id": "oct-09", "format": "carrusel", "month": "2026-10", "category": "inglés para el trabajo", "title": "3 frases para sonar más natural en una reunión" },
|
||||
{ "id": "oct-10", "format": "reel", "month": "2026-10", "category": "comunidad", "title": "Un día en la academia" },
|
||||
{ "id": "oct-11", "format": "post", "month": "2026-10", "category": "cursos y certificaciones", "title": "Grupos de máximo 8 alumnos" },
|
||||
{ "id": "oct-12", "format": "carrusel", "month": "2026-10", "category": "aprende inglés", "title": "Phrasal verbs con «get» que usarás cada día" },
|
||||
|
||||
{ "id": "nov-01", "format": "carrusel", "month": "2026-11", "category": "inglés con series", "title": "Friendsgiving: el inglés de Acción de Gracias con Friends" },
|
||||
{ "id": "nov-02", "format": "reel", "month": "2026-11", "category": "inglés para el día a día", "title": "Black Friday: 5 palabras que verás en todas las tiendas" },
|
||||
{ "id": "nov-03", "format": "story", "month": "2026-11", "category": "comunidad", "title": "¿Deal o bargain? Tú decides" },
|
||||
{ "id": "nov-04", "format": "carrusel", "month": "2026-11", "category": "inglés con series", "title": "El inglés de Stranger Things" },
|
||||
{ "id": "nov-05", "format": "post", "month": "2026-11", "category": "cursos y certificaciones", "title": "Certifica tu nivel antes de que acabe el año" },
|
||||
{ "id": "nov-06", "format": "reel", "month": "2026-11", "category": "speaking", "title": "Thanks, cheers, much appreciated: cómo dar las gracias en inglés" },
|
||||
{ "id": "nov-07", "format": "story", "month": "2026-11", "category": "aprende inglés", "title": "Palabra del día: leftovers" },
|
||||
|
||||
{ "id": "dic-01", "format": "carrusel", "month": "2026-12", "category": "inglés con películas", "title": "El inglés de Solo en casa" },
|
||||
{ "id": "dic-02", "format": "story", "month": "2026-12", "category": "aprende inglés", "title": "Palabra del día: cosy" },
|
||||
{ "id": "dic-03", "format": "reel", "month": "2026-12", "category": "speaking", "title": "Felicita la Navidad en inglés sin decir solo Merry Christmas" },
|
||||
{ "id": "dic-04", "format": "carrusel", "month": "2026-12", "category": "inglés con películas", "title": "Love Actually: frases para practicar tu inglés" },
|
||||
{ "id": "dic-05", "format": "post", "month": "2026-12", "category": "cursos y certificaciones", "title": "Estas Navidades, regala un idioma" },
|
||||
{ "id": "dic-06", "format": "reel", "month": "2026-12", "category": "aprende inglés", "title": "Christmas vocabulary: mistletoe, stocking, Boxing Day" },
|
||||
{ "id": "dic-07", "format": "story", "month": "2026-12", "category": "comunidad", "title": "¿Tu propósito de 2027 es aprender un idioma?" },
|
||||
|
||||
{ "id": "ene27-01", "format": "carrusel", "month": "2027-01", "category": "aprende inglés", "title": "New Year's resolutions: di tus propósitos en inglés" },
|
||||
{ "id": "ene27-02", "format": "story", "month": "2027-01", "category": "test de nivel", "title": "Empieza el año sabiendo tu nivel" },
|
||||
{ "id": "ene27-03", "format": "reel", "month": "2027-01", "category": "aprende inglés", "title": "Take up, give up, keep up: 3 phrasal verbs para tus propósitos" },
|
||||
{ "id": "ene27-04", "format": "post", "month": "2027-01", "category": "cursos y certificaciones", "title": "Nuevo año, nuevo idioma" },
|
||||
{ "id": "ene27-05", "format": "carrusel", "month": "2027-01", "category": "aprende inglés", "title": "Blue Monday: vocabulario para los días grises" },
|
||||
{ "id": "ene27-06", "format": "reel", "month": "2027-01", "category": "aprende inglés", "title": "Winter vocabulary: freezing, chilly, to bundle up" },
|
||||
{ "id": "ene27-07", "format": "story", "month": "2027-01", "category": "aprende inglés", "title": "Palabra del día: chilly" }
|
||||
]
|
||||
164
scripts/seed_blue_parrot_pipeline.ts
Normal file
164
scripts/seed_blue_parrot_pipeline.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Carga las 33 piezas de Blue Parrot (oct 2026 - ene 2027) en creative_pipeline.
|
||||
*
|
||||
* Requiere:
|
||||
* 1. Haber aplicado sql/creative_pipeline.sql en Supabase.
|
||||
* 2. Haber descomprimido Blue-Parrot-creatividades-con-imagen.zip en una carpeta.
|
||||
* 3. .env.local con NEXT_PUBLIC_SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY reales
|
||||
* (los mismos que usa la app en producción — ejecutar esto en el propio VPS,
|
||||
* por ejemplo desde "Open Terminal" en Dokploy, nunca pegando la service
|
||||
* role key en un chat).
|
||||
*
|
||||
* Uso:
|
||||
* npx tsx scripts/seed_blue_parrot_pipeline.ts --dir /ruta/a/Blue-Parrot-creatividades-con-imagen \
|
||||
* [--status revision_interna] [--dry-run]
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve, join } from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
// Mismo tenant_id que scripts/provision_victor_bps.ts
|
||||
const BLUE_PARROT_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||
const BUCKET = 'tenant-assets';
|
||||
|
||||
type SeedItem = {
|
||||
id: string;
|
||||
format: 'post' | 'story' | 'reel' | 'carrusel';
|
||||
month: string;
|
||||
category: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function arg(name: string, fallback?: string): string | undefined {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
return process.argv[i + 1];
|
||||
}
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
|
||||
async function main() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY en .env.local');
|
||||
|
||||
const dir = arg('dir');
|
||||
if (!dir || !existsSync(dir)) {
|
||||
throw new Error('Pasa --dir /ruta/a/la/carpeta/descomprimida (el ZIP de las 33 piezas)');
|
||||
}
|
||||
const status = arg('status', 'revision_interna')!;
|
||||
|
||||
const metaPath = resolve(__dirname, 'blue-parrot-pipeline-seed.json');
|
||||
const items: SeedItem[] = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
||||
|
||||
const admin = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
|
||||
|
||||
// Asegura el bucket (no falla si ya existe).
|
||||
const { data: buckets } = await admin.storage.listBuckets();
|
||||
if (!buckets?.some((b) => b.id === BUCKET)) {
|
||||
await admin.storage.createBucket(BUCKET, { public: false });
|
||||
}
|
||||
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const it of items) {
|
||||
const isCarrusel = it.format === 'carrusel';
|
||||
const coverLocal = isCarrusel
|
||||
? join(dir, `${it.id}-carrusel`, `${it.id}-1.jpg`)
|
||||
: join(dir, `${it.id}-${it.format}.jpg`);
|
||||
|
||||
if (!existsSync(coverLocal)) {
|
||||
console.error(`[SKIP] ${it.id}: no existe ${coverLocal}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[${it.id}] ${it.title}`);
|
||||
if (DRY_RUN) {
|
||||
ok++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1) upsert de la fila (sin imágenes todavía) para obtener el id.
|
||||
const { data: row, error: upErr } = await admin
|
||||
.from('creative_pipeline')
|
||||
.upsert(
|
||||
{
|
||||
tenant_id: BLUE_PARROT_TENANT_ID,
|
||||
external_ref: it.id,
|
||||
title: it.title,
|
||||
format: it.format,
|
||||
month: it.month,
|
||||
status,
|
||||
notes: it.category,
|
||||
},
|
||||
{ onConflict: 'tenant_id,external_ref' }
|
||||
)
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (upErr || !row) {
|
||||
console.error(`[FAIL] ${it.id}: ${upErr?.message}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
const itemId = row.id as string;
|
||||
|
||||
// 2) sube portada
|
||||
const coverPath = `${BLUE_PARROT_TENANT_ID}/pipeline/${itemId}/cover.jpg`;
|
||||
const coverBuf = readFileSync(coverLocal);
|
||||
const coverUp = await admin.storage.from(BUCKET).upload(coverPath, coverBuf, {
|
||||
contentType: 'image/jpeg',
|
||||
upsert: true,
|
||||
});
|
||||
if (coverUp.error) {
|
||||
console.error(`[FAIL cover] ${it.id}: ${coverUp.error.message}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3) sube slides (carrusel: 2,3,4,5 → 4 slides interiores + CTA)
|
||||
const slidePaths: string[] = [];
|
||||
if (isCarrusel) {
|
||||
for (let n = 2; n <= 5; n++) {
|
||||
const local = join(dir, `${it.id}-carrusel`, `${it.id}-${n}.jpg`);
|
||||
if (!existsSync(local)) continue;
|
||||
const path = `${BLUE_PARROT_TENANT_ID}/pipeline/${itemId}/slide-${n - 1}.jpg`;
|
||||
const buf = readFileSync(local);
|
||||
const up = await admin.storage.from(BUCKET).upload(path, buf, {
|
||||
contentType: 'image/jpeg',
|
||||
upsert: true,
|
||||
});
|
||||
if (up.error) {
|
||||
console.error(`[FAIL slide ${n}] ${it.id}: ${up.error.message}`);
|
||||
continue;
|
||||
}
|
||||
slidePaths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) cierra la fila con las rutas de storage
|
||||
const { error: patchErr } = await admin
|
||||
.from('creative_pipeline')
|
||||
.update({ cover_path: coverPath, cover_mime: 'image/jpeg', slide_paths: slidePaths })
|
||||
.eq('id', itemId);
|
||||
|
||||
if (patchErr) {
|
||||
console.error(`[FAIL patch] ${it.id}: ${patchErr.message}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
ok++;
|
||||
}
|
||||
|
||||
console.log(`\nHecho: ${ok} ok, ${failed} con error, de ${items.length} piezas.`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
97
sql/creative_pipeline.sql
Normal file
97
sql/creative_pipeline.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- Creative pipeline: producción interna de creatividades (Blue Parrot y resto
|
||||
-- de clientes) separada de `creatives` (que es el repositorio de anuncios
|
||||
-- Meta/Google ya aprobados). Aquí vive el flujo editorial completo:
|
||||
-- Propuesta -> En diseño -> Revisión interna -> Aprobado / Rechazado -> Publicado
|
||||
-- Visible también para el cliente en /dashboard/panel-cliente (RLS por tenant).
|
||||
-- Idempotente.
|
||||
|
||||
create table if not exists public.creative_pipeline (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
|
||||
-- referencia externa estable usada por los scripts de generación (ej. "oct-04")
|
||||
external_ref text,
|
||||
|
||||
title text not null,
|
||||
format text not null default 'post'
|
||||
check (format in ('post', 'story', 'reel', 'carrusel')),
|
||||
|
||||
-- mes/calendario al que pertenece la pieza (agrupación en el board)
|
||||
month text, -- 'YYYY-MM'
|
||||
|
||||
status text not null default 'propuesta'
|
||||
check (status in (
|
||||
'propuesta', 'en_diseno', 'revision_interna',
|
||||
'aprobado', 'rechazado', 'publicado'
|
||||
)),
|
||||
|
||||
-- portada (todas las piezas tienen una)
|
||||
cover_path text,
|
||||
cover_mime text,
|
||||
|
||||
-- piezas carrusel: array ordenado de storage paths de los slides interiores + CTA
|
||||
-- ej. ["tenant/.../slide-2.jpg", "tenant/.../slide-3.jpg", ...]
|
||||
slide_paths jsonb not null default '[]'::jsonb,
|
||||
|
||||
caption text,
|
||||
notes text,
|
||||
|
||||
created_by uuid references auth.users(id),
|
||||
approved_by uuid references auth.users(id),
|
||||
approved_at timestamptz,
|
||||
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create unique index if not exists creative_pipeline_tenant_ref_idx
|
||||
on public.creative_pipeline (tenant_id, external_ref)
|
||||
where external_ref is not null;
|
||||
|
||||
create index if not exists creative_pipeline_tenant_month_idx
|
||||
on public.creative_pipeline (tenant_id, month, status);
|
||||
|
||||
alter table public.creative_pipeline enable row level security;
|
||||
|
||||
-- Lectura: cliente ve las piezas de su propio tenant (incluye revisión interna,
|
||||
-- ya que este panel es también para el cliente, no solo para el equipo).
|
||||
create policy creative_pipeline_tenant_select on public.creative_pipeline
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- Alta/edición de contenido vía API del equipo (service role) — bloqueado para
|
||||
-- clientes directos por RLS; el endpoint PATCH permite al cliente mover el
|
||||
-- estado a 'aprobado'/'rechazado' únicamente mediante el mismo filtro de tenant.
|
||||
create policy creative_pipeline_tenant_insert on public.creative_pipeline
|
||||
for insert with check (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy creative_pipeline_tenant_update on public.creative_pipeline
|
||||
for update using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy creative_pipeline_tenant_delete on public.creative_pipeline
|
||||
for delete using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
comment on table public.creative_pipeline is
|
||||
'Pipeline interno de producción de creatividades (RRSS), separado de creatives (ads). Mismo bucket tenant-assets, prefijo pipeline/.';
|
||||
|
||||
-- Storage: reutiliza el bucket privado `tenant-assets` (ver src/lib/assets.ts),
|
||||
-- path = {tenant_id}/pipeline/{id}/cover.jpg y {tenant_id}/pipeline/{id}/slide-N.jpg
|
||||
67
src/app/api/creative-pipeline/media/route.ts
Normal file
67
src/app/api/creative-pipeline/media/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET ?path=<storage_path> — redirect to a short-lived signed URL. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const path = req.nextUrl.searchParams.get('path')?.trim() || '';
|
||||
if (!path) {
|
||||
return NextResponse.json({ error: 'path requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId) {
|
||||
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
|
||||
}
|
||||
|
||||
// El path siempre empieza por "{tenant_id}/pipeline/..." — evita servir
|
||||
// ficheros de otro tenant aunque conozcan la ruta.
|
||||
if (!path.startsWith(`${ctx.tenantId}/pipeline/`)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Doble check contra la fila (evita servir paths "adivinados").
|
||||
const headers = serviceHeaders();
|
||||
if (headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||
ctx.tenantId
|
||||
)}&or=(cover_path.eq.${encodeURIComponent(path)},slide_paths.cs.${encodeURIComponent(
|
||||
JSON.stringify([path])
|
||||
)})&select=id&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (r.ok) {
|
||||
const rows = await r.json();
|
||||
if (!rows?.length) {
|
||||
return NextResponse.json({ error: 'No encontrado' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const svc = serviceSupabase();
|
||||
if (!svc) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
const signed = await svc.storage.from(ASSETS_BUCKET).createSignedUrl(path, 300);
|
||||
if (signed.error || !signed.data?.signedUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: signed.error?.message || 'No signed URL' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.redirect(signed.data.signedUrl);
|
||||
}
|
||||
300
src/app/api/creative-pipeline/route.ts
Normal file
300
src/app/api/creative-pipeline/route.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
|
||||
const SELECT =
|
||||
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at';
|
||||
|
||||
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
|
||||
|
||||
const ALL_STATUS = [
|
||||
'propuesta',
|
||||
'en_diseno',
|
||||
'revision_interna',
|
||||
'aprobado',
|
||||
'rechazado',
|
||||
'publicado',
|
||||
] as const;
|
||||
type Status = (typeof ALL_STATUS)[number];
|
||||
const STATUS_SET = new Set<string>(ALL_STATUS);
|
||||
|
||||
// El cliente solo puede mover una pieza que esté en revisión interna,
|
||||
// y únicamente hacia aprobar o rechazar. El resto de transiciones (incluida
|
||||
// "publicado") son cosa del equipo de la agencia.
|
||||
const CLIENT_ALLOWED_TRANSITIONS: Record<string, Status[]> = {
|
||||
revision_interna: ['aprobado', 'rechazado'],
|
||||
};
|
||||
|
||||
function str(v: unknown, max = 2000): string | null {
|
||||
if (typeof v !== 'string') return null;
|
||||
const t = v.trim();
|
||||
if (!t) return null;
|
||||
return t.slice(0, max);
|
||||
}
|
||||
|
||||
function slidePaths(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === 'string').slice(0, 10);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const month = req.nextUrl.searchParams.get('month')?.trim() || null;
|
||||
const isAgency = isAgencyRole(userRole(user));
|
||||
|
||||
if (ctx.portfolioWide) {
|
||||
return NextResponse.json({ items: [], isAgency, warning: 'Selecciona un cliente en Contexto' });
|
||||
}
|
||||
if (!ctx.tenantId) {
|
||||
return NextResponse.json({ items: [], isAgency });
|
||||
}
|
||||
|
||||
let q = `${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||
ctx.tenantId
|
||||
)}&select=${SELECT}&order=month.asc,created_at.asc`;
|
||||
if (month) q += `&month=eq.${encodeURIComponent(month)}`;
|
||||
|
||||
if (ctx.viaView) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json(
|
||||
{ items: [], isAgency, warning: 'Service role required for superadmin view' },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
const r = await fetch(q, { headers, cache: 'no-store' });
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ items: [], isAgency, warning: await r.text() });
|
||||
}
|
||||
return NextResponse.json({ items: await r.json(), isAgency });
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('creative_pipeline')
|
||||
.select(SELECT)
|
||||
.order('month', { ascending: true })
|
||||
.order('created_at', { ascending: true });
|
||||
if (month) query = query.eq('month', month);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) {
|
||||
return NextResponse.json({ items: [], isAgency, warning: error.message });
|
||||
}
|
||||
return NextResponse.json({ items: data ?? [], isAgency });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
if (!agency) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Solo el equipo de la agencia puede crear piezas' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const title = str(body.title, 300);
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
||||
}
|
||||
const format = str(body.format, 20);
|
||||
|
||||
const row = {
|
||||
tenant_id: ctx.tenantId,
|
||||
title,
|
||||
external_ref: str(body.external_ref, 80),
|
||||
format: format && FORMATS.has(format) ? format : 'post',
|
||||
month: str(body.month, 7),
|
||||
caption: str(body.caption, 4000),
|
||||
notes: str(body.notes, 2000),
|
||||
status: 'propuesta',
|
||||
created_by: user.id,
|
||||
};
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/creative_pipeline`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({ item: Array.isArray(data) ? data[0] : data }, { status: 201 });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId) {
|
||||
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const id = str(body.id, 80);
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const headers = serviceHeaders();
|
||||
|
||||
// Resolve current row first — status transitions are checked against it.
|
||||
const curRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||
id
|
||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status`,
|
||||
{ headers: headers || undefined, cache: 'no-store' }
|
||||
);
|
||||
const curRows = curRes.ok ? await curRes.json() : [];
|
||||
const current: string | undefined = curRows?.[0]?.status;
|
||||
if (!current) {
|
||||
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
const clean: Record<string, unknown> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (typeof body.status === 'string' && STATUS_SET.has(body.status)) {
|
||||
const next = body.status as Status;
|
||||
if (agency) {
|
||||
clean.status = next;
|
||||
} else {
|
||||
const allowed = CLIENT_ALLOWED_TRANSITIONS[current] || [];
|
||||
if (!allowed.includes(next)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Como cliente solo puedes aprobar o rechazar una pieza en revisión interna` },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
clean.status = next;
|
||||
}
|
||||
if (next === 'aprobado') {
|
||||
clean.approved_by = user.id;
|
||||
clean.approved_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// Resto de campos: solo agencia los edita.
|
||||
if (agency) {
|
||||
const title = str(body.title, 300);
|
||||
if (title) clean.title = title;
|
||||
const month = str(body.month, 7);
|
||||
if (month) clean.month = month;
|
||||
const format = str(body.format, 20);
|
||||
if (format && FORMATS.has(format)) clean.format = format;
|
||||
if ('caption' in body) clean.caption = str(body.caption, 4000);
|
||||
if ('notes' in body) clean.notes = str(body.notes, 2000);
|
||||
if ('cover_path' in body) clean.cover_path = str(body.cover_path, 500);
|
||||
if ('cover_mime' in body) clean.cover_mime = str(body.cover_mime, 100);
|
||||
if ('slide_paths' in body) clean.slide_paths = slidePaths(body.slide_paths);
|
||||
}
|
||||
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||
id
|
||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(clean),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({ item: Array.isArray(data) ? data[0] : data });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
if (!agency) {
|
||||
return NextResponse.json({ error: 'Solo la agencia puede borrar' }, { status: 403 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId) {
|
||||
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
|
||||
}
|
||||
|
||||
const id = req.nextUrl.searchParams.get('id')?.trim() || '';
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||
id
|
||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
141
src/app/api/creative-pipeline/upload/route.ts
Normal file
141
src/app/api/creative-pipeline/upload/route.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
ASSETS_BUCKET,
|
||||
MAX_ASSET_BYTES,
|
||||
ensureAssetsBucket,
|
||||
sanitizeFileName,
|
||||
serviceSupabase,
|
||||
} from '@/lib/assets';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
|
||||
/**
|
||||
* POST multipart: sube portada o slide de una pieza del pipeline.
|
||||
* fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Solo el equipo de la agencia puede subir imágenes' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json({ error: 'Selecciona un cliente en Contexto' }, { status: 400 });
|
||||
}
|
||||
|
||||
const form = await req.formData();
|
||||
const file = form.get('file');
|
||||
const itemId = String(form.get('itemId') || '').trim();
|
||||
const slot = String(form.get('slot') || 'cover').trim();
|
||||
const index = Math.max(0, parseInt(String(form.get('index') || '0'), 10) || 0);
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'file requerido' }, { status: 400 });
|
||||
}
|
||||
if (!itemId) {
|
||||
return NextResponse.json({ error: 'itemId requerido' }, { status: 400 });
|
||||
}
|
||||
if (file.size <= 0 || file.size > MAX_ASSET_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Archivo vacío o > ${MAX_ASSET_BYTES / (1024 * 1024)} MB` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!ALLOWED_MIME.has(mime)) {
|
||||
return NextResponse.json({ error: 'Solo jpg/png/webp' }, { status: 400 });
|
||||
}
|
||||
|
||||
const bucketOk = await ensureAssetsBucket();
|
||||
if (!bucketOk.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: bucketOk.error || 'No se pudo asegurar el bucket' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const svc = serviceSupabase();
|
||||
if (!svc) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
// Verifica que la pieza existe y pertenece al tenant activo.
|
||||
const curRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||
itemId
|
||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,slide_paths`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
const curRows = curRes.ok ? await curRes.json() : [];
|
||||
if (!curRows?.[0]) {
|
||||
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
const ext = (sanitizeFileName(file.name || 'img.jpg').split('.').pop() || 'jpg').toLowerCase();
|
||||
const fileName = slot === 'cover' ? `cover.${ext}` : `slide-${index + 1}.${ext}`;
|
||||
const storagePath = `${ctx.tenantId}/pipeline/${itemId}/${fileName}`;
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
|
||||
contentType: mime,
|
||||
upsert: true,
|
||||
});
|
||||
if (up.error) {
|
||||
return NextResponse.json({ error: up.error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||
if (slot === 'cover') {
|
||||
patch.cover_path = storagePath;
|
||||
patch.cover_mime = mime;
|
||||
} else {
|
||||
const slides: string[] = Array.isArray(curRows[0].slide_paths)
|
||||
? [...curRows[0].slide_paths]
|
||||
: [];
|
||||
slides[index] = storagePath;
|
||||
patch.slide_paths = slides;
|
||||
}
|
||||
|
||||
const patchRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(itemId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
}
|
||||
);
|
||||
if (!patchRes.ok) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: await patchRes.text() }, { status: 500 });
|
||||
}
|
||||
const data = await patchRes.json();
|
||||
return NextResponse.json({
|
||||
item: Array.isArray(data) ? data[0] : data,
|
||||
storage_path: storagePath,
|
||||
});
|
||||
}
|
||||
11
src/app/dashboard/panel-cliente/page.tsx
Normal file
11
src/app/dashboard/panel-cliente/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Panel de Creatividades — pipeline editorial interno (Propuesta → Diseño →
|
||||
* Revisión interna → Aprobación), visible también para el cliente.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import CreativePipelineBoard from '@/components/CreativePipelineBoard';
|
||||
|
||||
export default function PanelClientePage() {
|
||||
return <CreativePipelineBoard />;
|
||||
}
|
||||
429
src/components/CreativePipelineBoard.tsx
Normal file
429
src/components/CreativePipelineBoard.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ImagePlus, Plus, Trash2, Upload } from 'lucide-react';
|
||||
import { PageHeader, Surface } from '@/components/ui';
|
||||
|
||||
type PipelineItem = {
|
||||
id: string;
|
||||
external_ref: string | null;
|
||||
title: string;
|
||||
format: 'post' | 'story' | 'reel' | 'carrusel';
|
||||
month: string | null;
|
||||
status: string;
|
||||
cover_path: string | null;
|
||||
cover_mime: string | null;
|
||||
slide_paths: string[];
|
||||
caption: string | null;
|
||||
notes: string | null;
|
||||
approved_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
propuesta: 'Propuesta',
|
||||
en_diseno: 'En diseño',
|
||||
revision_interna: 'Revisión interna',
|
||||
aprobado: 'Aprobado',
|
||||
rechazado: 'Rechazado',
|
||||
publicado: 'Publicado',
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
propuesta: 'bg-gray-100 text-gray-700',
|
||||
en_diseno: 'bg-blue-100 text-blue-700',
|
||||
revision_interna: 'bg-amber-100 text-amber-800',
|
||||
aprobado: 'bg-emerald-100 text-emerald-700',
|
||||
rechazado: 'bg-red-100 text-red-700',
|
||||
publicado: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
|
||||
const AGENCY_STATUS_ORDER = [
|
||||
'propuesta',
|
||||
'en_diseno',
|
||||
'revision_interna',
|
||||
'aprobado',
|
||||
'rechazado',
|
||||
'publicado',
|
||||
];
|
||||
|
||||
function mediaUrl(path: string | null) {
|
||||
if (!path) return null;
|
||||
return `/api/creative-pipeline/media?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
function monthLabel(m: string | null) {
|
||||
if (!m) return 'Sin mes';
|
||||
const [y, mm] = m.split('-');
|
||||
const d = new Date(Number(y), Number(mm) - 1, 1);
|
||||
return d.toLocaleDateString('es-ES', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
export default function CreativePipelineBoard() {
|
||||
const [items, setItems] = useState<PipelineItem[]>([]);
|
||||
const [isAgency, setIsAgency] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newFormat, setNewFormat] = useState<PipelineItem['format']>('post');
|
||||
const [newMonth, setNewMonth] = useState('');
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/creative-pipeline');
|
||||
const data = await res.json();
|
||||
setItems(data.items ?? []);
|
||||
setIsAgency(Boolean(data.isAgency));
|
||||
setWarning(data.warning ?? null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, PipelineItem[]>();
|
||||
for (const it of items) {
|
||||
const key = it.month || 'sin-mes';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(it);
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [items]);
|
||||
|
||||
async function createItem(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim()) return;
|
||||
setErr(null);
|
||||
const res = await fetch('/api/creative-pipeline', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: newTitle, format: newFormat, month: newMonth || null }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setErr(data.error || 'Error al crear');
|
||||
return;
|
||||
}
|
||||
setNewTitle('');
|
||||
setNewMonth('');
|
||||
setShowForm(false);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function setStatus(id: string, status: string) {
|
||||
setBusyId(id);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch('/api/creative-pipeline', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setErr(data.error || 'Error al cambiar estado');
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(id: string) {
|
||||
setBusyId(id);
|
||||
try {
|
||||
await fetch(`/api/creative-pipeline?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
await load();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
itemId: string,
|
||||
file: File,
|
||||
slot: 'cover' | 'slide',
|
||||
index = 0
|
||||
) {
|
||||
setBusyId(itemId);
|
||||
setErr(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('itemId', itemId);
|
||||
fd.append('slot', slot);
|
||||
fd.append('index', String(index));
|
||||
const res = await fetch('/api/creative-pipeline/upload', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setErr(data.error || 'Error al subir imagen');
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Producción editorial"
|
||||
title="Panel de Creatividades"
|
||||
description="Propuesta → Diseño → Revisión interna → Aprobación del cliente."
|
||||
actions={
|
||||
isAgency ? (
|
||||
<button
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Nueva pieza
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{warning ? (
|
||||
<div className="mb-4 rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-sm text-amber-800">
|
||||
{warning}
|
||||
</div>
|
||||
) : null}
|
||||
{err ? (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-800">
|
||||
{err}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showForm && isAgency ? (
|
||||
<Surface className="mb-6">
|
||||
<form onSubmit={createItem} className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600">Título</label>
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
placeholder="Certifica tu nivel"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600">Formato</label>
|
||||
<select
|
||||
value={newFormat}
|
||||
onChange={(e) => setNewFormat(e.target.value as PipelineItem['format'])}
|
||||
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="post">Post</option>
|
||||
<option value="story">Story</option>
|
||||
<option value="reel">Reel</option>
|
||||
<option value="carrusel">Carrusel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600">Mes</label>
|
||||
<input
|
||||
type="month"
|
||||
value={newMonth}
|
||||
onChange={(e) => setNewMonth(e.target.value)}
|
||||
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
Crear
|
||||
</button>
|
||||
</form>
|
||||
</Surface>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">Cargando…</p>
|
||||
) : items.length === 0 ? (
|
||||
<Surface>
|
||||
<p className="text-sm text-gray-500">Todavía no hay piezas en el pipeline.</p>
|
||||
</Surface>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{grouped.map(([month, monthItems]) => (
|
||||
<div key={month}>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
{monthLabel(month === 'sin-mes' ? null : month)}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{monthItems.map((it) => (
|
||||
<PieceCard
|
||||
key={it.id}
|
||||
item={it}
|
||||
isAgency={isAgency}
|
||||
busy={busyId === it.id}
|
||||
onStatus={(s) => setStatus(it.id, s)}
|
||||
onDelete={() => removeItem(it.id)}
|
||||
onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PieceCard({
|
||||
item,
|
||||
isAgency,
|
||||
busy,
|
||||
onStatus,
|
||||
onDelete,
|
||||
onUpload,
|
||||
}: {
|
||||
item: PipelineItem;
|
||||
isAgency: boolean;
|
||||
busy: boolean;
|
||||
onStatus: (status: string) => void;
|
||||
onDelete: () => void;
|
||||
onUpload: (file: File, slot: 'cover' | 'slide', index: number) => void;
|
||||
}) {
|
||||
const cover = mediaUrl(item.cover_path);
|
||||
const slides = item.slide_paths || [];
|
||||
const isCarrusel = item.format === 'carrusel';
|
||||
|
||||
const clientCanDecide = !isAgency && item.status === 'revision_interna';
|
||||
|
||||
return (
|
||||
<Surface padded={false} className="overflow-hidden flex flex-col">
|
||||
<div className="relative aspect-[4/5] bg-gray-100">
|
||||
{cover ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={cover} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-300">
|
||||
<ImagePlus className="h-10 w-10" />
|
||||
</div>
|
||||
)}
|
||||
{isAgency ? (
|
||||
<label className="absolute bottom-2 right-2 cursor-pointer rounded-full bg-black/60 p-2 text-white hover:bg-black/80">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) onUpload(f, 'cover', 0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isCarrusel ? (
|
||||
<div className="flex gap-1 overflow-x-auto p-2 bg-gray-50">
|
||||
{Array.from({ length: 4 }).map((_, i) => {
|
||||
const url = mediaUrl(slides[i] || null);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="relative h-14 w-11 shrink-0 overflow-hidden rounded bg-gray-200"
|
||||
>
|
||||
{url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={url} alt={`slide ${i + 1}`} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
{isAgency ? (
|
||||
<label className="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/0 hover:bg-black/40 text-transparent hover:text-white text-[10px]">
|
||||
+
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) onUpload(f, 'slide', i);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-gray-900 line-clamp-2">{item.title}</p>
|
||||
{isAgency ? (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
className="shrink-0 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
STATUS_COLOR[item.status] || 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABEL[item.status] || item.status}
|
||||
</span>
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
{isAgency ? (
|
||||
<select
|
||||
value={item.status}
|
||||
disabled={busy}
|
||||
onChange={(e) => onStatus(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-2 py-1.5 text-xs"
|
||||
>
|
||||
{AGENCY_STATUS_ORDER.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STATUS_LABEL[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : clientCanDecide ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => onStatus('aprobado')}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-2 py-1.5 text-xs font-medium text-white hover:bg-emerald-700"
|
||||
>
|
||||
Aprobar
|
||||
</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => onStatus('rechazado')}
|
||||
className="flex-1 rounded-lg bg-red-600 px-2 py-1.5 text-xs font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Rechazar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">
|
||||
{item.status === 'aprobado'
|
||||
? 'Aprobada por ti'
|
||||
: 'Pendiente del equipo de 4Ventures'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -100,6 +100,13 @@ const NAV_ITEMS: NavItem[] = [
|
||||
iconClass: 'text-orange-500',
|
||||
moduleId: 'creatives',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/panel-cliente',
|
||||
label: 'Panel de Creatividades',
|
||||
icon: Megaphone,
|
||||
iconClass: 'text-pink-500',
|
||||
moduleId: 'panel_cliente',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/asistente',
|
||||
label: 'Asistente IA Financiero',
|
||||
|
||||
@@ -11,6 +11,7 @@ export type DashboardModuleId =
|
||||
| 'organico'
|
||||
| 'activos'
|
||||
| 'creatives'
|
||||
| 'panel_cliente'
|
||||
| 'asistente';
|
||||
|
||||
export type DashboardModuleDef = {
|
||||
@@ -93,6 +94,13 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
|
||||
providers: ['google_drive', 'meta_ads', 'google_ads'],
|
||||
hint: 'Flujo creativo',
|
||||
},
|
||||
{
|
||||
id: 'panel_cliente',
|
||||
href: '/dashboard/panel-cliente',
|
||||
label: 'Panel de Creatividades',
|
||||
providers: [],
|
||||
hint: 'Producción editorial: propuesta, diseño, revisión y aprobación',
|
||||
},
|
||||
{
|
||||
id: 'asistente',
|
||||
href: '/dashboard/asistente',
|
||||
|
||||
Reference in New Issue
Block a user