ui improvements

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-27 06:08:29 +00:00
parent 726d945bda
commit 302af6337e
14 changed files with 219 additions and 211 deletions

View File

@@ -35,7 +35,7 @@ const ContentView = lazy(() => import("./pages/Writer/ContentView"));
const Drafts = lazy(() => import("./pages/Writer/Drafts")); const Drafts = lazy(() => import("./pages/Writer/Drafts"));
const Images = lazy(() => import("./pages/Writer/Images")); const Images = lazy(() => import("./pages/Writer/Images"));
const Review = lazy(() => import("./pages/Writer/Review")); const Review = lazy(() => import("./pages/Writer/Review"));
const Published = lazy(() => import("./pages/Writer/Published")); const Approved = lazy(() => import("./pages/Writer/Approved"));
// Automation Module - Lazy loaded // Automation Module - Lazy loaded
const AutomationPage = lazy(() => import("./pages/Automation/AutomationPage")); const AutomationPage = lazy(() => import("./pages/Automation/AutomationPage"));
@@ -160,7 +160,9 @@ export default function App() {
<Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} /> <Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} />
<Route path="/writer/images" element={<Images />} /> <Route path="/writer/images" element={<Images />} />
<Route path="/writer/review" element={<Review />} /> <Route path="/writer/review" element={<Review />} />
<Route path="/writer/published" element={<Published />} /> <Route path="/writer/approved" element={<Approved />} />
{/* Legacy route - redirect published to approved */}
<Route path="/writer/published" element={<Navigate to="/writer/approved" replace />} />
{/* Automation Module */} {/* Automation Module */}
<Route path="/automation" element={<AutomationPage />} /> <Route path="/automation" element={<AutomationPage />} />

View File

@@ -26,7 +26,7 @@ const SEARCH_ITEMS: SearchResult[] = [
{ title: 'Drafts', path: '/writer/content', type: 'page' }, { title: 'Drafts', path: '/writer/content', type: 'page' },
{ title: 'Images', path: '/writer/images', type: 'page' }, { title: 'Images', path: '/writer/images', type: 'page' },
{ title: 'Review', path: '/writer/review', type: 'page' }, { title: 'Review', path: '/writer/review', type: 'page' },
{ title: 'Published', path: '/writer/published', type: 'page' }, { title: 'Approved', path: '/writer/approved', type: 'page' },
// Setup // Setup
{ title: 'Sites', path: '/sites', type: 'page' }, { title: 'Sites', path: '/sites', type: 'page' },
{ title: 'Add Keywords', path: '/add-keywords', type: 'page' }, { title: 'Add Keywords', path: '/add-keywords', type: 'page' },

View File

@@ -1,6 +1,6 @@
/** /**
* Published Page Configuration * Approved Page Configuration
* Centralized config for Published page table, filters, and actions * Centralized config for Approved page table, filters, and actions
*/ */
import { Content } from '../../services/api'; import { Content } from '../../services/api';
@@ -39,23 +39,21 @@ export interface HeaderMetricConfig {
calculate: (data: { content: Content[]; totalCount: number }) => number; calculate: (data: { content: Content[]; totalCount: number }) => number;
} }
export interface PublishedPageConfig { export interface ApprovedPageConfig {
columns: ColumnConfig[]; columns: ColumnConfig[];
filters: FilterConfig[]; filters: FilterConfig[];
headerMetrics: HeaderMetricConfig[]; headerMetrics: HeaderMetricConfig[];
} }
export function createPublishedPageConfig(params: { export function createApprovedPageConfig(params: {
searchTerm: string; searchTerm: string;
setSearchTerm: (value: string) => void; setSearchTerm: (value: string) => void;
statusFilter: string;
setStatusFilter: (value: string) => void;
publishStatusFilter: string; publishStatusFilter: string;
setPublishStatusFilter: (value: string) => void; setPublishStatusFilter: (value: string) => void;
setCurrentPage: (page: number) => void; setCurrentPage: (page: number) => void;
activeSector: { id: number; name: string } | null; activeSector: { id: number; name: string } | null;
onRowClick?: (row: Content) => void; onRowClick?: (row: Content) => void;
}): PublishedPageConfig { }): ApprovedPageConfig {
const showSectorColumn = !params.activeSector; const showSectorColumn = !params.activeSector;
const columns: ColumnConfig[] = [ const columns: ColumnConfig[] = [
@@ -92,35 +90,16 @@ export function createPublishedPageConfig(params: {
</div> </div>
), ),
}, },
{
key: 'status',
label: 'Content Status',
sortable: true,
sortField: 'status',
width: '120px',
render: (value: string) => {
const statusConfig: Record<string, { color: 'amber' | 'success'; label: string }> = {
draft: { color: 'amber', label: 'Draft' },
published: { color: 'success', label: 'Published' },
};
const config = statusConfig[value] || { color: 'amber' as const, label: value };
return (
<Badge color={config.color} size="xs" variant="soft">
<span className="text-[11px] font-normal">{config.label}</span>
</Badge>
);
},
},
{ {
key: 'wordpress_status', key: 'wordpress_status',
label: 'WP Status', label: 'Site Status',
sortable: false, sortable: false,
width: '120px', width: '120px',
render: (_value: any, row: Content) => { render: (_value: any, row: Content) => {
// Check if content has been published to WordPress // Check if content has been published to WordPress
if (!row.external_id) { if (!row.external_id) {
return ( return (
<Badge color="gray" size="xs" variant="soft"> <Badge color="amber" size="xs" variant="soft">
<span className="text-[11px] font-normal">Not Published</span> <span className="text-[11px] font-normal">Not Published</span>
</Badge> </Badge>
); );
@@ -279,25 +258,15 @@ export function createPublishedPageConfig(params: {
key: 'search', key: 'search',
label: 'Search', label: 'Search',
type: 'text', type: 'text',
placeholder: 'Search published content...', placeholder: 'Search approved content...',
},
{
key: 'status',
label: 'Content Status',
type: 'select',
options: [
{ value: '', label: 'All Statuses' },
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
],
}, },
{ {
key: 'publishStatus', key: 'publishStatus',
label: 'WordPress Status', label: 'Site Status',
type: 'select', type: 'select',
options: [ options: [
{ value: '', label: 'All' }, { value: '', label: 'All' },
{ value: 'published', label: 'Published to WP' }, { value: 'published', label: 'Published to Site' },
{ value: 'not_published', label: 'Not Published' }, { value: 'not_published', label: 'Not Published' },
], ],
}, },
@@ -305,38 +274,24 @@ export function createPublishedPageConfig(params: {
const headerMetrics: HeaderMetricConfig[] = [ const headerMetrics: HeaderMetricConfig[] = [
{ {
label: 'Published', label: 'Approved',
accentColor: 'green', accentColor: 'green',
calculate: (data: { totalCount: number }) => data.totalCount, calculate: (data: { totalCount: number }) => data.totalCount,
tooltip: 'Total published content. Track your complete content library.', tooltip: 'Total approved content ready for publishing.',
}, },
{ {
label: 'Synced', label: 'On Site',
accentColor: 'blue', accentColor: 'blue',
calculate: (data: { content: Content[] }) => calculate: (data: { content: Content[] }) =>
data.content.filter(c => c.external_id).length, data.content.filter(c => c.external_id).length,
tooltip: 'Content synced to WordPress. Successfully published on your website.', tooltip: 'Content published to your website.',
}, },
{ {
label: 'This Month', label: 'Pending',
accentColor: 'purple',
calculate: (data: { content: Content[] }) => {
const now = new Date();
const thisMonth = now.getMonth();
const thisYear = now.getFullYear();
return data.content.filter(c => {
const date = new Date(c.created_at);
return date.getMonth() === thisMonth && date.getFullYear() === thisYear;
}).length;
},
tooltip: 'Content published this month. Track your monthly publishing velocity.',
},
{
label: 'Pending Sync',
accentColor: 'amber', accentColor: 'amber',
calculate: (data: { content: Content[] }) => calculate: (data: { content: Content[] }) =>
data.content.filter(c => !c.external_id).length, data.content.filter(c => !c.external_id).length,
tooltip: 'Published content not yet synced to WordPress. Sync these to make them live.', tooltip: 'Approved content not yet published to site.',
}, },
]; ];

View File

@@ -295,6 +295,45 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
}, },
], ],
}, },
'/writer/approved': {
rowActions: [
{
key: 'edit',
label: 'Edit Content',
icon: EditIcon,
variant: 'primary',
},
{
key: 'publish_wordpress',
label: 'Publish to Site',
icon: <ArrowRightIcon className="w-5 h-5" />,
variant: 'success',
shouldShow: (row: any) => !row.external_id, // Only show if not published
},
{
key: 'view_on_wordpress',
label: 'View on Site',
icon: <CheckCircleIcon className="w-5 h-5 text-blue-500" />,
variant: 'secondary',
shouldShow: (row: any) => !!row.external_id, // Only show if published
},
],
bulkActions: [
{
key: 'bulk_publish_wordpress',
label: 'Publish to Site',
icon: <ArrowRightIcon className="w-4 h-4" />,
variant: 'success',
},
{
key: 'export',
label: 'Export Selected',
icon: <DownloadIcon className="w-4 h-4 text-blue-light-500" />,
variant: 'secondary',
},
],
},
// Legacy route - keep for backwards compatibility
'/writer/published': { '/writer/published': {
rowActions: [ rowActions: [
{ {
@@ -352,19 +391,31 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
}, },
'/writer/review': { '/writer/review': {
rowActions: [ rowActions: [
{
key: 'approve',
label: 'Approve',
icon: <CheckCircleIcon className="w-5 h-5" />,
variant: 'success',
},
{ {
key: 'publish_wordpress', key: 'publish_wordpress',
label: 'Publish to WordPress', label: 'Publish to WordPress',
icon: <ArrowRightIcon className="w-5 h-5" />, icon: <ArrowRightIcon className="w-5 h-5" />,
variant: 'success', variant: 'primary',
}, },
], ],
bulkActions: [ bulkActions: [
{
key: 'bulk_approve',
label: 'Approve Selected',
icon: <CheckCircleIcon className="w-5 h-5" />,
variant: 'success',
},
{ {
key: 'bulk_publish_wordpress', key: 'bulk_publish_wordpress',
label: 'Publish to WordPress', label: 'Publish to WordPress',
icon: <ArrowRightIcon className="w-5 h-5" />, icon: <ArrowRightIcon className="w-5 h-5" />,
variant: 'success', variant: 'primary',
}, },
], ],
}, },

View File

@@ -37,7 +37,7 @@ export const routes: RouteConfig[] = [
{ path: '/writer', label: 'Dashboard', breadcrumb: 'Writer Dashboard' }, { path: '/writer', label: 'Dashboard', breadcrumb: 'Writer Dashboard' },
{ path: '/writer/tasks', label: 'Tasks', breadcrumb: 'Tasks' }, { path: '/writer/tasks', label: 'Tasks', breadcrumb: 'Tasks' },
{ path: '/writer/content', label: 'Content', breadcrumb: 'Content' }, { path: '/writer/content', label: 'Content', breadcrumb: 'Content' },
{ path: '/writer/published', label: 'Published', breadcrumb: 'Published' }, { path: '/writer/approved', label: 'Approved', breadcrumb: 'Approved' },
], ],
}, },
{ {

View File

@@ -135,7 +135,7 @@ const AppSidebar: React.FC = () => {
{ name: "Drafts", path: "/writer/content" }, { name: "Drafts", path: "/writer/content" },
{ name: "Images", path: "/writer/images" }, { name: "Images", path: "/writer/images" },
{ name: "Review", path: "/writer/review" }, { name: "Review", path: "/writer/review" },
{ name: "Published", path: "/writer/published" }, { name: "Approved", path: "/writer/approved" },
], ],
}); });
} }

View File

@@ -405,15 +405,13 @@ export default function Clusters() {
volumeMin: volumeMin, volumeMin: volumeMin,
volumeMax: volumeMax, volumeMax: volumeMax,
}} }}
nextAction={selectedIds.length > 0 ? { primaryAction={{
label: 'Generate Ideas', label: 'Generate Ideas',
message: `${selectedIds.length} selected`, icon: <BoltIcon className="w-4 h-4" />,
onClick: () => handleBulkAction('generate_ideas', selectedIds), onClick: () => handleBulkAction('auto_generate_ideas', selectedIds),
} : clusters.length > 0 ? { variant: 'success',
label: 'Generate Ideas', }}
href: '/planner/ideas', getRowClassName={(row) => (row.ideas_count || 0) > 0 ? 'bg-success-50 dark:bg-success-500/10' : ''}
message: `${clusters.length} clusters`,
} : undefined}
onFilterChange={(key, value) => { onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value); const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') { if (key === 'search') {

View File

@@ -23,7 +23,7 @@ import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal'; import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal'; import { useProgressModal } from '../../hooks/useProgressModal';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { BoltIcon, PlusIcon, DownloadIcon, ListIcon, GroupIcon } from '../../icons'; import { BoltIcon, PlusIcon, DownloadIcon, ListIcon, GroupIcon, ArrowRightIcon } from '../../icons';
import { LightBulbIcon } from '@heroicons/react/24/outline'; import { LightBulbIcon } from '@heroicons/react/24/outline';
import { createIdeasPageConfig } from '../../config/pages/ideas.config'; import { createIdeasPageConfig } from '../../config/pages/ideas.config';
import { useSectorStore } from '../../store/sectorStore'; import { useSectorStore } from '../../store/sectorStore';
@@ -316,15 +316,13 @@ export default function Ideas() {
content_structure: structureFilter, content_structure: structureFilter,
content_type: typeFilter, content_type: typeFilter,
}} }}
nextAction={selectedIds.length > 0 ? { primaryAction={{
label: 'Queue to Writer', label: 'Queue to Writer',
message: `${selectedIds.length} selected`, icon: <ArrowRightIcon className="w-4 h-4" />,
onClick: () => handleBulkAction('queue_to_writer', selectedIds), onClick: () => handleBulkAction('queue_to_writer', selectedIds),
} : ideas.filter(i => i.status === 'approved').length > 0 ? { variant: 'success',
label: 'Start Writing', }}
href: '/writer/queue', getRowClassName={(row) => row.status === 'queued' || row.status === 'completed' ? 'bg-success-50 dark:bg-success-500/10' : ''}
message: `${ideas.filter(i => i.status === 'approved').length} approved`,
} : undefined}
onFilterChange={(key, value) => { onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value); const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') { if (key === 'search') {

View File

@@ -597,19 +597,13 @@ export default function Keywords() {
volumeMin: volumeMin, volumeMin: volumeMin,
volumeMax: volumeMax, volumeMax: volumeMax,
}} }}
nextAction={selectedIds.length > 0 ? { primaryAction={{
label: 'Auto-Cluster Selected', label: 'Auto-Cluster',
message: `${selectedIds.length} selected`, icon: <BoltIcon className="w-4 h-4" />,
onClick: handleAutoCluster, onClick: () => handleBulkAction('auto_cluster', selectedIds),
} : workflowStats.unclustered >= 5 ? { variant: 'success',
label: 'Auto-Cluster All', }}
message: `${workflowStats.unclustered} unclustered`, getRowClassName={(row) => row.cluster_id ? 'bg-success-50 dark:bg-success-500/10' : ''}
onClick: handleAutoCluster,
} : workflowStats.clustered > 0 ? {
label: 'Generate Ideas',
href: '/planner/ideas',
message: `${workflowStats.clustered} clustered`,
} : undefined}
onFilterChange={(key, value) => { onFilterChange={(key, value) => {
// Normalize value to string, preserving empty strings // Normalize value to string, preserving empty strings
const stringValue = value === null || value === undefined ? '' : String(value); const stringValue = value === null || value === undefined ? '' : String(value);

View File

@@ -1,6 +1,6 @@
/** /**
* Published Page - Built with TablePageTemplate * Approved Page - Built with TablePageTemplate
* Shows published/review content with WordPress publishing capabilities * Shows approved content ready for publishing to WordPress/external sites
*/ */
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
@@ -17,15 +17,15 @@ import {
bulkDeleteContent, bulkDeleteContent,
} from '../../services/api'; } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, CheckCircleIcon } from '../../icons'; import { FileIcon, CheckCircleIcon, BoltIcon } from '../../icons';
import { RocketLaunchIcon } from '@heroicons/react/24/outline'; import { RocketLaunchIcon } from '@heroicons/react/24/outline';
import { createPublishedPageConfig } from '../../config/pages/published.config'; import { createApprovedPageConfig } from '../../config/pages/approved.config';
import { useSectorStore } from '../../store/sectorStore'; import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore'; import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader'; import PageHeader from '../../components/common/PageHeader';
import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter'; import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
export default function Published() { export default function Approved() {
const toast = useToast(); const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const { activeSector } = useSectorStore(); const { activeSector } = useSectorStore();
@@ -35,9 +35,9 @@ export default function Published() {
const [content, setContent] = useState<Content[]>([]); const [content, setContent] = useState<Content[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Filter state - default to published/review status // Filter state - default to approved status
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('published'); // Default to published const [statusFilter, setStatusFilter] = useState('approved'); // Default to approved
const [publishStatusFilter, setPublishStatusFilter] = useState(''); const [publishStatusFilter, setPublishStatusFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
@@ -51,7 +51,7 @@ export default function Published() {
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false); const [showContent, setShowContent] = useState(false);
// Load content - filtered for published/review // Load content - filtered for approved status
const loadContent = useCallback(async () => { const loadContent = useCallback(async () => {
setLoading(true); setLoading(true);
setShowContent(false); setShowContent(false);
@@ -60,7 +60,7 @@ export default function Published() {
const filters: ContentFilters = { const filters: ContentFilters = {
...(searchTerm && { search: searchTerm }), ...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }), status: 'approved', // Always filter for approved status
page: currentPage, page: currentPage,
page_size: pageSize, page_size: pageSize,
ordering, ordering,
@@ -280,11 +280,9 @@ export default function Published() {
// Create page config // Create page config
const pageConfig = useMemo(() => { const pageConfig = useMemo(() => {
return createPublishedPageConfig({ return createApprovedPageConfig({
searchTerm, searchTerm,
setSearchTerm, setSearchTerm,
statusFilter,
setStatusFilter,
publishStatusFilter, publishStatusFilter,
setPublishStatusFilter, setPublishStatusFilter,
setCurrentPage, setCurrentPage,
@@ -293,7 +291,7 @@ export default function Published() {
navigate(`/writer/content/${row.id}`); navigate(`/writer/content/${row.id}`);
}, },
}); });
}, [searchTerm, statusFilter, publishStatusFilter, activeSector, navigate]); }, [searchTerm, publishStatusFilter, activeSector, navigate]);
// Calculate header metrics // Calculate header metrics
const headerMetrics = useMemo(() => { const headerMetrics = useMemo(() => {
@@ -308,8 +306,8 @@ export default function Published() {
return ( return (
<> <>
<PageHeader <PageHeader
title="Published" title="Approved"
badge={{ icon: <RocketLaunchIcon />, color: 'green' }} badge={{ icon: <CheckCircleIcon />, color: 'green' }}
parent="Writer" parent="Writer"
/> />
<TablePageTemplate <TablePageTemplate
@@ -320,24 +318,18 @@ export default function Published() {
filters={pageConfig.filters} filters={pageConfig.filters}
filterValues={{ filterValues={{
search: searchTerm, search: searchTerm,
status: statusFilter,
publishStatus: publishStatusFilter, publishStatus: publishStatusFilter,
}} }}
nextAction={selectedIds.length > 0 ? { primaryAction={{
label: 'Sync to WordPress', label: 'Publish to Site',
message: `${selectedIds.length} selected`, icon: <BoltIcon className="w-4 h-4" />,
onClick: () => handleBulkAction('publish_to_wordpress', selectedIds), onClick: () => handleBulkAction('bulk_publish_wordpress', selectedIds),
} : { variant: 'success',
label: 'Create More Content',
href: '/planner/keywords',
message: `${content.length} published`,
}} }}
getRowClassName={(row) => row.external_id ? 'bg-success-50 dark:bg-success-500/10' : ''}
onFilterChange={(key: string, value: any) => { onFilterChange={(key: string, value: any) => {
if (key === 'search') { if (key === 'search') {
setSearchTerm(value); setSearchTerm(value);
} else if (key === 'status') {
setStatusFilter(value);
setCurrentPage(1);
} else if (key === 'publishStatus') { } else if (key === 'publishStatus') {
setPublishStatusFilter(value); setPublishStatusFilter(value);
setCurrentPage(1); setCurrentPage(1);
@@ -372,23 +364,23 @@ export default function Published() {
<ModuleMetricsFooter <ModuleMetricsFooter
metrics={[ metrics={[
{ {
title: 'Published Content', title: 'Approved Content',
value: content.filter(c => c.status === 'published').length.toLocaleString(), value: content.length.toLocaleString(),
subtitle: `${content.filter(c => c.external_id).length} on WordPress`, subtitle: 'ready for publishing',
icon: <CheckCircleIcon className="w-5 h-5" />, icon: <CheckCircleIcon className="w-5 h-5" />,
accentColor: 'green', accentColor: 'green',
href: '/writer/published',
}, },
{ {
title: 'Draft Content', title: 'Published to Site',
value: content.filter(c => c.status === 'draft').length.toLocaleString(), value: content.filter(c => c.external_id).length.toLocaleString(),
subtitle: 'Not yet published', subtitle: 'on WordPress',
icon: <FileIcon className="w-5 h-5" />, icon: <RocketLaunchIcon className="w-5 h-5" />,
accentColor: 'blue', accentColor: 'blue',
href: '/writer/approved',
}, },
]} ]}
progress={{ progress={{
label: 'WordPress Publishing Progress', label: 'Site Publishing Progress',
value: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0, value: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
color: 'success', color: 'success',
}} }}

View File

@@ -242,15 +242,7 @@ export default function Content() {
status: statusFilter, status: statusFilter,
source: sourceFilter, source: sourceFilter,
}} }}
nextAction={selectedIds.length > 0 ? { getRowClassName={(row) => row.status === 'review' || row.status === 'published' ? 'bg-success-50 dark:bg-success-500/10' : ''}
label: 'Generate Images',
message: `${selectedIds.length} selected`,
onClick: () => handleRowAction('generate_images', { id: selectedIds[0] }),
} : content.filter(c => c.status === 'draft').length > 0 ? {
label: 'Generate Images',
href: '/writer/images',
message: `${content.filter(c => c.status === 'draft').length} drafts`,
} : undefined}
onFilterChange={(key: string, value: any) => { onFilterChange={(key: string, value: any) => {
if (key === 'search') { if (key === 'search') {
setSearchTerm(value); setSearchTerm(value);

View File

@@ -243,6 +243,52 @@ export default function Review() {
} }
}, [loadContent, toast]); }, [loadContent, toast]);
// Approve content - single item (changes status from 'review' to 'approved')
const handleApproveSingle = useCallback(async (row: Content) => {
try {
await fetchAPI(`/v1/writer/content/${row.id}/`, {
method: 'PATCH',
body: JSON.stringify({ status: 'approved' })
});
toast.success(`Approved "${row.title}"`);
loadContent();
} catch (error: any) {
toast.error(`Failed to approve: ${error.message || 'Network error'}`);
}
}, [loadContent, toast]);
// Approve content - bulk (changes status from 'review' to 'approved')
const handleApproveBulk = useCallback(async (ids: string[]) => {
try {
let successCount = 0;
let failedCount = 0;
for (const id of ids) {
try {
await fetchAPI(`/v1/writer/content/${id}/`, {
method: 'PATCH',
body: JSON.stringify({ status: 'approved' })
});
successCount++;
} catch (error) {
failedCount++;
console.error(`Error approving content ${id}:`, error);
}
}
if (successCount > 0) {
toast.success(`Successfully approved ${successCount} item(s)`);
}
if (failedCount > 0) {
toast.warning(`${failedCount} item(s) failed to approve`);
}
loadContent();
} catch (error: any) {
toast.error(`Failed to approve: ${error.message || 'Network error'}`);
}
}, [loadContent, toast]);
// Publish to WordPress - bulk // Publish to WordPress - bulk
const handlePublishBulk = useCallback(async (ids: string[]) => { const handlePublishBulk = useCallback(async (ids: string[]) => {
try { try {
@@ -291,21 +337,25 @@ export default function Review() {
// Bulk action handler // Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => { const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'bulk_publish_wordpress') { if (action === 'bulk_approve') {
await handleApproveBulk(ids);
} else if (action === 'bulk_publish_wordpress') {
await handlePublishBulk(ids); await handlePublishBulk(ids);
} else { } else {
toast.info(`Bulk action "${action}" for ${ids.length} items`); toast.info(`Bulk action "${action}" for ${ids.length} items`);
} }
}, [handlePublishBulk, toast]); }, [handleApproveBulk, handlePublishBulk, toast]);
// Row action handler // Row action handler
const handleRowAction = useCallback(async (action: string, row: Content) => { const handleRowAction = useCallback(async (action: string, row: Content) => {
if (action === 'publish_wordpress') { if (action === 'approve') {
await handleApproveSingle(row);
} else if (action === 'publish_wordpress') {
await handlePublishSingle(row); await handlePublishSingle(row);
} else if (action === 'view') { } else if (action === 'view') {
navigate(`/writer/content/${row.id}`); navigate(`/writer/content/${row.id}`);
} }
}, [handlePublishSingle, navigate]); }, [handleApproveSingle, handlePublishSingle, navigate]);
// Delete handler (single) // Delete handler (single)
const handleDelete = useCallback(async (id: string) => { const handleDelete = useCallback(async (id: string) => {
@@ -360,15 +410,13 @@ export default function Review() {
filterValues={{ filterValues={{
search: searchTerm, search: searchTerm,
}} }}
nextAction={selectedIds.length > 0 ? { primaryAction={{
label: 'Publish Selected', label: 'Approve Selected',
message: `${selectedIds.length} selected`, icon: <CheckCircleIcon className="w-4 h-4" />,
onClick: () => handleBulkAction('publish', selectedIds), onClick: () => handleBulkAction('bulk_approve', selectedIds),
} : content.filter(c => c.status === 'review').length > 0 ? { variant: 'success',
label: 'View Published', }}
href: '/writer/published', getRowClassName={(row) => row.status === 'approved' ? 'bg-success-50 dark:bg-success-500/10' : ''}
message: `${content.filter(c => c.status === 'review').length} in review`,
} : undefined}
onFilterChange={(key, value) => { onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value); const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') { if (key === 'search') {

View File

@@ -385,15 +385,7 @@ export default function Tasks() {
content_type: typeFilter, content_type: typeFilter,
source: sourceFilter, source: sourceFilter,
}} }}
nextAction={selectedIds.length > 0 ? { getRowClassName={(row) => row.status === 'completed' ? 'bg-success-50 dark:bg-success-500/10' : ''}
label: 'Generate Content',
message: `${selectedIds.length} selected`,
onClick: () => handleBulkAction('generate_content', selectedIds),
} : tasks.filter(t => t.status === 'queued').length > 0 ? {
label: 'View Drafts',
href: '/writer/content',
message: `${tasks.filter(t => t.status === 'queued').length} queued`,
} : undefined}
onFilterChange={(key, value) => { onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value); const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') { if (key === 'search') {

View File

@@ -142,14 +142,15 @@ interface TablePageTemplateProps {
icon?: ReactNode; icon?: ReactNode;
variant?: 'primary' | 'success' | 'danger'; variant?: 'primary' | 'success' | 'danger';
}>; }>;
// Next action button for workflow guidance (shown in action bar) // Primary workflow action button (shown before Bulk Actions, enabled only with selection)
nextAction?: { primaryAction?: {
label: string; label: string;
message?: string; // Message to show above button (e.g., "5 selected") icon?: ReactNode;
onClick?: () => void; onClick: () => void;
href?: string; variant?: 'primary' | 'success' | 'warning';
disabled?: boolean;
}; };
// Custom row highlight function (returns bg class based on row data)
getRowClassName?: (row: any) => string;
} }
export default function TablePageTemplate({ export default function TablePageTemplate({
@@ -186,7 +187,8 @@ export default function TablePageTemplate({
className = '', className = '',
customActions, customActions,
bulkActions: customBulkActions, bulkActions: customBulkActions,
nextAction, primaryAction,
getRowClassName,
}: TablePageTemplateProps) { }: TablePageTemplateProps) {
const location = useLocation(); const location = useLocation();
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false); const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
@@ -561,8 +563,27 @@ export default function TablePageTemplate({
<div className={className}> <div className={className}>
{/* Bulk Actions and Action Buttons Row - Fixed height container */} {/* Bulk Actions and Action Buttons Row - Fixed height container */}
<div className="flex items-center justify-between min-h-[65px] mb-4 mt-[10px]"> <div className="flex items-center justify-between min-h-[65px] mb-4 mt-[10px]">
{/* Left side - Bulk Actions and Filter Toggle */} {/* Left side - Primary Action, Bulk Actions, and Filter Toggle */}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{/* Primary Workflow Action Button - Only enabled with selection */}
{primaryAction && (
<Button
size="md"
onClick={primaryAction.onClick}
disabled={selectedIds.length === 0}
variant={primaryAction.variant === 'success' ? 'success' : primaryAction.variant === 'warning' ? 'primary' : 'primary'}
startIcon={primaryAction.icon}
className={selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""}
>
{primaryAction.label}
{selectedIds.length > 0 && (
<span className="ml-2 inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium rounded-full bg-white/20 text-white">
{selectedIds.length}
</span>
)}
</Button>
)}
{/* Bulk Actions - Single button if only one action, dropdown if multiple */} {/* Bulk Actions - Single button if only one action, dropdown if multiple */}
{bulkActions.length > 0 && ( {bulkActions.length > 0 && (
<div className="inline-block"> <div className="inline-block">
@@ -750,44 +771,6 @@ export default function TablePageTemplate({
{createLabel} {createLabel}
</Button> </Button>
)} )}
{/* Next Action Button - Workflow Guidance */}
{nextAction && (
<div className="flex flex-col items-end ml-2">
{nextAction.message && (
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1">{nextAction.message}</span>
)}
{nextAction.href ? (
<a
href={nextAction.href}
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
nextAction.disabled
? 'bg-gray-200 text-gray-400 cursor-not-allowed dark:bg-gray-700 dark:text-gray-500'
: 'bg-success-500 text-white hover:bg-success-600 dark:bg-success-600 dark:hover:bg-success-500'
}`}
>
{nextAction.label}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
) : (
<button
onClick={nextAction.onClick}
disabled={nextAction.disabled}
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
nextAction.disabled
? 'bg-gray-200 text-gray-400 cursor-not-allowed dark:bg-gray-700 dark:text-gray-500'
: 'bg-success-500 text-white hover:bg-success-600 dark:bg-success-600 dark:hover:bg-success-500'
}`}
>
{nextAction.label}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</button>
)}
</div>
)}
</div> </div>
</div> </div>
@@ -906,10 +889,13 @@ export default function TablePageTemplate({
// Use same logic as handleBulkAddSelected - check if isAdded is truthy // Use same logic as handleBulkAddSelected - check if isAdded is truthy
const isRowAdded = !!(row as any).isAdded; const isRowAdded = !!(row as any).isAdded;
// Get custom row class from prop if provided
const customRowClass = getRowClassName ? getRowClassName(row) : '';
return ( return (
<React.Fragment key={row.id || index}> <React.Fragment key={row.id || index}>
<TableRow <TableRow
className={`igny8-data-row ${isRowAdded ? 'bg-blue-50 dark:bg-blue-500/10' : ''}`} className={`igny8-data-row ${isRowAdded ? 'bg-blue-50 dark:bg-blue-500/10' : ''} ${customRowClass}`}
> >
{selection && ( {selection && (
<TableCell className="px-5 py-4 text-start"> <TableCell className="px-5 py-4 text-start">