fix(pipeline): editar formato Post/Story y bloquear horarios pasados
Permite cambiar el formato al editar una pieza, normaliza Stories→story al guardar y rechaza programaciones en fecha/hora ya pasada. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -189,7 +189,7 @@ async function syncPieceToPipeline(
|
|||||||
const existing = find.ok ? ((await find.json()) as { id: string }[]) : [];
|
const existing = find.ok ? ((await find.json()) as { id: string }[]) : [];
|
||||||
const row: Record<string, unknown> = {
|
const row: Record<string, unknown> = {
|
||||||
title: piece.title,
|
title: piece.title,
|
||||||
format: mapFormatToPipeline(piece.format, piece.kind),
|
format: mapFormatToPipeline(piece.format, piece.kind) || 'post',
|
||||||
month,
|
month,
|
||||||
caption: caption || null,
|
caption: caption || null,
|
||||||
notes: piece.pillar ? `Pilar: ${piece.pillar}` : null,
|
notes: piece.pillar ? `Pilar: ${piece.pillar}` : null,
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildRrssGenerationBrief,
|
buildRrssGenerationBrief,
|
||||||
generationRequestNoteMarker,
|
generationRequestNoteMarker,
|
||||||
isRrssPipelineFormat,
|
|
||||||
type RrssPipelineFormat,
|
type RrssPipelineFormat,
|
||||||
} from '@/lib/rrss-generation';
|
} from '@/lib/rrss-generation';
|
||||||
|
import {
|
||||||
|
isScheduledAtInPast,
|
||||||
|
normalizePipelineFormat,
|
||||||
|
parseScheduledAt,
|
||||||
|
} from '@/lib/pipeline-schedule';
|
||||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -58,13 +62,23 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const title = String(body.title || '').trim();
|
const title = String(body.title || '').trim();
|
||||||
const format = body.format as RrssPipelineFormat;
|
const format = normalizePipelineFormat(body.format) as RrssPipelineFormat;
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!isRrssPipelineFormat(format)) {
|
const scheduledAt = parseScheduledAt(body.scheduled_at);
|
||||||
|
if (scheduledAt === undefined) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'format debe ser post|story|reel|carrusel' },
|
{ error: 'scheduled_at inválido' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scheduledAt && isScheduledAtInPast(scheduledAt)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.',
|
||||||
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -160,8 +174,7 @@ export async function POST(req: NextRequest) {
|
|||||||
caption,
|
caption,
|
||||||
notes: notesBase || null,
|
notes: notesBase || null,
|
||||||
calendar_piece_id: calendarPieceId,
|
calendar_piece_id: calendarPieceId,
|
||||||
scheduled_at:
|
scheduled_at: scheduledAt ?? null,
|
||||||
typeof body.scheduled_at === 'string' ? body.scheduled_at : null,
|
|
||||||
channels: Array.isArray(body.channels)
|
channels: Array.isArray(body.channels)
|
||||||
? body.channels
|
? body.channels
|
||||||
: ['instagram', 'facebook'],
|
: ['instagram', 'facebook'],
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
import {
|
import {
|
||||||
channelsForCalendarPlatform,
|
channelsForCalendarPlatform,
|
||||||
|
isScheduledAtInPast,
|
||||||
normalizeChannels,
|
normalizeChannels,
|
||||||
|
normalizePipelineFormat,
|
||||||
parseScheduledAt,
|
parseScheduledAt,
|
||||||
PUBLISH_STATUSES,
|
PUBLISH_STATUSES,
|
||||||
} from '@/lib/pipeline-schedule';
|
} from '@/lib/pipeline-schedule';
|
||||||
@@ -36,8 +38,6 @@ import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
|||||||
const SELECT =
|
const SELECT =
|
||||||
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error,calendar_piece_id';
|
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error,calendar_piece_id';
|
||||||
|
|
||||||
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
|
|
||||||
|
|
||||||
function str(v: unknown, max = 2000): string | null {
|
function str(v: unknown, max = 2000): string | null {
|
||||||
if (typeof v !== 'string') return null;
|
if (typeof v !== 'string') return null;
|
||||||
const t = v.trim();
|
const t = v.trim();
|
||||||
@@ -190,7 +190,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const calendarPieceId = str(body.calendar_piece_id, 120);
|
const calendarPieceId = str(body.calendar_piece_id, 120);
|
||||||
|
|
||||||
let title = str(body.title, 300);
|
let title = str(body.title, 300);
|
||||||
let format = str(body.format, 20);
|
let format = str(body.format, 40);
|
||||||
let month = str(body.month, 7);
|
let month = str(body.month, 7);
|
||||||
let caption =
|
let caption =
|
||||||
sanitizePublishCaption(str(body.caption, 4000) || '', { maxLen: 4000 })
|
sanitizePublishCaption(str(body.caption, 4000) || '', { maxLen: 4000 })
|
||||||
@@ -237,11 +237,21 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scheduledAt && isScheduledAtInPast(scheduledAt)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const row: Record<string, unknown> = {
|
const row: Record<string, unknown> = {
|
||||||
tenant_id: ctx.tenantId,
|
tenant_id: ctx.tenantId,
|
||||||
title,
|
title,
|
||||||
external_ref: str(body.external_ref, 80),
|
external_ref: str(body.external_ref, 80),
|
||||||
format: format && FORMATS.has(format) ? format : 'post',
|
format: normalizePipelineFormat(format),
|
||||||
month: month || null,
|
month: month || null,
|
||||||
caption: caption || null,
|
caption: caption || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
@@ -422,8 +432,9 @@ export async function PATCH(req: NextRequest) {
|
|||||||
if (title) clean.title = title;
|
if (title) clean.title = title;
|
||||||
const month = str(body.month, 7);
|
const month = str(body.month, 7);
|
||||||
if (month) clean.month = month;
|
if (month) clean.month = month;
|
||||||
const format = str(body.format, 20);
|
if ('format' in body && body.format != null) {
|
||||||
if (format && FORMATS.has(format)) clean.format = format;
|
clean.format = normalizePipelineFormat(body.format);
|
||||||
|
}
|
||||||
if ('caption' in body) {
|
if ('caption' in body) {
|
||||||
clean.caption =
|
clean.caption =
|
||||||
sanitizePublishCaption(str(body.caption, 4000) || '', {
|
sanitizePublishCaption(str(body.caption, 4000) || '', {
|
||||||
@@ -447,6 +458,15 @@ export async function PATCH(req: NextRequest) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (parsed && isScheduledAtInPast(parsed)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
clean.scheduled_at = parsed;
|
clean.scheduled_at = parsed;
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
// Cron publica queued + status aprobada. Programar implica ya validada.
|
// Cron publica queued + status aprobada. Programar implica ya validada.
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader, Surface } from '@/components/ui';
|
import { PageHeader, Surface } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
|
isScheduledAtInPast,
|
||||||
|
nextLocalScheduleSlot,
|
||||||
|
normalizePipelineFormat,
|
||||||
scheduleLabel,
|
scheduleLabel,
|
||||||
toDatetimeLocalValue,
|
toDatetimeLocalValue,
|
||||||
type PublishChannel,
|
type PublishChannel,
|
||||||
} from '@/lib/pipeline-schedule';
|
} from '@/lib/pipeline-schedule';
|
||||||
import {
|
import {
|
||||||
buildCaption,
|
buildCaption,
|
||||||
|
isLocalScheduleInPast,
|
||||||
mapFormatToPipeline,
|
mapFormatToPipeline,
|
||||||
mapPipelineFormatToCalendar,
|
mapPipelineFormatToCalendar,
|
||||||
scheduledAtFromPiece,
|
scheduledAtFromPiece,
|
||||||
@@ -245,14 +249,15 @@ export default function CreativePipelineBoard() {
|
|||||||
function applyCalendarPiece(id: string) {
|
function applyCalendarPiece(id: string) {
|
||||||
setCalPick(id);
|
setCalPick(id);
|
||||||
if (id === '__new__') {
|
if (id === '__new__') {
|
||||||
|
const slot = nextLocalScheduleSlot();
|
||||||
setNewTitle('');
|
setNewTitle('');
|
||||||
setNewCaption('');
|
setNewCaption('');
|
||||||
setNewHook('');
|
setNewHook('');
|
||||||
setNewPillar('');
|
setNewPillar('');
|
||||||
setNewFormat('post');
|
setNewFormat('post');
|
||||||
setNewMonth('');
|
setNewMonth(slot.date.slice(0, 7));
|
||||||
setNewDate('');
|
setNewDate(slot.date);
|
||||||
setNewTime('10:00');
|
setNewTime(slot.time);
|
||||||
clearMediaSelection();
|
clearMediaSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -263,11 +268,27 @@ export default function CreativePipelineBoard() {
|
|||||||
setNewCaption(buildCaption(p) || p.body || '');
|
setNewCaption(buildCaption(p) || p.body || '');
|
||||||
setNewPillar(p.pillar || '');
|
setNewPillar(p.pillar || '');
|
||||||
setNewFormat(
|
setNewFormat(
|
||||||
mapFormatToPipeline(p.format, p.kind) as PipelineItem['format']
|
normalizePipelineFormat(
|
||||||
|
mapFormatToPipeline(p.format, p.kind)
|
||||||
|
) as PipelineItem['format']
|
||||||
);
|
);
|
||||||
setNewMonth(p.month || p.date.slice(0, 7));
|
const date = p.date.slice(0, 10);
|
||||||
setNewDate(p.date.slice(0, 10));
|
let time = (p.time || '10:00').slice(0, 5);
|
||||||
setNewTime((p.time || '10:00').slice(0, 5));
|
if (isLocalScheduleInPast(date, time)) {
|
||||||
|
const slot = nextLocalScheduleSlot();
|
||||||
|
time = slot.date === date ? slot.time : slot.time;
|
||||||
|
// If calendar day is already past entirely, keep the day but bump time;
|
||||||
|
// if still past (e.g. yesterday), move to next slot date.
|
||||||
|
if (isLocalScheduleInPast(date, time)) {
|
||||||
|
setNewMonth(slot.date.slice(0, 7));
|
||||||
|
setNewDate(slot.date);
|
||||||
|
setNewTime(slot.time);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNewMonth(p.month || date.slice(0, 7));
|
||||||
|
setNewDate(date);
|
||||||
|
setNewTime(time);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
@@ -339,6 +360,12 @@ export default function CreativePipelineBoard() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newTitle.trim()) return;
|
if (!newTitle.trim()) return;
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
if (newDate && isLocalScheduleInPast(newDate, newTime || '10:00')) {
|
||||||
|
setErr(
|
||||||
|
'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -348,12 +375,13 @@ export default function CreativePipelineBoard() {
|
|||||||
calendarPieceId && calendarPieceId !== '__new__'
|
calendarPieceId && calendarPieceId !== '__new__'
|
||||||
? calPieces.find((p) => p.id === calendarPieceId)
|
? calPieces.find((p) => p.id === calendarPieceId)
|
||||||
: null;
|
: null;
|
||||||
|
const resolvedFormat = normalizePipelineFormat(newFormat);
|
||||||
|
|
||||||
if (syncCalendar) {
|
if (syncCalendar) {
|
||||||
const date =
|
const date =
|
||||||
newDate ||
|
newDate ||
|
||||||
(newMonth ? `${newMonth}-01` : new Date().toISOString().slice(0, 10));
|
(newMonth ? `${newMonth}-01` : new Date().toISOString().slice(0, 10));
|
||||||
const calFmt = mapPipelineFormatToCalendar(newFormat);
|
const calFmt = mapPipelineFormatToCalendar(resolvedFormat);
|
||||||
const calRes = await fetch('/api/calendar/pieces', {
|
const calRes = await fetch('/api/calendar/pieces', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -389,7 +417,7 @@ export default function CreativePipelineBoard() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: newTitle.trim(),
|
title: newTitle.trim(),
|
||||||
format: newFormat,
|
format: resolvedFormat,
|
||||||
month: newMonth || (newDate ? newDate.slice(0, 7) : null),
|
month: newMonth || (newDate ? newDate.slice(0, 7) : null),
|
||||||
caption: newCaption || null,
|
caption: newCaption || null,
|
||||||
notes: newPillar ? `Pilar: ${newPillar}` : null,
|
notes: newPillar ? `Pilar: ${newPillar}` : null,
|
||||||
@@ -403,7 +431,8 @@ export default function CreativePipelineBoard() {
|
|||||||
title: pickedCal.title,
|
title: pickedCal.title,
|
||||||
date: pickedCal.date,
|
date: pickedCal.date,
|
||||||
time: pickedCal.time,
|
time: pickedCal.time,
|
||||||
format: pickedCal.format,
|
format: mapPipelineFormatToCalendar(resolvedFormat).format,
|
||||||
|
kind: mapPipelineFormatToCalendar(resolvedFormat).kind,
|
||||||
pillar: pickedCal.pillar || newPillar,
|
pillar: pickedCal.pillar || newPillar,
|
||||||
hook: newHook || pickedCal.hook,
|
hook: newHook || pickedCal.hook,
|
||||||
body: newCaption || pickedCal.body,
|
body: newCaption || pickedCal.body,
|
||||||
@@ -415,6 +444,8 @@ export default function CreativePipelineBoard() {
|
|||||||
title: newTitle.trim(),
|
title: newTitle.trim(),
|
||||||
date: newDate || null,
|
date: newDate || null,
|
||||||
time: newTime || null,
|
time: newTime || null,
|
||||||
|
format: mapPipelineFormatToCalendar(resolvedFormat).format,
|
||||||
|
kind: mapPipelineFormatToCalendar(resolvedFormat).kind,
|
||||||
pillar: newPillar || null,
|
pillar: newPillar || null,
|
||||||
hook: newHook || null,
|
hook: newHook || null,
|
||||||
body: newCaption || null,
|
body: newCaption || null,
|
||||||
@@ -442,7 +473,7 @@ export default function CreativePipelineBoard() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: newTitle.trim(),
|
title: newTitle.trim(),
|
||||||
format: newFormat,
|
format: resolvedFormat,
|
||||||
month: newMonth || (newDate ? newDate.slice(0, 7) : null),
|
month: newMonth || (newDate ? newDate.slice(0, 7) : null),
|
||||||
caption: newCaption || null,
|
caption: newCaption || null,
|
||||||
notes: newPillar ? `Pilar: ${newPillar}` : null,
|
notes: newPillar ? `Pilar: ${newPillar}` : null,
|
||||||
@@ -556,12 +587,13 @@ export default function CreativePipelineBoard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Edit title/copy on an existing card; optionally sync RRSS calendar piece. */
|
/** Edit title/copy/format on an existing card; optionally sync RRSS calendar piece. */
|
||||||
async function saveContent(
|
async function saveContent(
|
||||||
id: string,
|
id: string,
|
||||||
payload: {
|
payload: {
|
||||||
title: string;
|
title: string;
|
||||||
caption: string;
|
caption: string;
|
||||||
|
format?: PipelineItem['format'];
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
syncCalendar: boolean;
|
syncCalendar: boolean;
|
||||||
calendarDate?: string | null;
|
calendarDate?: string | null;
|
||||||
@@ -569,16 +601,31 @@ export default function CreativePipelineBoard() {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (!isAgency) return;
|
if (!isAgency) return;
|
||||||
|
if (
|
||||||
|
payload.calendarDate &&
|
||||||
|
isLocalScheduleInPast(
|
||||||
|
payload.calendarDate,
|
||||||
|
payload.calendarTime || '10:00'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setErr(
|
||||||
|
'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const target = items.find((x) => x.id === id) || previewItem;
|
const target = items.find((x) => x.id === id) || previewItem;
|
||||||
|
const resolvedFormat = normalizePipelineFormat(
|
||||||
|
payload.format || target?.format || 'post'
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
payload.syncCalendar &&
|
payload.syncCalendar &&
|
||||||
target?.calendar_piece_id &&
|
target?.calendar_piece_id &&
|
||||||
payload.calendarDate
|
payload.calendarDate
|
||||||
) {
|
) {
|
||||||
const calFmt = mapPipelineFormatToCalendar(target.format || 'post');
|
const calFmt = mapPipelineFormatToCalendar(resolvedFormat);
|
||||||
const calRes = await fetch('/api/calendar/pieces', {
|
const calRes = await fetch('/api/calendar/pieces', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -615,6 +662,7 @@ export default function CreativePipelineBoard() {
|
|||||||
id,
|
id,
|
||||||
title: payload.title.trim(),
|
title: payload.title.trim(),
|
||||||
caption: payload.caption || null,
|
caption: payload.caption || null,
|
||||||
|
format: resolvedFormat,
|
||||||
notes: payload.notes ?? undefined,
|
notes: payload.notes ?? undefined,
|
||||||
...(sched !== undefined ? { scheduled_at: sched } : {}),
|
...(sched !== undefined ? { scheduled_at: sched } : {}),
|
||||||
}),
|
}),
|
||||||
@@ -1019,8 +1067,13 @@ export default function CreativePipelineBoard() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={newDate}
|
value={newDate}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setNewDate(e.target.value);
|
const d = e.target.value;
|
||||||
if (e.target.value) setNewMonth(e.target.value.slice(0, 7));
|
setNewDate(d);
|
||||||
|
if (d) setNewMonth(d.slice(0, 7));
|
||||||
|
if (d && isLocalScheduleInPast(d, newTime || '10:00')) {
|
||||||
|
const slot = nextLocalScheduleSlot();
|
||||||
|
setNewTime(slot.date === d ? slot.time : slot.time);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -1035,6 +1088,12 @@ export default function CreativePipelineBoard() {
|
|||||||
onChange={(e) => setNewTime(e.target.value)}
|
onChange={(e) => setNewTime(e.target.value)}
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
|
{newDate &&
|
||||||
|
isLocalScheduleInPast(newDate, newTime || '10:00') ? (
|
||||||
|
<p className="mt-1 text-[11px] text-red-600">
|
||||||
|
Ese horario ya ha pasado.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600">
|
<label className="block text-xs font-medium text-gray-600">
|
||||||
@@ -1685,6 +1744,7 @@ function DetailModal({
|
|||||||
onSaveContent: (payload: {
|
onSaveContent: (payload: {
|
||||||
title: string;
|
title: string;
|
||||||
caption: string;
|
caption: string;
|
||||||
|
format?: PipelineItem['format'];
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
syncCalendar: boolean;
|
syncCalendar: boolean;
|
||||||
calendarDate?: string | null;
|
calendarDate?: string | null;
|
||||||
@@ -1718,14 +1778,21 @@ function DetailModal({
|
|||||||
);
|
);
|
||||||
const [editTitle, setEditTitle] = useState(item.title);
|
const [editTitle, setEditTitle] = useState(item.title);
|
||||||
const [editCaption, setEditCaption] = useState(item.caption || '');
|
const [editCaption, setEditCaption] = useState(item.caption || '');
|
||||||
|
const [editFormat, setEditFormat] = useState<PipelineItem['format']>(
|
||||||
|
normalizePipelineFormat(item.format)
|
||||||
|
);
|
||||||
const [syncCal, setSyncCal] = useState(Boolean(item.calendar_piece_id));
|
const [syncCal, setSyncCal] = useState(Boolean(item.calendar_piece_id));
|
||||||
|
const [schedErr, setSchedErr] = useState<string | null>(null);
|
||||||
const [editDate, setEditDate] = useState(
|
const [editDate, setEditDate] = useState(
|
||||||
(item.scheduled_at || item.created_at || '').slice(0, 10)
|
(item.scheduled_at || item.created_at || '').slice(0, 10)
|
||||||
);
|
);
|
||||||
const [editTime, setEditTime] = useState(() => {
|
const [editTime, setEditTime] = useState(() => {
|
||||||
if (!item.scheduled_at) return '10:00';
|
if (!item.scheduled_at) {
|
||||||
|
const slot = nextLocalScheduleSlot();
|
||||||
|
return slot.time;
|
||||||
|
}
|
||||||
const d = new Date(item.scheduled_at);
|
const d = new Date(item.scheduled_at);
|
||||||
if (Number.isNaN(d.getTime())) return '10:00';
|
if (Number.isNaN(d.getTime())) return nextLocalScheduleSlot().time;
|
||||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1741,7 +1808,9 @@ function DetailModal({
|
|||||||
setChLi(ch.includes('linkedin'));
|
setChLi(ch.includes('linkedin'));
|
||||||
setEditTitle(item.title);
|
setEditTitle(item.title);
|
||||||
setEditCaption(item.caption || '');
|
setEditCaption(item.caption || '');
|
||||||
|
setEditFormat(normalizePipelineFormat(item.format));
|
||||||
setSyncCal(Boolean(item.calendar_piece_id));
|
setSyncCal(Boolean(item.calendar_piece_id));
|
||||||
|
setSchedErr(null);
|
||||||
setEditDate((item.scheduled_at || item.created_at || '').slice(0, 10));
|
setEditDate((item.scheduled_at || item.created_at || '').slice(0, 10));
|
||||||
if (item.scheduled_at) {
|
if (item.scheduled_at) {
|
||||||
const d = new Date(item.scheduled_at);
|
const d = new Date(item.scheduled_at);
|
||||||
@@ -1751,13 +1820,13 @@ function DetailModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [item.id, item.scheduled_at, item.channels, item.title, item.caption, item.calendar_piece_id, item.created_at]);
|
}, [item.id, item.scheduled_at, item.channels, item.title, item.caption, item.format, item.calendar_piece_id, item.created_at]);
|
||||||
|
|
||||||
const activePath = allImages[activeIdx] || item.cover_path;
|
const activePath = allImages[activeIdx] || item.cover_path;
|
||||||
const currentUrl = mediaUrl(activePath);
|
const currentUrl = mediaUrl(activePath);
|
||||||
const igUrl = getInstagramUrl(item.notes);
|
const igUrl = getInstagramUrl(item.notes);
|
||||||
const formatInfo = FORMAT_CONFIG[item.format] || {
|
const formatInfo = FORMAT_CONFIG[editFormat] || FORMAT_CONFIG[item.format] || {
|
||||||
label: item.format,
|
label: editFormat || item.format,
|
||||||
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
|
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1765,6 +1834,7 @@ function DetailModal({
|
|||||||
const clientCanDecide = !isAgency && clientCanDecideStatus(status);
|
const clientCanDecide = !isAgency && clientCanDecideStatus(status);
|
||||||
|
|
||||||
function submitSchedule() {
|
function submitSchedule() {
|
||||||
|
setSchedErr(null);
|
||||||
const channels: PublishChannel[] = [];
|
const channels: PublishChannel[] = [];
|
||||||
if (chIg) channels.push('instagram');
|
if (chIg) channels.push('instagram');
|
||||||
if (chFb) channels.push('facebook');
|
if (chFb) channels.push('facebook');
|
||||||
@@ -1779,6 +1849,20 @@ function DetailModal({
|
|||||||
: [igIso, fbIso]
|
: [igIso, fbIso]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.sort()[0] || null;
|
.sort()[0] || null;
|
||||||
|
if (primary && isScheduledAtInPast(primary)) {
|
||||||
|
setSchedErr(
|
||||||
|
'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (igIso && isScheduledAtInPast(igIso)) {
|
||||||
|
setSchedErr('El horario de Instagram ya ha pasado.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fbIso && isScheduledAtInPast(fbIso)) {
|
||||||
|
setSchedErr('El horario de Facebook ya ha pasado.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSaveSchedule({
|
onSaveSchedule({
|
||||||
scheduled_at: primary,
|
scheduled_at: primary,
|
||||||
channels: channels.length ? channels : ['instagram'],
|
channels: channels.length ? channels : ['instagram'],
|
||||||
@@ -1801,9 +1885,20 @@ function DetailModal({
|
|||||||
|
|
||||||
function submitContent() {
|
function submitContent() {
|
||||||
if (!editTitle.trim()) return;
|
if (!editTitle.trim()) return;
|
||||||
|
setSchedErr(null);
|
||||||
|
if (
|
||||||
|
editDate &&
|
||||||
|
isLocalScheduleInPast(editDate, editTime || '10:00')
|
||||||
|
) {
|
||||||
|
setSchedErr(
|
||||||
|
'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSaveContent({
|
onSaveContent({
|
||||||
title: editTitle.trim(),
|
title: editTitle.trim(),
|
||||||
caption: editCaption,
|
caption: editCaption,
|
||||||
|
format: editFormat,
|
||||||
syncCalendar: syncCal && Boolean(item.calendar_piece_id),
|
syncCalendar: syncCal && Boolean(item.calendar_piece_id),
|
||||||
calendarDate: editDate || null,
|
calendarDate: editDate || null,
|
||||||
calendarTime: editTime || '10:00',
|
calendarTime: editTime || '10:00',
|
||||||
@@ -1923,6 +2018,23 @@ function DetailModal({
|
|||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-1.5 text-sm font-semibold text-gray-900"
|
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-1.5 text-sm font-semibold text-gray-900"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600">
|
||||||
|
Formato
|
||||||
|
<select
|
||||||
|
value={editFormat}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditFormat(
|
||||||
|
e.target.value as PipelineItem['format']
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-1.5 text-sm text-gray-900"
|
||||||
|
>
|
||||||
|
<option value="post">Post</option>
|
||||||
|
<option value="story">Story</option>
|
||||||
|
<option value="reel">Reel</option>
|
||||||
|
<option value="carrusel">Carrusel</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{monthLabel(item.month)}{' '}
|
{monthLabel(item.month)}{' '}
|
||||||
{item.external_ref ? `· Ref: ${item.external_ref}` : ''}
|
{item.external_ref ? `· Ref: ${item.external_ref}` : ''}
|
||||||
@@ -1978,7 +2090,15 @@ function DetailModal({
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={editDate}
|
value={editDate}
|
||||||
onChange={(e) => setEditDate(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const d = e.target.value;
|
||||||
|
setEditDate(d);
|
||||||
|
if (d && isLocalScheduleInPast(d, editTime || '10:00')) {
|
||||||
|
const slot = nextLocalScheduleSlot();
|
||||||
|
if (slot.date === d) setEditTime(slot.time);
|
||||||
|
else setEditTime(slot.time);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="mt-0.5 w-full rounded-lg border border-gray-200 px-2 py-1 text-xs"
|
className="mt-0.5 w-full rounded-lg border border-gray-200 px-2 py-1 text-xs"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -1992,6 +2112,11 @@ function DetailModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{schedErr ? (
|
||||||
|
<p className="text-[11px] text-red-700 bg-red-50 border border-red-200 rounded-lg px-2 py-1.5">
|
||||||
|
{schedErr}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{item.calendar_piece_id ? (
|
{item.calendar_piece_id ? (
|
||||||
<label className="flex items-start gap-2 text-[11px] text-gray-700">
|
<label className="flex items-start gap-2 text-[11px] text-gray-700">
|
||||||
<input
|
<input
|
||||||
@@ -2152,6 +2277,11 @@ function DetailModal({
|
|||||||
{item.publish_error}
|
{item.publish_error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{schedErr ? (
|
||||||
|
<p className="text-[11px] text-red-700 bg-red-50 border border-red-200 rounded-lg px-2 py-1.5">
|
||||||
|
{schedErr}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -58,9 +58,10 @@ export function piecesFromDocs(docs: CalendarDoc[]): CalendarPieceRow[] {
|
|||||||
|
|
||||||
export function mapFormatToPipeline(format?: string, kind?: string): string {
|
export function mapFormatToPipeline(format?: string, kind?: string): string {
|
||||||
const hay = `${format || ''} ${kind || ''}`.toLowerCase();
|
const hay = `${format || ''} ${kind || ''}`.toLowerCase();
|
||||||
if (hay.includes('stor')) return 'story';
|
// Match Stories / Story / historia (substring "stor") and explicit kind
|
||||||
if (hay.includes('reel')) return 'reel';
|
if (/stor|historia/.test(hay) || kind === 'story') return 'story';
|
||||||
if (hay.includes('carrusel') || hay.includes('carousel')) return 'carrusel';
|
if (/reel/.test(hay) || kind === 'reel') return 'reel';
|
||||||
|
if (/carrusel|carousel/.test(hay)) return 'carrusel';
|
||||||
return 'post';
|
return 'post';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +91,17 @@ export function scheduledAtFromPiece(piece: Pick<RrssPiece, 'date' | 'time'>): s
|
|||||||
return iso.toISOString();
|
return iso.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True if local date+time is already past (for form validation). */
|
||||||
|
export function isLocalScheduleInPast(
|
||||||
|
date: string,
|
||||||
|
time: string,
|
||||||
|
graceMs = 60_000
|
||||||
|
): boolean {
|
||||||
|
const iso = scheduledAtFromPiece({ date, time });
|
||||||
|
if (!iso) return false;
|
||||||
|
return Date.parse(iso) < Date.now() - graceMs;
|
||||||
|
}
|
||||||
|
|
||||||
export function newPieceId(date: string, title: string): string {
|
export function newPieceId(date: string, title: string): string {
|
||||||
return `${date}-${slugify(title || 'pieza')}-${Math.random().toString(36).slice(2, 7)}`;
|
return `${date}-${slugify(title || 'pieza')}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,64 @@ export function parseScheduledAt(raw: unknown): string | null | undefined {
|
|||||||
return d.toISOString();
|
return d.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Grace for clock skew when rejecting schedules in the past. */
|
||||||
|
export const SCHEDULE_PAST_GRACE_MS = 60_000;
|
||||||
|
|
||||||
|
/** True if ISO timestamp is already in the past (beyond grace). */
|
||||||
|
export function isScheduledAtInPast(
|
||||||
|
iso: string | null | undefined,
|
||||||
|
graceMs = SCHEDULE_PAST_GRACE_MS
|
||||||
|
): boolean {
|
||||||
|
if (!iso) return false;
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
if (Number.isNaN(t)) return false;
|
||||||
|
return t < Date.now() - graceMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next local date+time slot at least `minAheadMin` minutes from now, rounded up. */
|
||||||
|
export function nextLocalScheduleSlot(minAheadMin = 15): {
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
} {
|
||||||
|
const d = new Date(Date.now() + minAheadMin * 60_000);
|
||||||
|
const step = 15;
|
||||||
|
const mins = d.getMinutes();
|
||||||
|
const rounded = Math.ceil(mins / step) * step;
|
||||||
|
if (rounded >= 60) {
|
||||||
|
d.setHours(d.getHours() + 1, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
d.setMinutes(rounded, 0, 0);
|
||||||
|
}
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return {
|
||||||
|
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
|
||||||
|
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PIPELINE_FORMATS = [
|
||||||
|
'post',
|
||||||
|
'story',
|
||||||
|
'reel',
|
||||||
|
'carrusel',
|
||||||
|
] as const;
|
||||||
|
export type PipelineFormat = (typeof PIPELINE_FORMATS)[number];
|
||||||
|
|
||||||
|
/** Normalize Stories/Story/carousel/etc. → canonical pipeline format. */
|
||||||
|
export function normalizePipelineFormat(
|
||||||
|
raw: unknown,
|
||||||
|
kind?: string | null
|
||||||
|
): PipelineFormat {
|
||||||
|
const hay = `${String(raw || '')} ${String(kind || '')}`.toLowerCase().trim();
|
||||||
|
if (/stor|historia/.test(hay)) return 'story';
|
||||||
|
if (/reel/.test(hay)) return 'reel';
|
||||||
|
if (/carrusel|carousel/.test(hay)) return 'carrusel';
|
||||||
|
if ((PIPELINE_FORMATS as readonly string[]).includes(hay)) {
|
||||||
|
return hay as PipelineFormat;
|
||||||
|
}
|
||||||
|
return 'post';
|
||||||
|
}
|
||||||
|
|
||||||
export function toDatetimeLocalValue(iso?: string | null): string {
|
export function toDatetimeLocalValue(iso?: string | null): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
|
|||||||
Reference in New Issue
Block a user