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 row: Record<string, unknown> = {
|
||||
title: piece.title,
|
||||
format: mapFormatToPipeline(piece.format, piece.kind),
|
||||
format: mapFormatToPipeline(piece.format, piece.kind) || 'post',
|
||||
month,
|
||||
caption: caption || null,
|
||||
notes: piece.pillar ? `Pilar: ${piece.pillar}` : null,
|
||||
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
import {
|
||||
buildRrssGenerationBrief,
|
||||
generationRequestNoteMarker,
|
||||
isRrssPipelineFormat,
|
||||
type RrssPipelineFormat,
|
||||
} from '@/lib/rrss-generation';
|
||||
import {
|
||||
isScheduledAtInPast,
|
||||
normalizePipelineFormat,
|
||||
parseScheduledAt,
|
||||
} from '@/lib/pipeline-schedule';
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -58,13 +62,23 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const title = String(body.title || '').trim();
|
||||
const format = body.format as RrssPipelineFormat;
|
||||
const format = normalizePipelineFormat(body.format) as RrssPipelineFormat;
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
||||
}
|
||||
if (!isRrssPipelineFormat(format)) {
|
||||
const scheduledAt = parseScheduledAt(body.scheduled_at);
|
||||
if (scheduledAt === undefined) {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
@@ -160,8 +174,7 @@ export async function POST(req: NextRequest) {
|
||||
caption,
|
||||
notes: notesBase || null,
|
||||
calendar_piece_id: calendarPieceId,
|
||||
scheduled_at:
|
||||
typeof body.scheduled_at === 'string' ? body.scheduled_at : null,
|
||||
scheduled_at: scheduledAt ?? null,
|
||||
channels: Array.isArray(body.channels)
|
||||
? body.channels
|
||||
: ['instagram', 'facebook'],
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
channelsForCalendarPlatform,
|
||||
isScheduledAtInPast,
|
||||
normalizeChannels,
|
||||
normalizePipelineFormat,
|
||||
parseScheduledAt,
|
||||
PUBLISH_STATUSES,
|
||||
} from '@/lib/pipeline-schedule';
|
||||
@@ -36,8 +38,6 @@ import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
||||
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';
|
||||
|
||||
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
|
||||
|
||||
function str(v: unknown, max = 2000): string | null {
|
||||
if (typeof v !== 'string') return null;
|
||||
const t = v.trim();
|
||||
@@ -190,7 +190,7 @@ export async function POST(req: NextRequest) {
|
||||
const calendarPieceId = str(body.calendar_piece_id, 120);
|
||||
|
||||
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 caption =
|
||||
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 });
|
||||
}
|
||||
|
||||
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> = {
|
||||
tenant_id: ctx.tenantId,
|
||||
title,
|
||||
external_ref: str(body.external_ref, 80),
|
||||
format: format && FORMATS.has(format) ? format : 'post',
|
||||
format: normalizePipelineFormat(format),
|
||||
month: month || null,
|
||||
caption: caption || null,
|
||||
notes: notes || null,
|
||||
@@ -422,8 +432,9 @@ export async function PATCH(req: NextRequest) {
|
||||
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 ('format' in body && body.format != null) {
|
||||
clean.format = normalizePipelineFormat(body.format);
|
||||
}
|
||||
if ('caption' in body) {
|
||||
clean.caption =
|
||||
sanitizePublishCaption(str(body.caption, 4000) || '', {
|
||||
@@ -447,6 +458,15 @@ export async function PATCH(req: NextRequest) {
|
||||
{ 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;
|
||||
if (parsed) {
|
||||
// Cron publica queued + status aprobada. Programar implica ya validada.
|
||||
|
||||
@@ -19,12 +19,16 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, Surface } from '@/components/ui';
|
||||
import {
|
||||
isScheduledAtInPast,
|
||||
nextLocalScheduleSlot,
|
||||
normalizePipelineFormat,
|
||||
scheduleLabel,
|
||||
toDatetimeLocalValue,
|
||||
type PublishChannel,
|
||||
} from '@/lib/pipeline-schedule';
|
||||
import {
|
||||
buildCaption,
|
||||
isLocalScheduleInPast,
|
||||
mapFormatToPipeline,
|
||||
mapPipelineFormatToCalendar,
|
||||
scheduledAtFromPiece,
|
||||
@@ -245,14 +249,15 @@ export default function CreativePipelineBoard() {
|
||||
function applyCalendarPiece(id: string) {
|
||||
setCalPick(id);
|
||||
if (id === '__new__') {
|
||||
const slot = nextLocalScheduleSlot();
|
||||
setNewTitle('');
|
||||
setNewCaption('');
|
||||
setNewHook('');
|
||||
setNewPillar('');
|
||||
setNewFormat('post');
|
||||
setNewMonth('');
|
||||
setNewDate('');
|
||||
setNewTime('10:00');
|
||||
setNewMonth(slot.date.slice(0, 7));
|
||||
setNewDate(slot.date);
|
||||
setNewTime(slot.time);
|
||||
clearMediaSelection();
|
||||
return;
|
||||
}
|
||||
@@ -263,11 +268,27 @@ export default function CreativePipelineBoard() {
|
||||
setNewCaption(buildCaption(p) || p.body || '');
|
||||
setNewPillar(p.pillar || '');
|
||||
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));
|
||||
setNewDate(p.date.slice(0, 10));
|
||||
setNewTime((p.time || '10:00').slice(0, 5));
|
||||
const date = p.date.slice(0, 10);
|
||||
let time = (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() {
|
||||
@@ -339,6 +360,12 @@ export default function CreativePipelineBoard() {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim()) return;
|
||||
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);
|
||||
|
||||
try {
|
||||
@@ -348,12 +375,13 @@ export default function CreativePipelineBoard() {
|
||||
calendarPieceId && calendarPieceId !== '__new__'
|
||||
? calPieces.find((p) => p.id === calendarPieceId)
|
||||
: null;
|
||||
const resolvedFormat = normalizePipelineFormat(newFormat);
|
||||
|
||||
if (syncCalendar) {
|
||||
const date =
|
||||
newDate ||
|
||||
(newMonth ? `${newMonth}-01` : new Date().toISOString().slice(0, 10));
|
||||
const calFmt = mapPipelineFormatToCalendar(newFormat);
|
||||
const calFmt = mapPipelineFormatToCalendar(resolvedFormat);
|
||||
const calRes = await fetch('/api/calendar/pieces', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -389,7 +417,7 @@ export default function CreativePipelineBoard() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: newTitle.trim(),
|
||||
format: newFormat,
|
||||
format: resolvedFormat,
|
||||
month: newMonth || (newDate ? newDate.slice(0, 7) : null),
|
||||
caption: newCaption || null,
|
||||
notes: newPillar ? `Pilar: ${newPillar}` : null,
|
||||
@@ -403,7 +431,8 @@ export default function CreativePipelineBoard() {
|
||||
title: pickedCal.title,
|
||||
date: pickedCal.date,
|
||||
time: pickedCal.time,
|
||||
format: pickedCal.format,
|
||||
format: mapPipelineFormatToCalendar(resolvedFormat).format,
|
||||
kind: mapPipelineFormatToCalendar(resolvedFormat).kind,
|
||||
pillar: pickedCal.pillar || newPillar,
|
||||
hook: newHook || pickedCal.hook,
|
||||
body: newCaption || pickedCal.body,
|
||||
@@ -415,6 +444,8 @@ export default function CreativePipelineBoard() {
|
||||
title: newTitle.trim(),
|
||||
date: newDate || null,
|
||||
time: newTime || null,
|
||||
format: mapPipelineFormatToCalendar(resolvedFormat).format,
|
||||
kind: mapPipelineFormatToCalendar(resolvedFormat).kind,
|
||||
pillar: newPillar || null,
|
||||
hook: newHook || null,
|
||||
body: newCaption || null,
|
||||
@@ -442,7 +473,7 @@ export default function CreativePipelineBoard() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: newTitle.trim(),
|
||||
format: newFormat,
|
||||
format: resolvedFormat,
|
||||
month: newMonth || (newDate ? newDate.slice(0, 7) : null),
|
||||
caption: newCaption || 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(
|
||||
id: string,
|
||||
payload: {
|
||||
title: string;
|
||||
caption: string;
|
||||
format?: PipelineItem['format'];
|
||||
notes?: string | null;
|
||||
syncCalendar: boolean;
|
||||
calendarDate?: string | null;
|
||||
@@ -569,16 +601,31 @@ export default function CreativePipelineBoard() {
|
||||
}
|
||||
) {
|
||||
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);
|
||||
setErr(null);
|
||||
try {
|
||||
const target = items.find((x) => x.id === id) || previewItem;
|
||||
const resolvedFormat = normalizePipelineFormat(
|
||||
payload.format || target?.format || 'post'
|
||||
);
|
||||
if (
|
||||
payload.syncCalendar &&
|
||||
target?.calendar_piece_id &&
|
||||
payload.calendarDate
|
||||
) {
|
||||
const calFmt = mapPipelineFormatToCalendar(target.format || 'post');
|
||||
const calFmt = mapPipelineFormatToCalendar(resolvedFormat);
|
||||
const calRes = await fetch('/api/calendar/pieces', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -615,6 +662,7 @@ export default function CreativePipelineBoard() {
|
||||
id,
|
||||
title: payload.title.trim(),
|
||||
caption: payload.caption || null,
|
||||
format: resolvedFormat,
|
||||
notes: payload.notes ?? undefined,
|
||||
...(sched !== undefined ? { scheduled_at: sched } : {}),
|
||||
}),
|
||||
@@ -1019,8 +1067,13 @@ export default function CreativePipelineBoard() {
|
||||
type="date"
|
||||
value={newDate}
|
||||
onChange={(e) => {
|
||||
setNewDate(e.target.value);
|
||||
if (e.target.value) setNewMonth(e.target.value.slice(0, 7));
|
||||
const d = e.target.value;
|
||||
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"
|
||||
/>
|
||||
@@ -1035,6 +1088,12 @@ export default function CreativePipelineBoard() {
|
||||
onChange={(e) => setNewTime(e.target.value)}
|
||||
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>
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
@@ -1685,6 +1744,7 @@ function DetailModal({
|
||||
onSaveContent: (payload: {
|
||||
title: string;
|
||||
caption: string;
|
||||
format?: PipelineItem['format'];
|
||||
notes?: string | null;
|
||||
syncCalendar: boolean;
|
||||
calendarDate?: string | null;
|
||||
@@ -1718,14 +1778,21 @@ function DetailModal({
|
||||
);
|
||||
const [editTitle, setEditTitle] = useState(item.title);
|
||||
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 [schedErr, setSchedErr] = useState<string | null>(null);
|
||||
const [editDate, setEditDate] = useState(
|
||||
(item.scheduled_at || item.created_at || '').slice(0, 10)
|
||||
);
|
||||
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);
|
||||
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')}`;
|
||||
});
|
||||
|
||||
@@ -1741,7 +1808,9 @@ function DetailModal({
|
||||
setChLi(ch.includes('linkedin'));
|
||||
setEditTitle(item.title);
|
||||
setEditCaption(item.caption || '');
|
||||
setEditFormat(normalizePipelineFormat(item.format));
|
||||
setSyncCal(Boolean(item.calendar_piece_id));
|
||||
setSchedErr(null);
|
||||
setEditDate((item.scheduled_at || item.created_at || '').slice(0, 10));
|
||||
if (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 currentUrl = mediaUrl(activePath);
|
||||
const igUrl = getInstagramUrl(item.notes);
|
||||
const formatInfo = FORMAT_CONFIG[item.format] || {
|
||||
label: item.format,
|
||||
const formatInfo = FORMAT_CONFIG[editFormat] || FORMAT_CONFIG[item.format] || {
|
||||
label: editFormat || item.format,
|
||||
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
};
|
||||
|
||||
@@ -1765,6 +1834,7 @@ function DetailModal({
|
||||
const clientCanDecide = !isAgency && clientCanDecideStatus(status);
|
||||
|
||||
function submitSchedule() {
|
||||
setSchedErr(null);
|
||||
const channels: PublishChannel[] = [];
|
||||
if (chIg) channels.push('instagram');
|
||||
if (chFb) channels.push('facebook');
|
||||
@@ -1779,6 +1849,20 @@ function DetailModal({
|
||||
: [igIso, fbIso]
|
||||
.filter(Boolean)
|
||||
.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({
|
||||
scheduled_at: primary,
|
||||
channels: channels.length ? channels : ['instagram'],
|
||||
@@ -1801,9 +1885,20 @@ function DetailModal({
|
||||
|
||||
function submitContent() {
|
||||
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({
|
||||
title: editTitle.trim(),
|
||||
caption: editCaption,
|
||||
format: editFormat,
|
||||
syncCalendar: syncCal && Boolean(item.calendar_piece_id),
|
||||
calendarDate: editDate || null,
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
{monthLabel(item.month)}{' '}
|
||||
{item.external_ref ? `· Ref: ${item.external_ref}` : ''}
|
||||
@@ -1978,7 +2090,15 @@ function DetailModal({
|
||||
<input
|
||||
type="date"
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
@@ -1992,6 +2112,11 @@ function DetailModal({
|
||||
/>
|
||||
</label>
|
||||
</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 ? (
|
||||
<label className="flex items-start gap-2 text-[11px] text-gray-700">
|
||||
<input
|
||||
@@ -2152,6 +2277,11 @@ function DetailModal({
|
||||
{item.publish_error}
|
||||
</p>
|
||||
) : 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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -58,9 +58,10 @@ export function piecesFromDocs(docs: CalendarDoc[]): CalendarPieceRow[] {
|
||||
|
||||
export function mapFormatToPipeline(format?: string, kind?: string): string {
|
||||
const hay = `${format || ''} ${kind || ''}`.toLowerCase();
|
||||
if (hay.includes('stor')) return 'story';
|
||||
if (hay.includes('reel')) return 'reel';
|
||||
if (hay.includes('carrusel') || hay.includes('carousel')) return 'carrusel';
|
||||
// Match Stories / Story / historia (substring "stor") and explicit kind
|
||||
if (/stor|historia/.test(hay) || kind === 'story') return 'story';
|
||||
if (/reel/.test(hay) || kind === 'reel') return 'reel';
|
||||
if (/carrusel|carousel/.test(hay)) return 'carrusel';
|
||||
return 'post';
|
||||
}
|
||||
|
||||
@@ -90,6 +91,17 @@ export function scheduledAtFromPiece(piece: Pick<RrssPiece, 'date' | 'time'>): s
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
|
||||
Reference in New Issue
Block a user