feat: Implement Keywords management page with DataGrid

Frontend Phase 2 - Keywords Management:
- Add MainLayout component with sidebar navigation
- Implement Keywords page with MUI DataGrid
- Add Keywords CRUD operations (Create, Edit, Delete dialogs)
- Add search and filter functionality (Category, Status)
- Install @mui/x-data-grid package for table component
- Update routing to include Keywords page
- Update Dashboard to use MainLayout
- Add navigation menu items for all planned pages

Features implemented:
- Keywords list with DataGrid table
- Add/Edit keyword dialog with form validation
- Delete confirmation dialog
- Category filter (People, Topics, Companies)
- Status filter (Active, Inactive)
- Search functionality
- Priority management

Tested in browser:
- Page loads successfully
- API integration working (200 OK)
- Layout and navigation functional
- All UI components rendering correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2025-11-04 21:47:45 +09:00
parent 55fcce9a38
commit 30fe4d0368
6 changed files with 818 additions and 51 deletions

View File

@ -12,6 +12,7 @@
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@mui/icons-material": "^6.1.3", "@mui/icons-material": "^6.1.3",
"@mui/material": "^6.1.3", "@mui/material": "^6.1.3",
"@mui/x-data-grid": "^8.16.0",
"@tanstack/react-query": "^5.56.2", "@tanstack/react-query": "^5.56.2",
"axios": "^1.7.7", "axios": "^1.7.7",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@ -1304,6 +1305,229 @@
} }
} }
}, },
"node_modules/@mui/x-data-grid": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.16.0.tgz",
"integrity": "sha512-yJ+v+E1yI1HxrEUdOfgrUTCxobAFvotGggU6cy6MnM7c7/TPPg9d5mDzjzxb0imOCJ6WyiM/vtd5WKbY/5sUNw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.3",
"@mui/x-internals": "8.16.0",
"@mui/x-virtualizer": "0.2.6",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
}
},
"node_modules/@mui/x-data-grid/node_modules/@mui/types": {
"version": "7.4.8",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz",
"integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-data-grid/node_modules/@mui/utils": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz",
"integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/types": "^7.4.8",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-internals": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.16.0.tgz",
"integrity": "sha512-JR53WOFqmQYQzurOpB0H91K7/9uMcte1ooxHxTLGB+97PgB+rKY6siRWvUALGS56XyPV+1a2ALI33hd2E7+Rgg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.3",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mui/x-internals/node_modules/@mui/types": {
"version": "7.4.8",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz",
"integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-internals/node_modules/@mui/utils": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz",
"integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/types": "^7.4.8",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-virtualizer": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.2.6.tgz",
"integrity": "sha512-t45EHhD9kStSwIYMkqYYQIFbZNVQws9LRANktf0e/+j+MxsRTFk41r0rgiazMSOSugJlCuSh/H8xUUuMCZdtow==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.3",
"@mui/x-internals": "8.16.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mui/x-virtualizer/node_modules/@mui/types": {
"version": "7.4.8",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz",
"integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-virtualizer/node_modules/@mui/utils": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz",
"integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/types": "^7.4.8",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -4193,6 +4417,12 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -4567,6 +4797,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": { "node_modules/victory-vendor": {
"version": "36.9.2", "version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",

View File

@ -11,17 +11,18 @@
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^6.1.3",
"@mui/material": "^6.1.3",
"@mui/x-data-grid": "^8.16.0",
"@tanstack/react-query": "^5.56.2",
"axios": "^1.7.7",
"date-fns": "^4.1.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"@mui/material": "^6.1.3",
"@mui/icons-material": "^6.1.3",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@tanstack/react-query": "^5.56.2",
"axios": "^1.7.7",
"recharts": "^2.12.7", "recharts": "^2.12.7",
"date-fns": "^4.1.0",
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './stores/authStore' import { useAuthStore } from './stores/authStore'
import Login from './pages/Login' import Login from './pages/Login'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Keywords from './pages/Keywords'
function App() { function App() {
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
@ -24,6 +25,13 @@ function App() {
} }
/> />
<Route
path="/keywords"
element={
isAuthenticated ? <Keywords /> : <Navigate to="/login" replace />
}
/>
{/* Catch all - redirect to dashboard or login */} {/* Catch all - redirect to dashboard or login */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@ -0,0 +1,175 @@
import { ReactNode, useState } from 'react'
import {
Box,
Drawer,
AppBar,
Toolbar,
List,
Typography,
Divider,
IconButton,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Button,
} from '@mui/material'
import {
Menu as MenuIcon,
Dashboard as DashboardIcon,
Label as KeywordIcon,
AccountTree as PipelineIcon,
People as PeopleIcon,
Apps as AppsIcon,
Article as ArticleIcon,
BarChart as MonitoringIcon,
} from '@mui/icons-material'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
const drawerWidth = 240
interface MainLayoutProps {
children: ReactNode
}
interface MenuItem {
text: string
icon: ReactNode
path: string
}
const menuItems: MenuItem[] = [
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/dashboard' },
{ text: 'Keywords', icon: <KeywordIcon />, path: '/keywords' },
{ text: 'Pipelines', icon: <PipelineIcon />, path: '/pipelines' },
{ text: 'Users', icon: <PeopleIcon />, path: '/users' },
{ text: 'Applications', icon: <AppsIcon />, path: '/applications' },
{ text: 'Articles', icon: <ArticleIcon />, path: '/articles' },
{ text: 'Monitoring', icon: <MonitoringIcon />, path: '/monitoring' },
]
export default function MainLayout({ children }: MainLayoutProps) {
const [mobileOpen, setMobileOpen] = useState(false)
const navigate = useNavigate()
const location = useLocation()
const { user, logout } = useAuthStore()
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen)
}
const handleLogout = async () => {
await logout()
navigate('/login')
}
const handleNavigate = (path: string) => {
navigate(path)
setMobileOpen(false)
}
const drawer = (
<div>
<Toolbar>
<Typography variant="h6" noWrap component="div">
News Engine
</Typography>
</Toolbar>
<Divider />
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => handleNavigate(item.path)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</div>
)
return (
<Box sx={{ display: 'flex' }}>
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
News Engine Console
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2">
{user?.full_name} ({user?.role})
</Typography>
<Button color="inherit" onClick={handleLogout}>
Logout
</Button>
</Box>
</Toolbar>
</AppBar>
<Box
component="nav"
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
aria-label="navigation"
>
{/* Mobile drawer */}
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawer}
</Drawer>
{/* Desktop drawer */}
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { sm: `calc(100% - ${drawerWidth}px)` },
minHeight: '100vh',
bgcolor: '#f5f5f5',
}}
>
<Toolbar />
{children}
</Box>
</Box>
)
}

View File

@ -1,46 +1,10 @@
import { Box, Container, Typography, Button, Paper, Grid } from '@mui/material' import { Box, Typography, Paper, Grid } from '@mui/material'
import { useNavigate } from 'react-router-dom' import MainLayout from '../components/MainLayout'
import { useAuthStore } from '../stores/authStore'
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const handleLogout = async () => {
await logout()
navigate('/login')
}
return ( return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f5f5f5' }}> <MainLayout>
{/* Header */} <Box>
<Box
sx={{
bgcolor: 'white',
borderBottom: 1,
borderColor: 'divider',
py: 2,
}}
>
<Container maxWidth="xl">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5" component="h1">
News Engine Console
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
{user?.full_name} ({user?.role})
</Typography>
<Button variant="outlined" size="small" onClick={handleLogout}>
Logout
</Button>
</Box>
</Box>
</Container>
</Box>
{/* Main Content */}
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
Dashboard Dashboard
</Typography> </Typography>
@ -106,8 +70,8 @@ export default function Dashboard() {
Frontend is now up and running! Next steps: Frontend is now up and running! Next steps:
</Typography> </Typography>
<Box component="ul" sx={{ mt: 1 }}> <Box component="ul" sx={{ mt: 1 }}>
<li>Implement sidebar navigation</li> <li>Implement sidebar navigation </li>
<li>Create Keywords management page</li> <li>Create Keywords management page </li>
<li>Create Pipelines management page</li> <li>Create Pipelines management page</li>
<li>Create Users management page</li> <li>Create Users management page</li>
<li>Create Applications management page</li> <li>Create Applications management page</li>
@ -116,7 +80,7 @@ export default function Dashboard() {
</Paper> </Paper>
</Grid> </Grid>
</Grid> </Grid>
</Container> </Box>
</Box> </MainLayout>
) )
} }

View File

@ -0,0 +1,380 @@
import { useState, useEffect } from 'react'
import {
Box,
Button,
Paper,
Typography,
IconButton,
Chip,
TextField,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
} from '@mui/material'
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Search as SearchIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material'
import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'
import MainLayout from '../components/MainLayout'
import { getKeywords, createKeyword, updateKeyword, deleteKeyword } from '@/api/keywords'
import type { Keyword, KeywordCreate, KeywordUpdate } from '@/types'
const Keywords = () => {
const [keywords, setKeywords] = useState<Keyword[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
// Dialog states
const [openDialog, setOpenDialog] = useState(false)
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create')
const [selectedKeyword, setSelectedKeyword] = useState<Keyword | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
// Form states
const [formData, setFormData] = useState<KeywordCreate>({
keyword: '',
category: 'topics',
status: 'active',
priority: 5,
})
useEffect(() => {
fetchKeywords()
}, [search, categoryFilter, statusFilter])
const fetchKeywords = async () => {
setLoading(true)
setError(null)
try {
const params: any = {}
if (search) params.search = search
if (categoryFilter !== 'all') params.category = categoryFilter
if (statusFilter !== 'all') params.status = statusFilter
const data = await getKeywords(params)
setKeywords(data)
} catch (err: any) {
setError(err.message || 'Failed to fetch keywords')
} finally {
setLoading(false)
}
}
const handleOpenDialog = (mode: 'create' | 'edit', keyword?: Keyword) => {
setDialogMode(mode)
if (mode === 'edit' && keyword) {
setSelectedKeyword(keyword)
setFormData({
keyword: keyword.keyword,
category: keyword.category,
status: keyword.status,
priority: keyword.priority,
})
} else {
setSelectedKeyword(null)
setFormData({
keyword: '',
category: 'topics',
status: 'active',
priority: 5,
})
}
setOpenDialog(true)
}
const handleCloseDialog = () => {
setOpenDialog(false)
setSelectedKeyword(null)
setFormData({
keyword: '',
category: 'topics',
status: 'active',
priority: 5,
})
}
const handleSubmit = async () => {
try {
if (dialogMode === 'create') {
await createKeyword(formData)
} else if (selectedKeyword?.id) {
await updateKeyword(selectedKeyword.id, formData as KeywordUpdate)
}
handleCloseDialog()
fetchKeywords()
} catch (err: any) {
setError(err.message || 'Failed to save keyword')
}
}
const handleDelete = async () => {
if (!selectedKeyword?.id) return
try {
await deleteKeyword(selectedKeyword.id)
setDeleteDialogOpen(false)
setSelectedKeyword(null)
fetchKeywords()
} catch (err: any) {
setError(err.message || 'Failed to delete keyword')
}
}
const columns: GridColDef[] = [
{
field: 'keyword',
headerName: 'Keyword',
flex: 1,
minWidth: 200,
},
{
field: 'category',
headerName: 'Category',
width: 120,
renderCell: (params: GridRenderCellParams) => {
const colorMap: Record<string, 'primary' | 'secondary' | 'success'> = {
people: 'primary',
topics: 'secondary',
companies: 'success',
}
return (
<Chip
label={params.value}
color={colorMap[params.value] || 'default'}
size="small"
/>
)
},
},
{
field: 'status',
headerName: 'Status',
width: 100,
renderCell: (params: GridRenderCellParams) => {
return (
<Chip
label={params.value}
color={params.value === 'active' ? 'success' : 'default'}
size="small"
/>
)
},
},
{
field: 'priority',
headerName: 'Priority',
width: 80,
align: 'center',
},
{
field: 'created_at',
headerName: 'Created At',
width: 180,
valueFormatter: (value) => {
return new Date(value).toLocaleString()
},
},
{
field: 'actions',
headerName: 'Actions',
width: 150,
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<Box>
<IconButton
size="small"
onClick={() => handleOpenDialog('edit', params.row)}
color="primary"
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
setSelectedKeyword(params.row)
setDeleteDialogOpen(true)
}}
color="error"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
),
},
]
return (
<MainLayout>
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h4" component="h1">
Keywords Management
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog('create')}
>
Add Keyword
</Button>
</Box>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Filters */}
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
placeholder="Search keywords..."
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
sx={{ flexGrow: 1 }}
InputProps={{
startAdornment: <SearchIcon sx={{ color: 'action.active', mr: 1 }} />,
}}
/>
<TextField
select
label="Category"
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
size="small"
sx={{ minWidth: 150 }}
>
<MenuItem value="all">All Categories</MenuItem>
<MenuItem value="people">People</MenuItem>
<MenuItem value="topics">Topics</MenuItem>
<MenuItem value="companies">Companies</MenuItem>
</TextField>
<TextField
select
label="Status"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
size="small"
sx={{ minWidth: 120 }}
>
<MenuItem value="all">All Status</MenuItem>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="inactive">Inactive</MenuItem>
</TextField>
<IconButton onClick={fetchKeywords} color="primary">
<RefreshIcon />
</IconButton>
</Box>
</Paper>
{/* Data Grid */}
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={keywords}
columns={columns}
loading={loading}
pageSizeOptions={[10, 25, 50, 100]}
initialState={{
pagination: {
paginationModel: { pageSize: 25 },
},
}}
disableRowSelectionOnClick
sx={{
'& .MuiDataGrid-cell:focus': {
outline: 'none',
},
}}
/>
</Paper>
{/* Create/Edit Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{dialogMode === 'create' ? 'Add New Keyword' : 'Edit Keyword'}
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<TextField
label="Keyword"
value={formData.keyword}
onChange={(e) => setFormData({ ...formData, keyword: e.target.value })}
fullWidth
required
/>
<TextField
select
label="Category"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as any })}
fullWidth
required
>
<MenuItem value="people">People</MenuItem>
<MenuItem value="topics">Topics</MenuItem>
<MenuItem value="companies">Companies</MenuItem>
</TextField>
<TextField
select
label="Status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
fullWidth
>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="inactive">Inactive</MenuItem>
</TextField>
<TextField
label="Priority"
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) })}
fullWidth
inputProps={{ min: 1, max: 10 }}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained" disabled={!formData.keyword}>
{dialogMode === 'create' ? 'Create' : 'Update'}
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete the keyword "{selectedKeyword?.keyword}"?
This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} variant="contained" color="error">
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
</MainLayout>
)
}
export default Keywords