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 Login from './pages/Login'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import Keywords from './pages/Keywords'
|
import Keywords from './pages/Keywords'
|
||||||
|
import Pipelines from './pages/Pipelines'
|
||||||
|
import Users from './pages/Users'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isAuthenticated } = useAuthStore()
|
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 */}
|
{/* Catch all - redirect to dashboard or login */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</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