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:
ang3l12
2026-07-13 11:41:22 -06:00
commit dea316b113
111 changed files with 13817 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
import {
Card,
CardContent,
Chip,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TablePagination,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api, buildQuery } from "../../api/client";
import type { AuditEntry } from "../../api/types";
interface GlobalAudit {
items: AuditEntry[];
total: number;
page: number;
page_size: number;
}
export function AdminAuditPage() {
const [page, setPage] = useState(1);
const [ncrNumber, setNcrNumber] = useState("");
const audit = useQuery({
queryKey: ["admin-audit", page, ncrNumber],
queryFn: () =>
api<GlobalAudit>(
`/api/admin/audit${buildQuery({ page, page_size: 50, ncr_number: ncrNumber })}`,
),
placeholderData: (prev) => prev,
});
return (
<Card>
<CardContent>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<TextField
size="small"
label="Filter by NCR number"
value={ncrNumber}
onChange={(e) => {
setNcrNumber(e.target.value);
setPage(1);
}}
/>
<Typography variant="body2" color="text.secondary">
Immutable system-wide audit trail (field-level before/after values).
</Typography>
</Stack>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>When</TableCell>
<TableCell>Who</TableCell>
<TableCell>Action</TableCell>
<TableCell>Field</TableCell>
<TableCell>Before</TableCell>
<TableCell>After</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(audit.data?.items ?? []).map((a) => (
<TableRow key={a.id}>
<TableCell sx={{ whiteSpace: "nowrap" }}>
{new Date(a.created_at).toLocaleString()}
</TableCell>
<TableCell>{a.user.display_name}</TableCell>
<TableCell>
<Chip size="small" label={a.action.replace(/_/g, " ")} />
{a.detail && (
<Typography variant="caption" display="block" color="text.secondary">
{a.detail}
</Typography>
)}
</TableCell>
<TableCell>{a.field_name ?? ""}</TableCell>
<TableCell sx={{ maxWidth: 200, overflowWrap: "anywhere" }}>
{a.old_value ?? ""}
</TableCell>
<TableCell sx={{ maxWidth: 200, overflowWrap: "anywhere" }}>
{a.new_value ?? ""}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TablePagination
component="div"
count={audit.data?.total ?? 0}
page={page - 1}
onPageChange={(_, p) => setPage(p + 1)}
rowsPerPage={50}
rowsPerPageOptions={[50]}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,145 @@
import AddIcon from "@mui/icons-material/Add";
import {
Button,
Card,
CardContent,
CardHeader,
Divider,
Grid,
Stack,
Switch,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "../../api/client";
import type { NamedLookup } from "../../api/types";
import { useToast } from "../../components/Toast";
function LookupManager({
title,
endpoint,
helper,
}: {
title: string;
endpoint: string; // /api/admin/departments or /api/admin/categories
helper: string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [newName, setNewName] = useState("");
const items = useQuery({
queryKey: ["admin-lookup", endpoint],
queryFn: () => api<NamedLookup[]>(endpoint),
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["admin-lookup", endpoint] });
qc.invalidateQueries({ queryKey: ["lookups"] });
};
const create = useMutation({
mutationFn: () => api<NamedLookup>(endpoint, { method: "POST", body: { name: newName } }),
onSuccess: () => {
setNewName("");
invalidate();
toast("Added.");
},
onError: (e) => toast(e.message, "error"),
});
const patch = useMutation({
mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) =>
api<NamedLookup>(`${endpoint}/${id}`, { method: "PATCH", body: { is_active } }),
onSuccess: invalidate,
onError: (e) => toast(e.message, "error"),
});
return (
<Card>
<CardHeader title={title} subheader={helper} />
<Divider />
<CardContent>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<TextField
size="small"
label={`New ${title.toLowerCase().replace(/s$/, "")}`}
value={newName}
onChange={(e) => setNewName(e.target.value)}
fullWidth
/>
<Button
variant="contained"
startIcon={<AddIcon />}
disabled={!newName.trim() || create.isPending}
onClick={() => create.mutate()}
>
Add
</Button>
</Stack>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell align="right">Active</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(items.data ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell
sx={{ color: item.is_active ? "inherit" : "text.disabled" }}
>
{item.name}
</TableCell>
<TableCell align="right">
<Switch
size="small"
checked={item.is_active}
onChange={(e) =>
patch.mutate({ id: item.id, is_active: e.target.checked })
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export function AdminListsPage() {
return (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Values are never deleted deactivating hides them from new NCRs while
existing records keep their history.
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<LookupManager
title="Departments"
endpoint="/api/admin/departments"
helper="Shown in the Department dropdown on new NCRs"
/>
</Grid>
<Grid item xs={12} md={6}>
<LookupManager
title="Deviation Categories"
endpoint="/api/admin/categories"
helper="Shown in the Deviation Category dropdown on new NCRs"
/>
</Grid>
</Grid>
</>
);
}

View File

@@ -0,0 +1,60 @@
import {
Alert,
Box,
Tab,
Tabs,
Typography,
} from "@mui/material";
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { useMe } from "../../api/hooks";
import { AdminAuditPage } from "./AdminAuditPage";
import { AdminListsPage } from "./AdminListsPage";
import { AdminSettingsPage } from "./AdminSettingsPage";
import { AdminUsersPage } from "./AdminUsersPage";
const TABS = [
{ path: "users", label: "Users & Roles" },
{ path: "lists", label: "Departments & Categories" },
{ path: "settings", label: "Settings" },
{ path: "audit", label: "Audit Log" },
];
export function AdminPage() {
const me = useMe();
const navigate = useNavigate();
const location = useLocation();
if (me.data && !me.data.roles.includes("admin")) {
return <Alert severity="warning">The Admin area requires the Admin role.</Alert>;
}
const current = TABS.findIndex((t) =>
location.pathname.includes(`/admin/${t.path}`),
);
return (
<Box>
<Typography variant="h5" gutterBottom>
Administration
</Typography>
<Tabs
value={current === -1 ? 0 : current}
onChange={(_, v) => navigate(`/admin/${TABS[v].path}`)}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 2, borderBottom: 1, borderColor: "divider" }}
>
{TABS.map((t) => (
<Tab key={t.path} label={t.label} />
))}
</Tabs>
<Routes>
<Route path="users" element={<AdminUsersPage />} />
<Route path="lists" element={<AdminListsPage />} />
<Route path="settings" element={<AdminSettingsPage />} />
<Route path="audit" element={<AdminAuditPage />} />
<Route path="*" element={<Navigate to="users" replace />} />
</Routes>
</Box>
);
}

View File

@@ -0,0 +1,66 @@
import {
Card,
CardContent,
FormControlLabel,
Switch,
Typography,
} from "@mui/material";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api/client";
import { useToast } from "../../components/Toast";
interface Settings {
notifications_enabled: boolean;
}
export function AdminSettingsPage() {
const qc = useQueryClient();
const { toast } = useToast();
const settings = useQuery({
queryKey: ["admin-settings"],
queryFn: () => api<Settings>("/api/admin/settings"),
});
const save = useMutation({
mutationFn: (notifications_enabled: boolean) =>
api<Settings>("/api/admin/settings", {
method: "PUT",
body: { notifications_enabled },
}),
onSuccess: (data) => {
qc.setQueryData(["admin-settings"], data);
toast(
data.notifications_enabled
? "Email notifications enabled."
: "Email notifications disabled.",
);
},
onError: (e) => toast(e.message, "error"),
});
return (
<Card sx={{ maxWidth: 640 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Notifications
</Typography>
<FormControlLabel
control={
<Switch
checked={settings.data?.notifications_enabled ?? false}
disabled={settings.isLoading || save.isPending}
onChange={(e) => save.mutate(e.target.checked)}
/>
}
label="Send stage-transition emails (Microsoft Graph, from the acting user's mailbox)"
/>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Turn this off during testing to stop all outgoing email. Workflow
transitions always proceed even if an email fails failures surface
as non-blocking warnings and are written to the API log.
</Typography>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,174 @@
import {
Button,
Card,
CardContent,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Stack,
Switch,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "../../api/client";
import type { Role, UserOut } from "../../api/types";
import { ROLE_LABELS, ROLES } from "../../api/types";
import { useToast } from "../../components/Toast";
function RolesDialog({
user,
onClose,
}: {
user: UserOut;
onClose: () => void;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [roles, setRoles] = useState<Set<Role>>(new Set(user.roles));
const save = useMutation({
mutationFn: () =>
api<UserOut>(`/api/admin/users/${user.id}/roles`, {
method: "PUT",
body: { roles: Array.from(roles) },
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
qc.invalidateQueries({ queryKey: ["users"] });
toast("Roles updated.");
onClose();
},
onError: (e) => toast(e.message, "error"),
});
return (
<Dialog open onClose={onClose} fullWidth maxWidth="xs">
<DialogTitle>Roles {user.display_name}</DialogTitle>
<DialogContent>
<Stack>
{ROLES.map((role) => (
<FormControlLabel
key={role}
control={
<Checkbox
checked={roles.has(role)}
onChange={(e) => {
const next = new Set(roles);
if (e.target.checked) next.add(role);
else next.delete(role);
setRoles(next);
}}
/>
}
label={ROLE_LABELS[role]}
/>
))}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={() => save.mutate()} disabled={save.isPending}>
Save
</Button>
</DialogActions>
</Dialog>
);
}
export function AdminUsersPage() {
const qc = useQueryClient();
const { toast } = useToast();
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<UserOut | null>(null);
const users = useQuery({
queryKey: ["admin-users", search],
queryFn: () =>
api<UserOut[]>(`/api/admin/users${search ? `?search=${encodeURIComponent(search)}` : ""}`),
});
const setActive = useMutation({
mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) =>
api<UserOut>(`/api/admin/users/${id}/active`, {
method: "PUT",
body: { is_active },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
onError: (e) => toast(e.message, "error"),
});
return (
<Card>
<CardContent>
<Stack direction="row" spacing={1} sx={{ mb: 2 }} alignItems="center">
<TextField
size="small"
label="Search users"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Typography variant="body2" color="text.secondary">
Users are auto-provisioned on first sign-in; assign workflow roles here.
</Typography>
</Stack>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Roles</TableCell>
<TableCell>Active</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{(users.data ?? []).map((u) => (
<TableRow key={u.id} hover>
<TableCell>{u.display_name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{u.roles.map((r) => (
<Chip
key={r}
size="small"
label={ROLE_LABELS[r] ?? r}
color={r === "admin" ? "secondary" : "default"}
/>
))}
</Stack>
</TableCell>
<TableCell>
<Switch
size="small"
checked={u.is_active}
onChange={(e) =>
setActive.mutate({ id: u.id, is_active: e.target.checked })
}
/>
</TableCell>
<TableCell>
<Button size="small" onClick={() => setEditing(u)}>
Edit Roles
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
{editing && <RolesDialog user={editing} onClose={() => setEditing(null)} />}
</Card>
);
}