feat(approvals): UI für Freigaben — Review-Queue mit Approve/Reject (Modul 1/16)
Backend: - Phantom-Permission-Bug gefixt: approvals:read/write/approve fehlten in CORE_PERMISSIONS (Rollen konnten sie nie zugewiesen bekommen — gleiche Fehlerklasse wie dashboard:read in M2) Frontend: - api/approvals.ts: TanStack Hooks (list/detail/approve/reject/expire/create) - pages/Approvals.tsx: Review-Queue — Status-Tabs (Offen/Alle/Genehmigt/ Abgelehnt/Abgelaufen), Karten mit Aktion/Entity/Requester/Metadata, Approve/Reject mit Kommentar-Modal, Permission-Gating (approvals:approve) - Route /approvals (PermissionRoute approvals:read), Sidebar-Eintrag - i18n approvals.* + nav.approvals (de/en) Verifikation: Vitest 10/10 (Rendering, Tabs, Approve/Reject-Flow, Kommentar, Permission-Gating, Resolved-Zustände), RBAC-Regression 102/102, tsc clean, Build OK
This commit is contained in:
@@ -64,6 +64,9 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "workspaces:delete", "label": "Workspaces: Delete", "category": "core", "module": "workspaces"},
|
||||
{"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "category": "core", "module": "workspaces"},
|
||||
{"key": "workspaces:configure_modules", "label": "Workspaces: Configure Modules", "category": "core", "module": "workspaces"},
|
||||
{"key": "approvals:read", "label": "Approvals: Read", "category": "core", "module": "approvals"},
|
||||
{"key": "approvals:write", "label": "Approvals: Write", "category": "core", "module": "approvals"},
|
||||
{"key": "approvals:approve", "label": "Approvals: Approve/Reject", "category": "core", "module": "approvals"},
|
||||
{"key": "dashboard:read", "label": "Dashboard: Read", "category": "core", "module": "dashboard"},
|
||||
{"key": "dashboard:write", "label": "Dashboard: Write", "category": "core", "module": "dashboard"},
|
||||
{"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"},
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Approvals page tests — review queue UI for approval requests.
|
||||
*
|
||||
* Covers: rendering, status tabs, approve/reject flow with comment modal,
|
||||
* permission gating (approvals:approve decides button visibility).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ApprovalsPage } from '@/pages/Approvals';
|
||||
import type { ApprovalRequest } from '@/api/approvals';
|
||||
|
||||
const approveMut = vi.fn().mockResolvedValue({});
|
||||
const rejectMut = vi.fn().mockResolvedValue({});
|
||||
|
||||
const makeApproval = (overrides: Partial<ApprovalRequest> = {}): ApprovalRequest => ({
|
||||
id: 'ap-1',
|
||||
tenant_id: 't-1',
|
||||
entity_type: 'contact',
|
||||
entity_id: '11111111-1111-1111-1111-111111111111',
|
||||
action: 'Kunde löschen',
|
||||
requested_by: '22222222-2222-2222-2222-222222222222',
|
||||
requested_by_type: 'agent',
|
||||
approver_id: null,
|
||||
approver_group: null,
|
||||
status: 'pending',
|
||||
comment: null,
|
||||
created_at: '2026-09-01T10:00:00Z',
|
||||
resolved_at: null,
|
||||
expires_at: null,
|
||||
metadata: { reason: 'cleanup' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let mockItems: ApprovalRequest[] = [];
|
||||
let mockCanApprove = true;
|
||||
|
||||
vi.mock('@/api/approvals', () => ({
|
||||
useApprovals: () => ({
|
||||
data: { items: mockItems, total: mockItems.length },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useApproveApproval: () => ({
|
||||
mutate: approveMut,
|
||||
isPending: false,
|
||||
}),
|
||||
useRejectApproval: () => ({
|
||||
mutate: rejectMut,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/usePermission', () => ({
|
||||
usePermission: () => ({
|
||||
hasPermission: (perm: string) => mockCanApprove || perm !== 'approvals:approve',
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ApprovalsPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockItems = [];
|
||||
mockCanApprove = true;
|
||||
});
|
||||
|
||||
describe('ApprovalsPage', () => {
|
||||
it('renders the page with title and status tabs', () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId('approvals-page')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('approvals-tab-pending')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('approvals-tab-all')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('approvals-tab-approved')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('approvals-tab-rejected')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('approvals-tab-expired')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no approvals exist', () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId('approvals-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders approval cards with action, entity and requester', () => {
|
||||
mockItems = [makeApproval()];
|
||||
renderPage();
|
||||
expect(screen.getByText('Kunde löschen')).toBeInTheDocument();
|
||||
expect(screen.getByText(/contact/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('approval-card-ap-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows approve/reject buttons for pending requests when user can decide', () => {
|
||||
mockItems = [makeApproval()];
|
||||
renderPage();
|
||||
expect(screen.getByTestId('approve-btn-ap-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('reject-btn-ap-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides approve/reject buttons without approvals:approve permission', () => {
|
||||
mockItems = [makeApproval()];
|
||||
mockCanApprove = false;
|
||||
renderPage();
|
||||
expect(screen.queryByTestId('approve-btn-ap-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('reject-btn-ap-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens comment modal on approve and submits the decision', async () => {
|
||||
mockItems = [makeApproval()];
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByTestId('approve-btn-ap-1'));
|
||||
|
||||
const textarea = await screen.findByTestId('approval-comment-input');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'Sieht gut aus' } });
|
||||
fireEvent.click(screen.getByTestId('approval-comment-confirm'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(approveMut).toHaveBeenCalledWith({
|
||||
requestId: 'ap-1',
|
||||
payload: { comment: 'Sieht gut aus' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens comment modal on reject and submits the decision', async () => {
|
||||
mockItems = [makeApproval()];
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByTestId('reject-btn-ap-1'));
|
||||
|
||||
const textarea = await screen.findByTestId('approval-comment-input');
|
||||
fireEvent.change(textarea, { target: { value: 'Zu riskant' } });
|
||||
fireEvent.click(screen.getByTestId('approval-comment-confirm'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(rejectMut).toHaveBeenCalledWith({
|
||||
requestId: 'ap-1',
|
||||
payload: { comment: 'Zu riskant' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits without comment when textarea is empty', async () => {
|
||||
mockItems = [makeApproval()];
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByTestId('approve-btn-ap-1'));
|
||||
fireEvent.click(screen.getByTestId('approval-comment-confirm'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(approveMut).toHaveBeenCalledWith({
|
||||
requestId: 'ap-1',
|
||||
payload: { comment: undefined },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show action buttons for resolved requests', () => {
|
||||
mockItems = [makeApproval({ status: 'approved', resolved_at: '2026-09-02T10:00:00Z' })];
|
||||
renderPage();
|
||||
expect(screen.queryByTestId('approve-btn-ap-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('reject-btn-ap-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows metadata entries on the card', () => {
|
||||
mockItems = [makeApproval({ metadata: { reason: 'Datenbereinigung', priority: 'hoch' } })];
|
||||
renderPage();
|
||||
expect(screen.getByText(/Datenbereinigung/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/hoch/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Approvals API client — approval requests from workflows, agents and
|
||||
* users that need human review (approve / reject with comment).
|
||||
*
|
||||
* Backend: /api/v1/approvals (create, list, get, approve, reject, expire).
|
||||
* Lifecycle: pending → approved | rejected | expired.
|
||||
* Permissions: approvals:read / approvals:write / approvals:approve.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost } from '@/api/client';
|
||||
|
||||
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired';
|
||||
export type RequesterType = 'user' | 'agent' | 'system';
|
||||
|
||||
export interface ApprovalRequest {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
action: string;
|
||||
requested_by: string;
|
||||
requested_by_type: RequesterType;
|
||||
approver_id: string | null;
|
||||
approver_group: string | null;
|
||||
status: ApprovalStatus;
|
||||
comment: string | null;
|
||||
created_at: string | null;
|
||||
resolved_at: string | null;
|
||||
expires_at: string | null;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ApprovalListResponse {
|
||||
items: ApprovalRequest[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ApprovalListParams {
|
||||
status?: ApprovalStatus;
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
requested_by?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ApprovalCreatePayload {
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
action: string;
|
||||
requested_by?: string;
|
||||
requested_by_type?: RequesterType;
|
||||
approver_id?: string;
|
||||
approver_group?: string;
|
||||
expires_at?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ApprovalResolvePayload {
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// ─── Query hooks ─────────────────────────────────────────────
|
||||
|
||||
export function useApprovals(params: ApprovalListParams = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.status) searchParams.set('status', params.status);
|
||||
if (params.entity_type) searchParams.set('entity_type', params.entity_type);
|
||||
if (params.entity_id) searchParams.set('entity_id', params.entity_id);
|
||||
if (params.requested_by) searchParams.set('requested_by', params.requested_by);
|
||||
if (params.limit) searchParams.set('limit', String(params.limit));
|
||||
if (params.offset) searchParams.set('offset', String(params.offset));
|
||||
|
||||
const qs = searchParams.toString();
|
||||
return useQuery<ApprovalListResponse>({
|
||||
queryKey: ['approvals', params.status, params.entity_type, params.entity_id, params.requested_by, params.limit, params.offset],
|
||||
queryFn: () => apiGet<ApprovalListResponse>(`/approvals${qs ? `?${qs}` : ''}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useApproval(requestId: string | null) {
|
||||
return useQuery<ApprovalRequest>({
|
||||
queryKey: ['approvals', requestId],
|
||||
queryFn: () => apiGet<ApprovalRequest>(`/approvals/${requestId}`),
|
||||
enabled: !!requestId,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Mutation hooks ──────────────────────────────────────────
|
||||
|
||||
export function useApproveApproval() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<ApprovalRequest, Error, { requestId: string; payload?: ApprovalResolvePayload }>({
|
||||
mutationFn: ({ requestId, payload }) =>
|
||||
apiPost<ApprovalRequest>(`/approvals/${requestId}/approve`, payload || {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['approvals'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRejectApproval() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<ApprovalRequest, Error, { requestId: string; payload?: ApprovalResolvePayload }>({
|
||||
mutationFn: ({ requestId, payload }) =>
|
||||
apiPost<ApprovalRequest>(`/approvals/${requestId}/reject`, payload || {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['approvals'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExpireApproval() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<ApprovalRequest, Error, string>({
|
||||
mutationFn: (requestId: string) =>
|
||||
apiPost<ApprovalRequest>(`/approvals/${requestId}/expire`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['approvals'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateApproval() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<ApprovalRequest, Error, ApprovalCreatePayload>({
|
||||
mutationFn: (data) => apiPost<ApprovalRequest>('/approvals', data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['approvals'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import clsx from 'clsx';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { ChevronRight, FileText, Home, Settings, Users, ArrowLeft } from 'lucide-react';
|
||||
import { ChevronRight, FileText, Home, Settings, Users, ArrowLeft , CheckCircle2 } from 'lucide-react';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
// Curated icon map — avoids `import * as LucideIcons` which loads ALL icons and causes OOM in tests
|
||||
import {
|
||||
@@ -55,6 +55,7 @@ function getIcon(name: string): React.ReactNode {
|
||||
const singleItems: NavSingleItem[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 0 },
|
||||
{ to: '/system-dashboard', labelKey: 'nav.systemDashboard', icon: <Activity className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 90 },
|
||||
{ to: '/approvals', labelKey: 'nav.approvals', icon: <CheckCircle2 className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 91 },
|
||||
];
|
||||
|
||||
const bottomItems: NavSingleItem[] = [];
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"reports": "Reports",
|
||||
"tasks": "Aufgaben",
|
||||
"wiki": "Wiki",
|
||||
"systemDashboard": "System Dashboard"
|
||||
"systemDashboard": "System Dashboard",
|
||||
"approvals": "Freigaben"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -1604,5 +1605,31 @@
|
||||
"noValues": "Keine Werte verfügbar",
|
||||
"loadError": "Werte konnten nicht geladen werden"
|
||||
}
|
||||
},
|
||||
"approvals": {
|
||||
"title": "Freigaben",
|
||||
"statusPending": "Offen",
|
||||
"statusAll": "Alle",
|
||||
"statusApproved": "Genehmigt",
|
||||
"statusRejected": "Abgelehnt",
|
||||
"statusExpired": "Abgelaufen",
|
||||
"status_Pending": "Offen",
|
||||
"status_Approved": "Genehmigt",
|
||||
"status_Rejected": "Abgelehnt",
|
||||
"status_Expired": "Abgelaufen",
|
||||
"approve": "Genehmigen",
|
||||
"reject": "Ablehnen",
|
||||
"approveWithTitle": "Anfrage genehmigen",
|
||||
"rejectWithTitle": "Anfrage ablehnen",
|
||||
"commentOptional": "Kommentar (optional)",
|
||||
"comment": "Kommentar",
|
||||
"entity": "Entität",
|
||||
"created": "Erstellt",
|
||||
"resolved": "Entschieden",
|
||||
"expires": "Läuft ab",
|
||||
"empty": "Keine Freigaben in dieser Ansicht.",
|
||||
"requesterUser": "Benutzer",
|
||||
"requesterAgent": "Agent",
|
||||
"requesterSystem": "System"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"reports": "Reports",
|
||||
"tasks": "Tasks",
|
||||
"wiki": "Wiki",
|
||||
"systemDashboard": "System Dashboard"
|
||||
"systemDashboard": "System Dashboard",
|
||||
"approvals": "Approvals"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign In",
|
||||
@@ -1604,5 +1605,31 @@
|
||||
"noValues": "No values available",
|
||||
"loadError": "Failed to load values"
|
||||
}
|
||||
},
|
||||
"approvals": {
|
||||
"title": "Approvals",
|
||||
"statusPending": "Pending",
|
||||
"statusAll": "All",
|
||||
"statusApproved": "Approved",
|
||||
"statusRejected": "Rejected",
|
||||
"statusExpired": "Expired",
|
||||
"status_Pending": "Pending",
|
||||
"status_Approved": "Approved",
|
||||
"status_Rejected": "Rejected",
|
||||
"status_Expired": "Expired",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"approveWithTitle": "Approve request",
|
||||
"rejectWithTitle": "Reject request",
|
||||
"commentOptional": "Comment (optional)",
|
||||
"comment": "Comment",
|
||||
"entity": "Entity",
|
||||
"created": "Created",
|
||||
"resolved": "Resolved",
|
||||
"expires": "Expires",
|
||||
"empty": "No approvals in this view.",
|
||||
"requesterUser": "User",
|
||||
"requesterAgent": "Agent",
|
||||
"requesterSystem": "System"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Approvals page — human review queue for approval requests.
|
||||
*
|
||||
* Producers: workflows, AI agents, users. This page is where humans
|
||||
* approve or reject pending requests (with optional comment).
|
||||
*
|
||||
* Features:
|
||||
* - Status tabs: pending / all / approved / rejected / expired
|
||||
* - Request cards: action, entity, requester, timestamps, metadata
|
||||
* - Approve / reject with optional comment (modal)
|
||||
* - Permission-aware action buttons (approvals:approve)
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Inbox,
|
||||
RefreshCw,
|
||||
User,
|
||||
Bot,
|
||||
Server,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useApprovals,
|
||||
useApproveApproval,
|
||||
useRejectApproval,
|
||||
type ApprovalRequest,
|
||||
type ApprovalStatus,
|
||||
} from '@/api/approvals';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
const STATUS_TABS: { key: ApprovalStatus | 'all'; labelKey: string }[] = [
|
||||
{ key: 'pending', labelKey: 'approvals.statusPending' },
|
||||
{ key: 'all', labelKey: 'approvals.statusAll' },
|
||||
{ key: 'approved', labelKey: 'approvals.statusApproved' },
|
||||
{ key: 'rejected', labelKey: 'approvals.statusRejected' },
|
||||
{ key: 'expired', labelKey: 'approvals.statusExpired' },
|
||||
];
|
||||
|
||||
const STATUS_BADGE: Record<ApprovalStatus, string> = {
|
||||
pending: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-300',
|
||||
approved: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-300',
|
||||
rejected: 'bg-danger-100 text-danger-800 dark:bg-danger-900/30 dark:text-danger-300',
|
||||
expired: 'bg-secondary-200 text-secondary-700 dark:bg-secondary-800 dark:text-secondary-300',
|
||||
};
|
||||
|
||||
function RequesterIcon({ type }: { type: ApprovalRequest['requested_by_type'] }) {
|
||||
if (type === 'agent') return <Bot className="w-3.5 h-3.5" aria-hidden="true" />;
|
||||
if (type === 'system') return <Server className="w-3.5 h-3.5" aria-hidden="true" />;
|
||||
return <User className="w-3.5 h-3.5" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function ApprovalCard({
|
||||
req,
|
||||
onApprove,
|
||||
onReject,
|
||||
canDecide,
|
||||
isDeciding,
|
||||
}: {
|
||||
req: ApprovalRequest;
|
||||
onApprove: (comment: string | null) => void;
|
||||
onReject: (comment: string | null) => void;
|
||||
canDecide: boolean;
|
||||
isDeciding: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metaEntries = Object.entries(req.metadata || {}).slice(0, 4);
|
||||
|
||||
return (
|
||||
<Card className="p-4 space-y-3" data-testid={`approval-card-${req.id}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
STATUS_BADGE[req.status],
|
||||
)}
|
||||
data-testid={`approval-status-${req.id}`}
|
||||
>
|
||||
{t(`approvals.status_${req.status === 'pending' ? 'Pending' : req.status === 'approved' ? 'Approved' : req.status === 'rejected' ? 'Rejected' : 'Expired'}`)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-secondary-500">
|
||||
<RequesterIcon type={req.requested_by_type} />
|
||||
{t(`approvals.requester${req.requested_by_type === 'user' ? 'User' : req.requested_by_type === 'agent' ? 'Agent' : 'System'}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-medium text-secondary-900 dark:text-secondary-100 break-words">
|
||||
{req.action}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
{t('approvals.entity')}: <span className="font-mono">{req.entity_type}</span>
|
||||
{' · '}
|
||||
<span className="font-mono" title={req.entity_id}>{req.entity_id.slice(0, 8)}…</span>
|
||||
</p>
|
||||
</div>
|
||||
{req.status === 'pending' && canDecide && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onApprove(null)}
|
||||
disabled={isDeciding}
|
||||
data-testid={`approve-btn-${req.id}`}
|
||||
aria-label={t('approvals.approve')}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" aria-hidden="true" />
|
||||
{t('approvals.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onReject(null)}
|
||||
disabled={isDeciding}
|
||||
data-testid={`reject-btn-${req.id}`}
|
||||
aria-label={t('approvals.reject')}
|
||||
>
|
||||
<XCircle className="w-4 h-4" aria-hidden="true" />
|
||||
{t('approvals.reject')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{metaEntries.length > 0 && (
|
||||
<dl className="text-xs text-secondary-600 dark:text-secondary-400 grid grid-cols-2 gap-x-3 gap-y-1 border-t border-secondary-200 dark:border-secondary-700 pt-2">
|
||||
{metaEntries.map(([k, v]) => (
|
||||
<div key={k} className="truncate">
|
||||
<dt className="inline font-medium">{k}:</dt>{' '}
|
||||
<dd className="inline break-all">{typeof v === 'object' ? JSON.stringify(v) : String(v)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-secondary-400 border-t border-secondary-200 dark:border-secondary-700 pt-2">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" aria-hidden="true" />
|
||||
{t('approvals.created')}: {formatDateTime(req.created_at)}
|
||||
</span>
|
||||
{req.resolved_at && (
|
||||
<span>
|
||||
{t('approvals.resolved')}: {formatDateTime(req.resolved_at)}
|
||||
</span>
|
||||
)}
|
||||
{req.expires_at && (
|
||||
<span>
|
||||
{t('approvals.expires')}: {formatDateTime(req.expires_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{req.comment && (
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-300 bg-secondary-50 dark:bg-secondary-800/50 rounded-md px-3 py-2">
|
||||
<span className="font-medium">{t('approvals.comment')}:</span> {req.comment}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { hasPermission } = usePermission();
|
||||
const canDecide = hasPermission('approvals:approve');
|
||||
|
||||
const [tab, setTab] = useState<ApprovalStatus | 'all'>('pending');
|
||||
const [commentTarget, setCommentTarget] = useState<
|
||||
{ id: string; decision: 'approve' | 'reject' } | null
|
||||
>(null);
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const params = tab === 'all' ? {} : { status: tab };
|
||||
const { data, isLoading, isError, refetch, isFetching } = useApprovals(params);
|
||||
const approveMut = useApproveApproval();
|
||||
const rejectMut = useRejectApproval();
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const isDeciding = approveMut.isPending || rejectMut.isPending;
|
||||
|
||||
const handleDecide = () => {
|
||||
if (!commentTarget) return;
|
||||
const payload = { comment: comment.trim() || undefined };
|
||||
if (commentTarget.decision === 'approve') {
|
||||
approveMut.mutate({ requestId: commentTarget.id, payload });
|
||||
} else {
|
||||
rejectMut.mutate({ requestId: commentTarget.id, payload });
|
||||
}
|
||||
setCommentTarget(null);
|
||||
setComment('');
|
||||
};
|
||||
|
||||
const openComment = (id: string, decision: 'approve' | 'reject') => {
|
||||
setComment('');
|
||||
setCommentTarget({ id, decision });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-4xl mx-auto" data-testid="approvals-page">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="text-xl font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
{t('approvals.title')}
|
||||
</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label={t('common.refresh')}
|
||||
data-testid="approvals-refresh"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', isFetching && 'animate-spin')} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status tabs */}
|
||||
<div
|
||||
className="flex items-center gap-1 border-b border-secondary-200 dark:border-secondary-700 overflow-x-auto"
|
||||
role="tablist"
|
||||
aria-label={t('approvals.title')}
|
||||
>
|
||||
{STATUS_TABS.map(({ key, labelKey }) => (
|
||||
<button
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
onClick={() => setTab(key)}
|
||||
className={clsx(
|
||||
'px-3 py-2 text-sm border-b-2 -mb-px transition-colors min-h-touch whitespace-nowrap',
|
||||
tab === key
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400 font-medium'
|
||||
: 'border-transparent text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300',
|
||||
)}
|
||||
data-testid={`approvals-tab-${key}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-secondary-400" aria-hidden="true" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto text-danger-500" aria-hidden="true" />
|
||||
<p className="mt-2 text-sm text-secondary-600">{t('common.loadError')}</p>
|
||||
</Card>
|
||||
) : items.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<Inbox className="w-10 h-10 mx-auto text-secondary-300" aria-hidden="true" />
|
||||
<p className="mt-3 text-sm text-secondary-500" data-testid="approvals-empty">
|
||||
{t('approvals.empty')}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{items.map((req) => (
|
||||
<ApprovalCard
|
||||
key={req.id}
|
||||
req={req}
|
||||
canDecide={canDecide}
|
||||
isDeciding={isDeciding}
|
||||
onApprove={(c) => openComment(req.id, 'approve')}
|
||||
onReject={(c) => openComment(req.id, 'reject')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment modal (optional comment on decision) */}
|
||||
<Modal
|
||||
open={!!commentTarget}
|
||||
onClose={() => { setCommentTarget(null); setComment(''); }}
|
||||
title={
|
||||
commentTarget?.decision === 'approve'
|
||||
? t('approvals.approveWithTitle')
|
||||
: t('approvals.rejectWithTitle')
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
{t('approvals.commentOptional')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-3 py-2 text-sm min-h-[80px]"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
maxLength={2000}
|
||||
data-testid="approval-comment-input"
|
||||
aria-label={t('approvals.commentOptional')}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => { setCommentTarget(null); setComment(''); }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={commentTarget?.decision === 'approve' ? 'primary' : 'danger'}
|
||||
onClick={handleDecide}
|
||||
disabled={isDeciding}
|
||||
data-testid="approval-comment-confirm"
|
||||
>
|
||||
{commentTarget?.decision === 'approve' ? t('approvals.approve') : t('approvals.reject')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ const ImportExportPage = React.lazy(() => import('@/pages/ImportExport').then(m
|
||||
const TagsPage = React.lazy(() => import('@/pages/Tags').then(m => ({ default: m.TagsPage })));
|
||||
const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m => ({ default: m.CustomFieldsPage })));
|
||||
const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage })));
|
||||
const ApprovalsPage = React.lazy(() => import('@/pages/Approvals').then(m => ({ default: m.ApprovalsPage })));
|
||||
const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage })));
|
||||
const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage })));
|
||||
const WorkspaceManagerPage = React.lazy(() => import('@/pages/SettingsWorkspaces').then(m => ({ default: m.WorkspaceManagerPage })));
|
||||
@@ -256,6 +257,7 @@ const router = createBrowserRouter([
|
||||
{ path: '/workflows', element: <PermissionRoute permission="workflows:read">{withSuspense(<WorkflowsPage />)}</PermissionRoute> },
|
||||
{ path: '/import-export', element: <PermissionRoute permission="import_export:read">{withSuspense(<ImportExportPage />)}</PermissionRoute> },
|
||||
{ path: 'tags', element: <PermissionRoute permission="tags:read">{withSuspense(<TagsPage />)}</PermissionRoute> },
|
||||
{ path: '/approvals', element: <PermissionRoute permission="approvals:read">{withSuspense(<ApprovalsPage />)}</PermissionRoute> },
|
||||
{ path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
|
||||
{ path: '/activity', element: <PermissionRoute permission="audit:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
|
||||
{ path: '/wiki', element: <PermissionRoute permission="wiki:read">{withSuspense(<WikiPage />)}</PermissionRoute> },
|
||||
|
||||
Reference in New Issue
Block a user