Files
igny8/frontend/src/config/pages/keywords.config.tsx
2025-11-14 15:41:25 +05:00

592 lines
20 KiB
TypeScript

/**
* Keywords Page Configuration
* Centralized config for Keywords page table, filters, and actions
*
* This config is fully dynamic - all columns, filters, actions, and form fields
* are defined here instead of inline in the component.
*/
import React from 'react';
import {
keywordColumn,
volumeColumn,
difficultyColumn,
intentColumn,
clusterColumn,
sectorColumn,
statusColumn,
createdColumn,
} from '../snippets/columns.snippets';
// Icons removed - bulkActions and rowActions are now in table-actions.config.tsx
import Badge from '../../components/ui/badge/Badge';
import { getDifficultyNumber, getDifficultyOptions, getDifficultyValueFromNumber } from '../../utils/difficulty';
import { formatRelativeDate } from '../../utils/date';
import { Keyword } from '../../services/api';
import Input from '../../components/form/input/InputField';
import Label from '../../components/form/Label';
import Button from '../../components/ui/button/Button';
// SelectDropdown not used directly in config - removed
// Type definitions
export interface ColumnConfig {
key: string;
label: string;
sortable?: boolean;
sortField?: string;
align?: 'left' | 'center' | 'right';
width?: string;
render?: (value: any, row: any) => React.ReactNode;
defaultVisible?: boolean; // Whether column is visible by default (default: true)
}
// BulkActionConfig and RowActionConfig are now in table-actions.config.tsx
export interface FormFieldConfig {
key: string;
label: string;
type: 'text' | 'number' | 'select';
placeholder?: string;
required?: boolean;
min?: number;
max?: number;
className?: string;
options?: Array<{ value: string; label: string }>;
value: any; // Required for FormModal
onChange: (value: any) => void; // Required for FormModal
}
export interface FilterConfig {
key: string;
label: string;
type: 'text' | 'select' | 'daterange' | 'range' | 'custom';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
min?: number;
max?: number;
step?: number;
className?: string;
customRender?: () => React.ReactNode; // For complex custom filters like volume range
dynamicOptions?: string; // e.g., 'clusters' - flag for dynamic option loading
}
export interface HeaderMetricConfig {
label: string;
value: number;
accentColor: 'blue' | 'green' | 'amber' | 'purple';
calculate: (data: { keywords: any[]; totalCount: number; clusters: any[] }) => number;
}
export interface KeywordsPageConfig {
columns: ColumnConfig[];
filters: FilterConfig[];
// bulkActions and rowActions are now global - defined in table-actions.config.tsx
formFields: (clusters: Array<{ id: number; name: string }>) => FormFieldConfig[];
headerMetrics: HeaderMetricConfig[];
exportConfig: {
endpoint: string;
filename: string;
formats: Array<'csv' | 'json'>;
};
importConfig: {
endpoint: string;
acceptedFormats: string[];
maxFileSize: number;
};
}
/**
* Factory function to create keywords page config
* Accepts handlers/closures from component for actions that need them
*/
export const createKeywordsPageConfig = (
handlers: {
clusters: Array<{ id: number; name: string }>;
activeSector: { id: number; name: string } | null;
availableSeedKeywords: Array<{ id: number; keyword: string; volume: number; difficulty: number; intent: string }>;
formData: {
seed_keyword_id: number;
volume_override?: number | null;
difficulty_override?: number | null;
cluster_id?: number | null;
status: string;
};
setFormData: React.Dispatch<React.SetStateAction<any>>;
// Filter state handlers
searchTerm: string;
setSearchTerm: (value: string) => void;
statusFilter: string;
setStatusFilter: (value: string) => void;
intentFilter: string;
setIntentFilter: (value: string) => void;
difficultyFilter: string;
setDifficultyFilter: (value: string) => void;
clusterFilter: string;
setClusterFilter: (value: string) => void;
volumeMin: number | '';
volumeMax: number | '';
setVolumeMin: (value: number | '') => void;
setVolumeMax: (value: number | '') => void;
isVolumeDropdownOpen: boolean;
setIsVolumeDropdownOpen: (value: boolean) => void;
tempVolumeMin: number | '';
tempVolumeMax: number | '';
setTempVolumeMin: (value: number | '') => void;
setTempVolumeMax: (value: number | '') => void;
volumeButtonRef: React.RefObject<HTMLButtonElement | null>;
volumeDropdownRef: React.RefObject<HTMLDivElement | null>;
setCurrentPage: (page: number) => void;
loadKeywords: () => Promise<void>;
}
): KeywordsPageConfig => {
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
return {
columns: [
{
...keywordColumn,
sortable: true,
sortField: 'keyword',
},
// Sector column - only show when viewing all sectors
...(showSectorColumn ? [{
...sectorColumn,
render: (value: string, row: Keyword) => (
<Badge color="info" size="sm" variant="light">
{row.sector_name || '-'}
</Badge>
),
}] : []),
{
...volumeColumn,
sortable: true,
sortField: 'volume',
render: (value: number) => value.toLocaleString(),
},
{
...clusterColumn,
sortable: true,
sortField: 'cluster_id',
render: (_value: string, row: Keyword) => row.cluster_name || '-',
},
{
...difficultyColumn,
sortable: true,
sortField: 'difficulty',
align: 'center' as const,
render: (value: number) => {
const difficultyNum = getDifficultyNumber(value);
const difficultyBadgeVariant = 'light';
const difficultyBadgeColor =
typeof difficultyNum === 'number' && difficultyNum === 1
? 'success'
: typeof difficultyNum === 'number' && difficultyNum === 2
? 'success'
: typeof difficultyNum === 'number' && difficultyNum === 3
? 'warning'
: typeof difficultyNum === 'number' && difficultyNum === 4
? 'error'
: typeof difficultyNum === 'number' && difficultyNum === 5
? 'error'
: 'light';
return typeof difficultyNum === 'number' ? (
<Badge
color={difficultyBadgeColor}
variant={difficultyBadgeVariant}
size="sm"
>
{difficultyNum}
</Badge>
) : (
difficultyNum
);
},
},
{
...intentColumn,
sortable: true,
sortField: 'intent',
render: (value: string) => {
// Map intent values to badge colors
// Transactional and Commercial → success (green, like active)
// Navigational → warning (amber/yellow, like pending)
// Informational → info (blue)
const getIntentColor = (intent: string) => {
const lowerIntent = intent?.toLowerCase() || '';
if (lowerIntent === 'transactional' || lowerIntent === 'commercial') {
return 'success'; // Green, like active status
} else if (lowerIntent === 'navigational') {
return 'warning'; // Amber/yellow, like pending status
}
return 'info'; // Blue for informational or default
};
return (
<Badge
color={getIntentColor(value)}
size="sm"
variant={value?.toLowerCase() === 'informational' ? 'light' : undefined}
>
{value}
</Badge>
);
},
},
{
...statusColumn,
sortable: true,
sortField: 'status',
render: (value: string) => {
return (
<Badge
color={
value === 'active'
? 'success'
: value === 'pending'
? 'warning'
: 'error'
}
size="sm"
>
{value}
</Badge>
);
},
},
{
...createdColumn,
sortable: true,
sortField: 'created_at',
render: (value: string) => formatRelativeDate(value),
},
// Optional columns - hidden by default
{
key: 'updated_at',
label: 'Updated',
sortable: true,
sortField: 'updated_at',
defaultVisible: false,
render: (value: string) => formatRelativeDate(value),
},
{
key: 'volume_override',
label: 'Volume Override',
sortable: true,
sortField: 'volume_override',
defaultVisible: false,
render: (value: number | null) => value ? value.toLocaleString() : '-',
},
{
key: 'difficulty_override',
label: 'Difficulty Override',
sortable: true,
sortField: 'difficulty_override',
defaultVisible: false,
align: 'center' as const,
render: (value: number | null) => {
if (value === null || value === undefined) return '-';
const difficultyNum = getDifficultyNumber(value);
return typeof difficultyNum === 'number' ? (
<Badge
color={
difficultyNum === 1 || difficultyNum === 2
? 'success'
: difficultyNum === 3
? 'warning'
: 'error'
}
variant={difficultyNum === 5 ? 'solid' : 'light'}
size="sm"
>
{difficultyNum}
</Badge>
) : (
difficultyNum
);
},
},
],
filters: [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search keywords...',
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: '', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'pending', label: 'Pending' },
{ value: 'archived', label: 'Archived' },
],
},
{
key: 'intent',
label: 'Intent',
type: 'select',
options: [
{ value: '', label: 'All Intent' },
{ value: 'informational', label: 'Informational' },
{ value: 'navigational', label: 'Navigational' },
{ value: 'transactional', label: 'Transactional' },
{ value: 'commercial', label: 'Commercial' },
],
},
{
key: 'difficulty',
label: 'Difficulty',
type: 'select',
options: [
{ value: '', label: 'All Difficulty' },
{ value: '1', label: '1 - Very Easy' },
{ value: '2', label: '2 - Easy' },
{ value: '3', label: '3 - Medium' },
{ value: '4', label: '4 - Hard' },
{ value: '5', label: '5 - Very Hard' },
],
},
{
key: 'volume',
label: 'Volume Range',
type: 'custom',
customRender: () => (
<div className="relative flex-1 min-w-[140px]">
<button
ref={handlers.volumeButtonRef}
type="button"
onClick={() => {
handlers.setIsVolumeDropdownOpen(!handlers.isVolumeDropdownOpen);
handlers.setTempVolumeMin(handlers.volumeMin);
handlers.setTempVolumeMax(handlers.volumeMax);
}}
className={`igny8-select-styled h-9 w-full appearance-none rounded-lg border border-gray-300 bg-transparent px-3 py-2 pr-10 text-sm shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
handlers.volumeMin || handlers.volumeMax
? "text-gray-800 dark:text-white/90"
: "text-gray-400 dark:text-gray-400"
} ${
handlers.isVolumeDropdownOpen
? "border-brand-300 ring-3 ring-brand-500/10 dark:border-brand-800"
: ""
}`}
>
<span className="block text-left truncate">
{handlers.volumeMin || handlers.volumeMax
? `Vol: ${handlers.volumeMin || 'Min'} - ${handlers.volumeMax || 'Max'}`
: 'Volume Range'}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg
className="h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</span>
</button>
{/* Dropdown Menu */}
{handlers.isVolumeDropdownOpen && (
<div
ref={handlers.volumeDropdownRef}
className="absolute z-50 left-0 right-0 mt-1 rounded-lg border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark overflow-hidden p-4 min-w-[280px]"
>
<div className="space-y-3">
<div>
<Label htmlFor="vol-min" className="text-xs mb-1">Min Volume</Label>
<Input
id="vol-min"
type="number"
placeholder="Min"
value={handlers.tempVolumeMin}
onChange={(e) => {
const val = e.target.value;
handlers.setTempVolumeMin(val === '' ? '' : parseInt(val) || '');
}}
className="w-full h-9"
/>
</div>
<div>
<Label htmlFor="vol-max" className="text-xs mb-1">Max Volume</Label>
<Input
id="vol-max"
type="number"
placeholder="Max"
value={handlers.tempVolumeMax}
onChange={(e) => {
const val = e.target.value;
handlers.setTempVolumeMax(val === '' ? '' : parseInt(val) || '');
}}
className="w-full h-9"
/>
</div>
<div className="flex gap-2 pt-2">
<Button
size="sm"
variant="primary"
onClick={async () => {
const newMin = handlers.tempVolumeMin === '' ? '' : Number(handlers.tempVolumeMin);
const newMax = handlers.tempVolumeMax === '' ? '' : Number(handlers.tempVolumeMax);
handlers.setIsVolumeDropdownOpen(false);
handlers.setVolumeMin(newMin);
handlers.setVolumeMax(newMax);
handlers.setCurrentPage(1);
setTimeout(() => {
handlers.loadKeywords();
}, 0);
}}
className="flex-1"
>
OK
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
handlers.setIsVolumeDropdownOpen(false);
handlers.setTempVolumeMin(handlers.volumeMin);
handlers.setTempVolumeMax(handlers.volumeMax);
}}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</div>
)}
</div>
),
},
{
key: 'cluster_id',
label: 'Cluster',
type: 'select',
options: (() => {
// Dynamically generate options from current clusters
return [
{ value: '', label: 'All Clusters' },
...handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
];
})(),
className: 'w-40',
},
],
headerMetrics: [
{
label: 'Total Keywords',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
},
{
label: 'Mapped',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.keywords.filter((k: Keyword) => k.cluster_id).length,
},
{
label: 'Unmapped',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.keywords.filter((k: Keyword) => !k.cluster_id).length,
},
{
label: 'Total Volume',
value: 0,
accentColor: 'purple' as const,
calculate: (data) => data.keywords.reduce((sum: number, k: Keyword) => sum + (k.volume || 0), 0),
},
],
// bulkActions and rowActions are now global - defined in table-actions.config.ts
// They're automatically loaded by TablePageTemplate based on the current route
formFields: (clusters: Array<{ id: number; name: string }>) => [
{
key: 'seed_keyword_id',
label: 'Seed Keyword',
type: 'select',
placeholder: 'Select a seed keyword',
value: handlers.formData.seed_keyword_id?.toString() || '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, seed_keyword_id: value ? parseInt(value) : 0 }),
required: true,
options: [
{ value: '', label: 'Select a keyword...' },
...handlers.availableSeedKeywords.map((sk) => ({
value: sk.id.toString(),
label: `${sk.keyword} (Vol: ${sk.volume.toLocaleString()}, Diff: ${sk.difficulty}, ${sk.intent})`,
})),
],
},
{
key: 'volume_override',
label: 'Volume Override (optional)',
type: 'number',
placeholder: 'Leave empty to use seed keyword volume',
value: handlers.formData.volume_override ?? '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, volume_override: value ? parseInt(value) : null }),
},
{
key: 'difficulty_override',
label: 'Difficulty Override (optional)',
type: 'number',
placeholder: 'Leave empty to use seed keyword difficulty',
value: handlers.formData.difficulty_override ?? '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, difficulty_override: value ? parseInt(value) : null }),
min: 0,
max: 100,
},
{
key: 'cluster_id',
label: 'Cluster',
type: 'select',
value: handlers.formData.cluster_id?.toString() || '',
onChange: (value: any) =>
handlers.setFormData({
...handlers.formData,
cluster_id: value ? parseInt(value) : null,
}),
options: [
{ value: '', label: 'No Cluster' },
...clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
],
},
{
key: 'status',
label: 'Status',
type: 'select',
value: handlers.formData.status || 'pending',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, status: value }),
options: [
{ value: 'pending', label: 'Pending' },
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' },
],
},
],
exportConfig: {
endpoint: '/v1/planner/keywords/export/',
filename: 'keywords',
formats: ['csv', 'json'],
},
importConfig: {
endpoint: '/v1/planner/keywords/import_keywords/',
acceptedFormats: ['.csv'],
maxFileSize: 5 * 1024 * 1024, // 5MB
},
};
};