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:
239
services/news-engine-console/frontend/package-lock.json
generated
239
services/news-engine-console/frontend/package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"@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",
|
||||
@ -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": {
|
||||
"version": "2.1.5",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@ -4567,6 +4797,15 @@
|
||||
"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": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
|
||||
@ -11,17 +11,18 @@
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"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-dom": "^18.3.1",
|
||||
"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",
|
||||
"date-fns": "^4.1.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Keywords from './pages/Keywords'
|
||||
|
||||
function App() {
|
||||
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 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,46 +1,10 @@
|
||||
import { Box, Container, Typography, Button, Paper, Grid } from '@mui/material'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { Box, Typography, Paper, Grid } from '@mui/material'
|
||||
import MainLayout from '../components/MainLayout'
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: '#f5f5f5' }}>
|
||||
{/* Header */}
|
||||
<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 }}>
|
||||
<MainLayout>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Dashboard
|
||||
</Typography>
|
||||
@ -106,8 +70,8 @@ export default function Dashboard() {
|
||||
Frontend is now up and running! Next steps:
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ mt: 1 }}>
|
||||
<li>Implement sidebar navigation</li>
|
||||
<li>Create Keywords management page</li>
|
||||
<li>Implement sidebar navigation ✅</li>
|
||||
<li>Create Keywords management page ✅</li>
|
||||
<li>Create Pipelines management page</li>
|
||||
<li>Create Users management page</li>
|
||||
<li>Create Applications management page</li>
|
||||
@ -116,7 +80,7 @@ export default function Dashboard() {
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
|
||||
380
services/news-engine-console/frontend/src/pages/Keywords.tsx
Normal file
380
services/news-engine-console/frontend/src/pages/Keywords.tsx
Normal 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
|
||||
Reference in New Issue
Block a user