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
+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'] });
},
});
}