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:
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user