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:
105
frontend/src/pages/admin/AdminAuditPage.tsx
Normal file
105
frontend/src/pages/admin/AdminAuditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
frontend/src/pages/admin/AdminListsPage.tsx
Normal file
145
frontend/src/pages/admin/AdminListsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
frontend/src/pages/admin/AdminPage.tsx
Normal file
60
frontend/src/pages/admin/AdminPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
frontend/src/pages/admin/AdminSettingsPage.tsx
Normal file
66
frontend/src/pages/admin/AdminSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
174
frontend/src/pages/admin/AdminUsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user