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:
Agent Zero
2026-09-08 23:26:56 +02:00
parent 4eb05d96ad
commit ecc7a24c1c
8 changed files with 708 additions and 3 deletions
+3
View File
@@ -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();
});
});
+133
View File
@@ -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'] });
},
});
}
+2 -1
View File
@@ -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[] = [];
+28 -1
View File
@@ -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"
}
}
+28 -1
View File
@@ -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"
}
}
+333
View File
@@ -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>
);
}
+2
View File
@@ -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> },