Initial commit: PESCO NCR system
Complete Non-Conformance Report system replacing the PowerApps/SharePoint prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend, React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS, group-gated), Microsoft Graph delegated Mail.Send notifications (OBO), six-stage workflow state machine with server-side enforcement, atomic NCR-YYYY-NNNN numbering, attachments with camera capture, immutable field-level audit trail, admin reopen, reports + CSV export, WeasyPrint PDF traveler, Power BI reporting views + read-only DB user, documented VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose deployment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
135
frontend/src/api/client.ts
Normal file
135
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
InteractionRequiredAuthError,
|
||||
PublicClientApplication,
|
||||
} from "@azure/msal-browser";
|
||||
import { config } from "../config";
|
||||
|
||||
export const msalInstance =
|
||||
config.authMode === "entra"
|
||||
? new PublicClientApplication({
|
||||
auth: {
|
||||
clientId: config.clientId,
|
||||
authority: `https://login.microsoftonline.com/${config.tenantId}`,
|
||||
redirectUri: window.location.origin,
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
},
|
||||
cache: { cacheLocation: "sessionStorage" },
|
||||
})
|
||||
: null;
|
||||
|
||||
const DEV_USER_KEY = "pesco-ncr-dev-user";
|
||||
|
||||
export function getDevUser(): string {
|
||||
return localStorage.getItem(DEV_USER_KEY) || "admin@pescoinc.biz";
|
||||
}
|
||||
|
||||
export function setDevUser(email: string): void {
|
||||
localStorage.setItem(DEV_USER_KEY, email);
|
||||
}
|
||||
|
||||
async function authHeaders(): Promise<Record<string, string>> {
|
||||
if (config.authMode === "dev") {
|
||||
return { "X-Dev-User": getDevUser() };
|
||||
}
|
||||
const instance = msalInstance!;
|
||||
const account = instance.getActiveAccount() ?? instance.getAllAccounts()[0];
|
||||
if (!account) {
|
||||
await instance.loginRedirect({ scopes: [config.apiScope] });
|
||||
throw new Error("Redirecting to sign in…");
|
||||
}
|
||||
try {
|
||||
const result = await instance.acquireTokenSilent({
|
||||
scopes: [config.apiScope],
|
||||
account,
|
||||
});
|
||||
return { Authorization: `Bearer ${result.accessToken}` };
|
||||
} catch (err) {
|
||||
if (err instanceof InteractionRequiredAuthError) {
|
||||
await instance.acquireTokenRedirect({ scopes: [config.apiScope], account });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, detail: string) {
|
||||
super(detail);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(resp: Response): Promise<ApiError> {
|
||||
let detail = `Request failed (${resp.status})`;
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (typeof body.detail === "string") detail = body.detail;
|
||||
else if (Array.isArray(body.detail) && body.detail[0]?.msg)
|
||||
detail = body.detail
|
||||
.map((d: { loc?: unknown[]; msg: string }) => d.msg)
|
||||
.join("; ");
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
return new ApiError(resp.status, detail);
|
||||
}
|
||||
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
options: { method?: string; body?: unknown } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = await authHeaders();
|
||||
const init: RequestInit = { method: options.method ?? "GET", headers };
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(options.body);
|
||||
}
|
||||
const resp = await fetch(path, init);
|
||||
if (!resp.ok) throw await parseError(resp);
|
||||
if (resp.status === 204) return undefined as T;
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
export async function apiUpload<T>(path: string, form: FormData): Promise<T> {
|
||||
const headers = await authHeaders();
|
||||
const resp = await fetch(path, { method: "POST", headers, body: form });
|
||||
if (!resp.ok) throw await parseError(resp);
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
export async function apiBlob(path: string): Promise<Blob> {
|
||||
const headers = await authHeaders();
|
||||
const resp = await fetch(path, { headers });
|
||||
if (!resp.ok) throw await parseError(resp);
|
||||
return resp.blob();
|
||||
}
|
||||
|
||||
/** Fetch a protected file and hand it to the browser (download or new tab). */
|
||||
export async function openBlob(
|
||||
path: string,
|
||||
filename: string,
|
||||
mode: "download" | "open",
|
||||
): Promise<void> {
|
||||
const blob = await apiBlob(path);
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (mode === "open") {
|
||||
window.open(url, "_blank");
|
||||
} else {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
export function buildQuery(params: Record<string, unknown>): string {
|
||||
const q = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
q.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const s = q.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
Reference in New Issue
Block a user