feat: Add Pipelines and Users management pages
Frontend Phase 2 - Additional Management Pages: - Implement Pipelines management with Start/Stop/Restart controls - Implement Users management with role assignment and enable/disable - Add routing for Pipelines and Users pages Pipelines Page Features: - DataGrid table with pipeline list - Type filter (RSS Collector, Translator, Image Generator) - Status filter (Running, Stopped, Error) - Pipeline controls (Start, Stop, Restart) - Add/Edit pipeline dialog with JSON config editor - Delete confirmation dialog - Success rate display - Cron schedule management Users Page Features: - DataGrid table with user list - Role filter (Admin, Editor, Viewer) - Status filter (Active, Disabled) - Enable/Disable user toggle - Add/Edit user dialog with role selection - Delete confirmation dialog - Password management for new users Progress: ✅ Keywords Management ✅ Pipelines Management ✅ Users Management ⏳ Applications Management (pending) ⏳ Articles List (pending) ⏳ Monitoring Page (pending) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -3,6 +3,8 @@ import { useAuthStore } from './stores/authStore'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Keywords from './pages/Keywords'
|
||||
import Pipelines from './pages/Pipelines'
|
||||
import Users from './pages/Users'
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
@ -32,6 +34,20 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/pipelines"
|
||||
element={
|
||||
isAuthenticated ? <Pipelines /> : <Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
isAuthenticated ? <Users /> : <Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch all - redirect to dashboard or login */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
459
services/news-engine-console/frontend/src/pages/Pipelines.tsx
Normal file
459
services/news-engine-console/frontend/src/pages/Pipelines.tsx
Normal file
@ -0,0 +1,459 @@
|
||||
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,
|
||||
PlayArrow as StartIcon,
|
||||
Stop as StopIcon,
|
||||
Refresh as RefreshIcon,
|
||||
RestartAlt as RestartIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'
|
||||
import MainLayout from '../components/MainLayout'
|
||||
import {
|
||||
getPipelines,
|
||||
createPipeline,
|
||||
updatePipeline,
|
||||
deletePipeline,
|
||||
startPipeline,
|
||||
stopPipeline,
|
||||
restartPipeline,
|
||||
} from '@/api/pipelines'
|
||||
import type { Pipeline, PipelineCreate, PipelineUpdate } from '@/types'
|
||||
|
||||
const Pipelines = () => {
|
||||
const [pipelines, setPipelines] = useState<Pipeline[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
|
||||
// Dialog states
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create')
|
||||
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
|
||||
// Form states
|
||||
const [formData, setFormData] = useState<PipelineCreate>({
|
||||
name: '',
|
||||
type: 'rss_collector',
|
||||
config: {},
|
||||
schedule: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchPipelines()
|
||||
}, [typeFilter, statusFilter])
|
||||
|
||||
const fetchPipelines = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params: any = {}
|
||||
if (typeFilter !== 'all') params.type = typeFilter
|
||||
if (statusFilter !== 'all') params.status = statusFilter
|
||||
|
||||
const data = await getPipelines(params)
|
||||
setPipelines(data)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch pipelines')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenDialog = (mode: 'create' | 'edit', pipeline?: Pipeline) => {
|
||||
setDialogMode(mode)
|
||||
if (mode === 'edit' && pipeline) {
|
||||
setSelectedPipeline(pipeline)
|
||||
setFormData({
|
||||
name: pipeline.name,
|
||||
type: pipeline.type,
|
||||
config: pipeline.config,
|
||||
schedule: pipeline.schedule || '',
|
||||
})
|
||||
} else {
|
||||
setSelectedPipeline(null)
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'rss_collector',
|
||||
config: {},
|
||||
schedule: '',
|
||||
})
|
||||
}
|
||||
setOpenDialog(true)
|
||||
}
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false)
|
||||
setSelectedPipeline(null)
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'rss_collector',
|
||||
config: {},
|
||||
schedule: '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
if (dialogMode === 'create') {
|
||||
await createPipeline(formData)
|
||||
} else if (selectedPipeline?.id) {
|
||||
await updatePipeline(selectedPipeline.id, formData as PipelineUpdate)
|
||||
}
|
||||
handleCloseDialog()
|
||||
fetchPipelines()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to save pipeline')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedPipeline?.id) return
|
||||
|
||||
try {
|
||||
await deletePipeline(selectedPipeline.id)
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedPipeline(null)
|
||||
fetchPipelines()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete pipeline')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStart = async (pipeline: Pipeline) => {
|
||||
if (!pipeline.id) return
|
||||
try {
|
||||
await startPipeline(pipeline.id)
|
||||
fetchPipelines()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to start pipeline')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async (pipeline: Pipeline) => {
|
||||
if (!pipeline.id) return
|
||||
try {
|
||||
await stopPipeline(pipeline.id)
|
||||
fetchPipelines()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to stop pipeline')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestart = async (pipeline: Pipeline) => {
|
||||
if (!pipeline.id) return
|
||||
try {
|
||||
await restartPipeline(pipeline.id)
|
||||
fetchPipelines()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to restart pipeline')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'Pipeline Name',
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
headerName: 'Type',
|
||||
width: 150,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const colorMap: Record<string, 'primary' | 'secondary' | 'success'> = {
|
||||
rss_collector: 'primary',
|
||||
translator: 'secondary',
|
||||
image_generator: 'success',
|
||||
}
|
||||
return (
|
||||
<Chip
|
||||
label={params.value.replace('_', ' ')}
|
||||
color={colorMap[params.value] || 'default'}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
width: 100,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const colorMap: Record<string, 'success' | 'error' | 'default'> = {
|
||||
running: 'success',
|
||||
stopped: 'default',
|
||||
error: 'error',
|
||||
}
|
||||
return (
|
||||
<Chip
|
||||
label={params.value}
|
||||
color={colorMap[params.value] || 'default'}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'stats',
|
||||
headerName: 'Success Rate',
|
||||
width: 120,
|
||||
valueGetter: (value) => {
|
||||
if (!value) return 'N/A'
|
||||
const total = value.total_runs
|
||||
const successful = value.successful_runs
|
||||
if (total === 0) return '0%'
|
||||
return `${Math.round((successful / total) * 100)}%`
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'last_run',
|
||||
headerName: 'Last Run',
|
||||
width: 180,
|
||||
valueFormatter: (value) => {
|
||||
return value ? new Date(value).toLocaleString() : 'Never'
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: 'Actions',
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const pipeline = params.row as Pipeline
|
||||
return (
|
||||
<Box>
|
||||
{pipeline.status !== 'running' && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleStart(pipeline)}
|
||||
color="success"
|
||||
title="Start"
|
||||
>
|
||||
<StartIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{pipeline.status === 'running' && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleStop(pipeline)}
|
||||
color="error"
|
||||
title="Stop"
|
||||
>
|
||||
<StopIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRestart(pipeline)}
|
||||
color="info"
|
||||
title="Restart"
|
||||
>
|
||||
<RestartIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenDialog('edit', pipeline)}
|
||||
color="primary"
|
||||
title="Edit"
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedPipeline(pipeline)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
color="error"
|
||||
title="Delete"
|
||||
>
|
||||
<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">
|
||||
Pipelines Management
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => handleOpenDialog('create')}
|
||||
>
|
||||
Add Pipeline
|
||||
</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
|
||||
select
|
||||
label="Type"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="all">All Types</MenuItem>
|
||||
<MenuItem value="rss_collector">RSS Collector</MenuItem>
|
||||
<MenuItem value="translator">Translator</MenuItem>
|
||||
<MenuItem value="image_generator">Image Generator</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="running">Running</MenuItem>
|
||||
<MenuItem value="stopped">Stopped</MenuItem>
|
||||
<MenuItem value="error">Error</MenuItem>
|
||||
</TextField>
|
||||
<IconButton onClick={fetchPipelines} color="primary">
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Data Grid */}
|
||||
<Paper sx={{ height: 600, width: '100%' }}>
|
||||
<DataGrid
|
||||
rows={pipelines}
|
||||
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 Pipeline' : 'Edit Pipeline'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
|
||||
<TextField
|
||||
label="Pipeline Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Type"
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
|
||||
fullWidth
|
||||
required
|
||||
>
|
||||
<MenuItem value="rss_collector">RSS Collector</MenuItem>
|
||||
<MenuItem value="translator">Translator</MenuItem>
|
||||
<MenuItem value="image_generator">Image Generator</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Schedule (Cron)"
|
||||
value={formData.schedule}
|
||||
onChange={(e) => setFormData({ ...formData, schedule: e.target.value })}
|
||||
fullWidth
|
||||
placeholder="0 */6 * * *"
|
||||
helperText="Cron format: minute hour day month weekday"
|
||||
/>
|
||||
<TextField
|
||||
label="Configuration (JSON)"
|
||||
value={JSON.stringify(formData.config, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const config = JSON.parse(e.target.value)
|
||||
setFormData({ ...formData, config })
|
||||
} catch (err) {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} variant="contained" disabled={!formData.name}>
|
||||
{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 pipeline "{selectedPipeline?.name}"?
|
||||
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 Pipelines
|
||||
425
services/news-engine-console/frontend/src/pages/Users.tsx
Normal file
425
services/news-engine-console/frontend/src/pages/Users.tsx
Normal file
@ -0,0 +1,425 @@
|
||||
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,
|
||||
Refresh as RefreshIcon,
|
||||
Block as BlockIcon,
|
||||
CheckCircle as EnableIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'
|
||||
import MainLayout from '../components/MainLayout'
|
||||
import {
|
||||
getUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
toggleUserStatus,
|
||||
} from '@/api/users'
|
||||
import type { User, UserCreate, UserUpdate } from '@/types'
|
||||
|
||||
const Users = () => {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
|
||||
// Dialog states
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create')
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
|
||||
// Form states
|
||||
const [formData, setFormData] = useState<UserCreate | UserUpdate>({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'viewer',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [roleFilter, statusFilter])
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params: any = {}
|
||||
if (roleFilter !== 'all') params.role = roleFilter
|
||||
if (statusFilter === 'disabled') params.disabled = true
|
||||
if (statusFilter === 'enabled') params.disabled = false
|
||||
|
||||
const data = await getUsers(params)
|
||||
setUsers(data)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch users')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenDialog = (mode: 'create' | 'edit', user?: User) => {
|
||||
setDialogMode(mode)
|
||||
if (mode === 'edit' && user) {
|
||||
setSelectedUser(user)
|
||||
setFormData({
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
role: user.role,
|
||||
})
|
||||
} else {
|
||||
setSelectedUser(null)
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'viewer',
|
||||
})
|
||||
}
|
||||
setOpenDialog(true)
|
||||
}
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false)
|
||||
setSelectedUser(null)
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'viewer',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
if (dialogMode === 'create') {
|
||||
await createUser(formData as UserCreate)
|
||||
} else if (selectedUser?.id) {
|
||||
await updateUser(selectedUser.id, formData as UserUpdate)
|
||||
}
|
||||
handleCloseDialog()
|
||||
fetchUsers()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to save user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedUser?.id) return
|
||||
|
||||
try {
|
||||
await deleteUser(selectedUser.id)
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedUser(null)
|
||||
fetchUsers()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (user: User) => {
|
||||
if (!user.id) return
|
||||
try {
|
||||
await toggleUserStatus(user.id)
|
||||
fetchUsers()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to toggle user status')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'username',
|
||||
headerName: 'Username',
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'full_name',
|
||||
headerName: 'Full Name',
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
headerName: 'Email',
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'role',
|
||||
headerName: 'Role',
|
||||
width: 100,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const colorMap: Record<string, 'error' | 'warning' | 'success'> = {
|
||||
admin: 'error',
|
||||
editor: 'warning',
|
||||
viewer: 'success',
|
||||
}
|
||||
return (
|
||||
<Chip
|
||||
label={params.value}
|
||||
color={colorMap[params.value] || 'default'}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'disabled',
|
||||
headerName: 'Status',
|
||||
width: 100,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<Chip
|
||||
label={params.value ? 'Disabled' : 'Active'}
|
||||
color={params.value ? 'default' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'created_at',
|
||||
headerName: 'Created At',
|
||||
width: 180,
|
||||
valueFormatter: (value) => {
|
||||
return new Date(value).toLocaleString()
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: 'Actions',
|
||||
width: 180,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const user = params.row as User
|
||||
return (
|
||||
<Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleToggleStatus(user)}
|
||||
color={user.disabled ? 'success' : 'warning'}
|
||||
title={user.disabled ? 'Enable' : 'Disable'}
|
||||
>
|
||||
{user.disabled ? <EnableIcon fontSize="small" /> : <BlockIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenDialog('edit', user)}
|
||||
color="primary"
|
||||
title="Edit"
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedUser(user)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
color="error"
|
||||
title="Delete"
|
||||
>
|
||||
<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">
|
||||
Users Management
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => handleOpenDialog('create')}
|
||||
>
|
||||
Add User
|
||||
</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
|
||||
select
|
||||
label="Role"
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
<MenuItem value="all">All Roles</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="editor">Editor</MenuItem>
|
||||
<MenuItem value="viewer">Viewer</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="enabled">Active</MenuItem>
|
||||
<MenuItem value="disabled">Disabled</MenuItem>
|
||||
</TextField>
|
||||
<IconButton onClick={fetchUsers} color="primary">
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Data Grid */}
|
||||
<Paper sx={{ height: 600, width: '100%' }}>
|
||||
<DataGrid
|
||||
rows={users}
|
||||
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 User' : 'Edit User'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
|
||||
{dialogMode === 'create' && (
|
||||
<TextField
|
||||
label="Username"
|
||||
value={(formData as UserCreate).username || ''}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
label="Full Name"
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
{dialogMode === 'create' && (
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={(formData as UserCreate).password || ''}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
select
|
||||
label="Role"
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
||||
fullWidth
|
||||
required
|
||||
>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="editor">Editor</MenuItem>
|
||||
<MenuItem value="viewer">Viewer</MenuItem>
|
||||
</TextField>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
disabled={
|
||||
!formData.full_name ||
|
||||
!formData.email ||
|
||||
(dialogMode === 'create' && !(formData as UserCreate).username) ||
|
||||
(dialogMode === 'create' && !(formData as UserCreate).password)
|
||||
}
|
||||
>
|
||||
{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 user "{selectedUser?.username}"?
|
||||
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 Users
|
||||
Reference in New Issue
Block a user