446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
/**
|
|
* Approved Page Configuration
|
|
* Centralized config for Approved page table, filters, and actions
|
|
*/
|
|
|
|
import { Content } from '../../services/api';
|
|
import Badge from '../../components/ui/badge/Badge';
|
|
import { formatRelativeDate } from '../../utils/date';
|
|
import { CheckCircleIcon, ArrowRightIcon } from '../../icons';
|
|
import { TYPE_LABELS, CONTENT_TYPE_OPTIONS, ALL_CONTENT_STRUCTURES } from '../structureMapping';
|
|
import { getSectorBadgeColor, getStructureBadgeColor, getStructureLabel } from '../../utils/badgeColors';
|
|
import type { Sector } from '../../store/sectorStore';
|
|
|
|
export interface ColumnConfig {
|
|
key: string;
|
|
label: string;
|
|
sortable?: boolean;
|
|
sortField?: string;
|
|
align?: 'left' | 'center' | 'right';
|
|
width?: string;
|
|
numeric?: boolean;
|
|
date?: boolean;
|
|
render?: (value: any, row: any) => React.ReactNode;
|
|
toggleable?: boolean;
|
|
toggleContentKey?: string;
|
|
toggleContentLabel?: string;
|
|
defaultVisible?: boolean;
|
|
}
|
|
|
|
export interface FilterConfig {
|
|
key: string;
|
|
label: string;
|
|
type: 'text' | 'select';
|
|
placeholder?: string;
|
|
options?: Array<{ value: string; label: string }>;
|
|
}
|
|
|
|
export interface HeaderMetricConfig {
|
|
label: string;
|
|
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
|
calculate: (data: { content: Content[]; totalCount: number }) => number;
|
|
}
|
|
|
|
export interface ApprovedPageConfig {
|
|
columns: ColumnConfig[];
|
|
filters: FilterConfig[];
|
|
headerMetrics: HeaderMetricConfig[];
|
|
}
|
|
|
|
export function createApprovedPageConfig(params: {
|
|
searchTerm: string;
|
|
setSearchTerm: (value: string) => void;
|
|
statusFilter: string;
|
|
setStatusFilter: (value: string) => void;
|
|
siteStatusFilter: string;
|
|
setSiteStatusFilter: (value: string) => void;
|
|
setCurrentPage: (page: number) => void;
|
|
activeSector: { id: number; name: string } | null;
|
|
sectors?: Sector[];
|
|
onRowClick?: (row: Content) => void;
|
|
statusOptions?: Array<{ value: string; label: string }>;
|
|
siteStatusOptions?: Array<{ value: string; label: string }>;
|
|
contentTypeOptions?: Array<{ value: string; label: string }>;
|
|
contentStructureOptions?: Array<{ value: string; label: string }>;
|
|
}): ApprovedPageConfig {
|
|
const showSectorColumn = !params.activeSector;
|
|
|
|
const columns: ColumnConfig[] = [
|
|
{
|
|
key: 'title',
|
|
label: 'Content Idea Title',
|
|
sortable: true,
|
|
sortField: 'title',
|
|
width: '400px',
|
|
render: (value: string, row: Content) => (
|
|
<div className="flex items-center gap-2">
|
|
{params.onRowClick ? (
|
|
<button
|
|
onClick={() => params.onRowClick!(row)}
|
|
className="text-sm text-brand-500 hover:text-brand-600 hover:underline text-left transition-colors"
|
|
>
|
|
{value || `Content #${row.id}`}
|
|
</button>
|
|
) : (
|
|
<span className="text-sm text-gray-900 dark:text-white">
|
|
{value || `Content #${row.id}`}
|
|
</span>
|
|
)}
|
|
{row.external_url && (
|
|
<a
|
|
href={row.external_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-brand-500 hover:text-brand-600 transition-colors"
|
|
title="View on Site"
|
|
>
|
|
<ArrowRightIcon className="w-4 h-4" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
sortable: true,
|
|
sortField: 'status',
|
|
render: (value: string, row: Content) => {
|
|
// Map internal status to standard labels
|
|
const statusConfig: Record<string, { color: 'success' | 'blue' | 'amber' | 'gray'; label: string }> = {
|
|
'draft': { color: 'gray', label: 'Draft' },
|
|
'review': { color: 'amber', label: 'Review' },
|
|
'approved': { color: 'blue', label: 'Approved' },
|
|
'published': { color: 'success', label: 'Published' },
|
|
};
|
|
const config = statusConfig[value] || { color: 'gray' as const, label: value || '-' };
|
|
|
|
return (
|
|
<Badge color={config.color} size="xs" variant="soft">
|
|
<span className="text-[11px] font-normal">{config.label}</span>
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'site_status',
|
|
label: 'Site Status',
|
|
sortable: true,
|
|
sortField: 'site_status',
|
|
width: '130px',
|
|
render: (value: string, row: Content) => {
|
|
// Show actual site_status field
|
|
const statusConfig: Record<string, { color: 'success' | 'amber' | 'blue' | 'gray' | 'red'; label: string }> = {
|
|
'not_published': { color: 'gray', label: 'Not Published' },
|
|
'scheduled': { color: 'amber', label: 'Scheduled' },
|
|
'publishing': { color: 'amber', label: 'Publishing' },
|
|
'published': { color: 'success', label: 'Published' },
|
|
'failed': { color: 'red', label: 'Failed' },
|
|
};
|
|
const config = statusConfig[value] || { color: 'gray' as const, label: value || 'Not Published' };
|
|
|
|
return (
|
|
<Badge color={config.color} size="xs" variant="soft">
|
|
<span className="text-[11px] font-normal">{config.label}</span>
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'scheduled_publish_at',
|
|
label: 'Publish Date',
|
|
sortable: true,
|
|
sortField: 'scheduled_publish_at',
|
|
date: true,
|
|
width: '150px',
|
|
render: (value: string, row: Content) => {
|
|
const siteStatus = row.site_status;
|
|
|
|
// For published items: show when it was published
|
|
if (siteStatus === 'published') {
|
|
// Use site_status_updated_at if available, otherwise updated_at
|
|
const publishedAt = row.site_status_updated_at || row.updated_at;
|
|
if (publishedAt) {
|
|
return (
|
|
<span className="text-success-600 dark:text-success-400 font-medium">
|
|
{formatRelativeDate(publishedAt)}
|
|
</span>
|
|
);
|
|
}
|
|
return <span className="text-success-600 dark:text-success-400 text-[11px]">Published</span>;
|
|
}
|
|
|
|
// For failed items: show when the failure occurred
|
|
if (siteStatus === 'failed') {
|
|
const failedAt = row.site_status_updated_at || row.updated_at;
|
|
if (failedAt) {
|
|
return (
|
|
<span className="text-red-600 dark:text-red-400 font-medium">
|
|
{formatRelativeDate(failedAt)}
|
|
</span>
|
|
);
|
|
}
|
|
return <span className="text-red-600 dark:text-red-400 text-[11px]">Failed</span>;
|
|
}
|
|
|
|
// For scheduled items: show when it will be published
|
|
if (siteStatus === 'scheduled' || siteStatus === 'publishing') {
|
|
if (value) {
|
|
const publishDate = new Date(value);
|
|
const now = new Date();
|
|
const isFuture = publishDate > now;
|
|
return (
|
|
<span className={isFuture ? "text-blue-600 dark:text-blue-400 font-medium" : "text-amber-600 dark:text-amber-400 font-medium"}>
|
|
{formatRelativeDate(value)}
|
|
</span>
|
|
);
|
|
}
|
|
return <span className="text-amber-600 dark:text-amber-400 text-[11px]">Pending</span>;
|
|
}
|
|
|
|
// For not_published items: show scheduled date if available
|
|
if (value) {
|
|
const publishDate = new Date(value);
|
|
const now = new Date();
|
|
const isFuture = publishDate > now;
|
|
return (
|
|
<span className={isFuture ? "text-blue-600 dark:text-blue-400 font-medium" : "text-amber-600 dark:text-amber-400 font-medium"}>
|
|
{formatRelativeDate(value)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return <span className="text-gray-400 dark:text-gray-500 text-[11px]">Not scheduled</span>;
|
|
},
|
|
},
|
|
|
|
{
|
|
key: 'content_type',
|
|
label: 'Type',
|
|
sortable: false, // Backend doesn't support sorting by content_type
|
|
sortField: 'content_type',
|
|
width: '110px',
|
|
render: (value: string) => {
|
|
const label = TYPE_LABELS[value] || value || '-';
|
|
const properCase = label.charAt(0).toUpperCase() + label.slice(1);
|
|
return (
|
|
<Badge color="blue" size="xs" variant="soft">
|
|
<span className="text-[11px] font-normal">{properCase}</span>
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'content_structure',
|
|
label: 'Structure',
|
|
sortable: false, // Backend doesn't support sorting by content_structure
|
|
sortField: 'content_structure',
|
|
width: '130px',
|
|
render: (value: string) => {
|
|
const properCase = getStructureLabel(value)
|
|
.split(/[_\s]+/)
|
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ');
|
|
return (
|
|
<Badge color={getStructureBadgeColor(value)} size="xs" variant="soft">
|
|
<span className="text-[11px] font-normal">{properCase}</span>
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'cluster_name',
|
|
label: 'Cluster',
|
|
sortable: false,
|
|
width: '130px',
|
|
render: (_value: any, row: Content) => {
|
|
const clusterName = row.cluster_name;
|
|
if (!clusterName) {
|
|
return <span className="text-gray-400 dark:text-gray-500 text-[11px]">-</span>;
|
|
}
|
|
return (
|
|
<Badge color="indigo" size="xs" variant="soft">
|
|
<span className="text-[11px] font-normal">{clusterName}</span>
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'tags',
|
|
label: 'Tags',
|
|
sortable: false,
|
|
width: '150px',
|
|
render: (_value: any, row: Content) => {
|
|
const tags = row.tags || [];
|
|
if (!tags || tags.length === 0) {
|
|
return <span className="text-gray-400 dark:text-gray-500 text-[11px]">-</span>;
|
|
}
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{tags.slice(0, 2).map((tag, index) => (
|
|
<Badge key={`${tag}-${index}`} color="pink" size="xs" variant="soft">
|
|
<span className="text-[11px] font-normal">{tag}</span>
|
|
</Badge>
|
|
))}
|
|
{tags.length > 2 && (
|
|
<span className="text-[11px] text-gray-500">+{tags.length - 2}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'categories',
|
|
label: 'Categories',
|
|
sortable: false,
|
|
width: '150px',
|
|
render: (_value: any, row: Content) => {
|
|
const categories = row.categories || [];
|
|
if (!categories || categories.length === 0) {
|
|
return <span className="text-gray-400 dark:text-gray-500 text-[11px]">-</span>;
|
|
}
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{categories.slice(0, 2).map((category, index) => (
|
|
<Badge key={`${category}-${index}`} color="blue" size="xs" variant="soft">
|
|
<span className="text-[11px] font-normal">{category}</span>
|
|
</Badge>
|
|
))}
|
|
{categories.length > 2 && (
|
|
<span className="text-[11px] text-gray-500">+{categories.length - 2}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'word_count',
|
|
label: 'Words',
|
|
sortable: false, // Backend doesn't support sorting by word_count
|
|
sortField: 'word_count',
|
|
numeric: true,
|
|
align: 'center' as const,
|
|
headingAlign: 'center' as const,
|
|
render: (value: number) => (
|
|
<span className="text-gray-900 dark:text-white">
|
|
{value ? value.toLocaleString() : '-'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
label: 'Created',
|
|
sortable: true,
|
|
sortField: 'created_at',
|
|
date: true,
|
|
width: '130px',
|
|
hasActions: true,
|
|
render: (value: string) => (
|
|
<span className="text-gray-600 dark:text-gray-400">
|
|
{formatRelativeDate(value)}
|
|
</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
if (showSectorColumn) {
|
|
columns.splice(2, 0, {
|
|
key: 'sector_name',
|
|
label: 'Sector',
|
|
sortable: false,
|
|
width: '120px',
|
|
render: (value: string, row: Content) => {
|
|
const color = getSectorBadgeColor(row.sector_id, row.sector_name, params.sectors);
|
|
return (
|
|
<Badge color={color} size="xs" variant="soft">
|
|
<span className="text-[11px] font-normal">{row.sector_name || '-'}</span>
|
|
</Badge>
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
const filters: FilterConfig[] = [
|
|
{
|
|
key: 'search',
|
|
label: 'Search',
|
|
type: 'text',
|
|
placeholder: 'Search approved content...',
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
type: 'select',
|
|
options: params.statusOptions,
|
|
},
|
|
{
|
|
key: 'site_status',
|
|
label: 'Site Status',
|
|
type: 'select',
|
|
options: params.siteStatusOptions,
|
|
},
|
|
{
|
|
key: 'content_type',
|
|
label: 'Type',
|
|
type: 'select',
|
|
options: params.contentTypeOptions,
|
|
},
|
|
{
|
|
key: 'content_structure',
|
|
label: 'Structure',
|
|
type: 'select',
|
|
options: params.contentStructureOptions,
|
|
},
|
|
];
|
|
|
|
const headerMetrics: HeaderMetricConfig[] = [
|
|
{
|
|
label: 'Content',
|
|
accentColor: 'blue',
|
|
calculate: (data: { totalCount: number }) => data.totalCount,
|
|
tooltip: 'Total content items tracked. Overall volume across all stages.',
|
|
},
|
|
{
|
|
label: 'Draft',
|
|
accentColor: 'amber',
|
|
calculate: (data: { content: Content[] }) =>
|
|
data.content.filter(c => c.status === 'draft').length,
|
|
tooltip: 'Content written, images not generated. Generate images to move to review.',
|
|
},
|
|
{
|
|
label: 'In Review',
|
|
accentColor: 'purple',
|
|
calculate: (data: { content: Content[] }) =>
|
|
data.content.filter(c => c.status === 'review').length,
|
|
tooltip: 'Images generated, awaiting approval. Review and approve to publish.',
|
|
},
|
|
{
|
|
label: 'Approved',
|
|
accentColor: 'green',
|
|
calculate: (data: { content: Content[] }) =>
|
|
data.content.filter(c => c.status === 'approved').length,
|
|
tooltip: 'Approved content awaiting publishing. Publish to site when ready.',
|
|
},
|
|
{
|
|
label: 'Published',
|
|
accentColor: 'green',
|
|
calculate: (data: { content: Content[] }) =>
|
|
data.content.filter(c => c.status === 'published').length,
|
|
tooltip: 'Live content on your website. Successfully published and accessible.',
|
|
},
|
|
{
|
|
label: 'Total Images',
|
|
accentColor: 'blue',
|
|
calculate: (data: { content: Content[] }) =>
|
|
data.content.filter(c => c.has_generated_images).length,
|
|
tooltip: 'Total images generated across all content. Tracks visual asset coverage.',
|
|
},
|
|
];
|
|
|
|
return {
|
|
columns,
|
|
filters,
|
|
headerMetrics,
|
|
};
|
|
}
|