confirm actions
This commit is contained in:
@@ -8,7 +8,7 @@ interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isReady: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -48,13 +48,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setUser(result.user);
|
||||
window.localStorage.setItem(tokenKey, result.token);
|
||||
},
|
||||
logout() {
|
||||
async logout() {
|
||||
if (token) {
|
||||
try {
|
||||
await api.logout(token);
|
||||
} catch {
|
||||
// Clearing local auth state still signs the user out on the client.
|
||||
}
|
||||
}
|
||||
window.localStorage.removeItem(tokenKey);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
},
|
||||
}),
|
||||
[isReady, token, user]
|
||||
[token, user, isReady]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
@@ -67,4 +74,3 @@ export function useAuth() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,9 @@ export function AppShell() {
|
||||
<p className="text-xs text-muted">{user?.email}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
|
||||
>
|
||||
Sign out
|
||||
|
||||
107
client/src/components/ConfirmActionDialog.tsx
Normal file
107
client/src/components/ConfirmActionDialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ConfirmActionDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
impact?: string;
|
||||
recovery?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
intent?: "danger" | "primary";
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
isConfirming?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmActionDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
impact,
|
||||
recovery,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
intent = "danger",
|
||||
confirmationLabel,
|
||||
confirmationValue,
|
||||
isConfirming = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ConfirmActionDialogProps) {
|
||||
const [typedValue, setTypedValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTypedValue("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
|
||||
const isConfirmDisabled = isConfirming || (requiresTypedConfirmation && typedValue.trim() !== confirmationValue);
|
||||
const confirmButtonClass =
|
||||
intent === "danger"
|
||||
? "bg-red-600 text-white hover:bg-red-700"
|
||||
: "bg-brand text-white hover:brightness-110";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6">
|
||||
<div className="w-full max-w-xl rounded-[28px] border border-line/70 bg-surface p-5 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Confirm Action</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-text">{description}</p>
|
||||
{impact ? (
|
||||
<div className="mt-4 rounded-2xl border border-red-300/50 bg-red-50 px-3 py-3 text-sm text-red-800">
|
||||
<span className="block text-xs font-semibold uppercase tracking-[0.18em]">Impact</span>
|
||||
<span className="mt-1 block">{impact}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{recovery ? (
|
||||
<div className="mt-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
||||
<span className="block text-xs font-semibold uppercase tracking-[0.18em] text-text">Recovery</span>
|
||||
<span className="mt-1 block">{recovery}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requiresTypedConfirmation ? (
|
||||
<label className="mt-4 block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">
|
||||
{confirmationLabel} <span className="font-mono">{confirmationValue}</span>
|
||||
</span>
|
||||
<input
|
||||
value={typedValue}
|
||||
onChange={(event) => setTypedValue(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isConfirming}
|
||||
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onConfirm();
|
||||
}}
|
||||
disabled={isConfirmDisabled}
|
||||
className={`rounded-2xl px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${confirmButtonClass}`}
|
||||
>
|
||||
{isConfirming ? "Working..." : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../auth/AuthProvider";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import { ConfirmActionDialog } from "./ConfirmActionDialog";
|
||||
|
||||
interface FileAttachmentsPanelProps {
|
||||
ownerType: string;
|
||||
@@ -41,6 +42,7 @@ export function FileAttachmentsPanel({
|
||||
const [status, setStatus] = useState("Loading attachments...");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
|
||||
const [attachmentPendingDelete, setAttachmentPendingDelete] = useState<FileAttachmentDto | null>(null);
|
||||
|
||||
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
|
||||
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
|
||||
@@ -120,12 +122,13 @@ export function FileAttachmentsPanel({
|
||||
onAttachmentCountChange?.(nextAttachments.length);
|
||||
return nextAttachments;
|
||||
});
|
||||
setStatus("Attachment deleted.");
|
||||
setStatus("Attachment deleted. Upload a replacement file if this document is still required for the record.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setDeletingAttachmentId(null);
|
||||
setAttachmentPendingDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +180,7 @@ export function FileAttachmentsPanel({
|
||||
{canWriteFiles ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(attachment)}
|
||||
onClick={() => setAttachmentPendingDelete(attachment)}
|
||||
disabled={deletingAttachmentId === attachment.id}
|
||||
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
|
||||
>
|
||||
@@ -189,6 +192,29 @@ export function FileAttachmentsPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmActionDialog
|
||||
open={attachmentPendingDelete != null}
|
||||
title="Delete attachment"
|
||||
description={
|
||||
attachmentPendingDelete
|
||||
? `Delete ${attachmentPendingDelete.originalName} from this record.`
|
||||
: "Delete this attachment."
|
||||
}
|
||||
impact="The file link will be removed from this record immediately."
|
||||
recovery="Re-upload the document if it was removed by mistake. Historical downloads are not retained in the UI."
|
||||
confirmLabel="Delete file"
|
||||
isConfirming={attachmentPendingDelete != null && deletingAttachmentId === attachmentPendingDelete.id}
|
||||
onClose={() => {
|
||||
if (!deletingAttachmentId) {
|
||||
setAttachmentPendingDelete(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (attachmentPendingDelete) {
|
||||
await handleDelete(attachmentPendingDelete);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AdminDiagnosticsDto,
|
||||
AdminAuthSessionDto,
|
||||
BackupGuidanceDto,
|
||||
AdminPermissionOptionDto,
|
||||
AdminRoleDto,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
PlanningTimelineDto,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
} from "@mrp/shared";
|
||||
import type {
|
||||
CrmContactDto,
|
||||
@@ -138,6 +140,9 @@ export const api = {
|
||||
me(token: string) {
|
||||
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
|
||||
},
|
||||
logout(token: string) {
|
||||
return request<LogoutResponse>("/api/v1/auth/logout", { method: "POST" }, token);
|
||||
},
|
||||
getAdminDiagnostics(token: string) {
|
||||
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
|
||||
},
|
||||
@@ -165,6 +170,12 @@ export const api = {
|
||||
getAdminUsers(token: string) {
|
||||
return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token);
|
||||
},
|
||||
getAdminSessions(token: string) {
|
||||
return request<AdminAuthSessionDto[]>("/api/v1/admin/sessions", undefined, token);
|
||||
},
|
||||
revokeAdminSession(token: string, sessionId: string) {
|
||||
return request<LogoutResponse>(`/api/v1/admin/sessions/${sessionId}/revoke`, { method: "POST" }, token);
|
||||
},
|
||||
createAdminUser(token: string, payload: AdminUserInput) {
|
||||
return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config";
|
||||
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
|
||||
import { InventoryStatusBadge } from "./InventoryStatusBadge";
|
||||
@@ -48,6 +49,19 @@ export function InventoryDetailPage() {
|
||||
const [isSavingTransfer, setIsSavingTransfer] = useState(false);
|
||||
const [isSavingReservation, setIsSavingReservation] = useState(false);
|
||||
const [status, setStatus] = useState("Loading inventory item...");
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
kind: "transaction" | "transfer" | "reservation";
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
recovery: string;
|
||||
confirmLabel: string;
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||
|
||||
@@ -100,8 +114,7 @@ export function InventoryDetailPage() {
|
||||
setTransferForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function submitTransaction() {
|
||||
if (!token || !itemId) {
|
||||
return;
|
||||
}
|
||||
@@ -112,7 +125,7 @@ export function InventoryDetailPage() {
|
||||
try {
|
||||
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
|
||||
setItem(nextItem);
|
||||
setTransactionStatus("Stock transaction recorded.");
|
||||
setTransactionStatus("Stock transaction recorded. If this was posted in error, create an offsetting stock entry and verify the result in Recent Movements.");
|
||||
setTransactionForm((current) => ({
|
||||
...emptyInventoryTransactionInput,
|
||||
transactionType: current.transactionType,
|
||||
@@ -127,8 +140,7 @@ export function InventoryDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function submitTransfer() {
|
||||
if (!token || !itemId) {
|
||||
return;
|
||||
}
|
||||
@@ -139,7 +151,7 @@ export function InventoryDetailPage() {
|
||||
try {
|
||||
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
|
||||
setItem(nextItem);
|
||||
setTransferStatus("Transfer recorded.");
|
||||
setTransferStatus("Transfer recorded. Review stock balances on both locations and post a return transfer if this movement was entered incorrectly.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
|
||||
setTransferStatus(message);
|
||||
@@ -148,8 +160,7 @@ export function InventoryDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function submitReservation() {
|
||||
if (!token || !itemId) {
|
||||
return;
|
||||
}
|
||||
@@ -160,7 +171,7 @@ export function InventoryDetailPage() {
|
||||
try {
|
||||
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
|
||||
setItem(nextItem);
|
||||
setReservationStatus("Reservation recorded.");
|
||||
setReservationStatus("Reservation recorded. Verify available stock and add a compensating reservation change if this demand hold was entered incorrectly.");
|
||||
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
|
||||
@@ -170,6 +181,64 @@ export function InventoryDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transactionLabel = inventoryTransactionOptions.find((option) => option.value === transactionForm.transactionType)?.label ?? "transaction";
|
||||
setPendingConfirmation({
|
||||
kind: "transaction",
|
||||
title: `Post ${transactionLabel.toLowerCase()}`,
|
||||
description: `Post a ${transactionLabel.toLowerCase()} of ${transactionForm.quantity} units for ${item.sku} at the selected stock location.`,
|
||||
impact:
|
||||
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||
? "This reduces available inventory immediately and affects downstream shortage and readiness calculations."
|
||||
: "This updates the stock ledger immediately and becomes part of the item transaction history.",
|
||||
recovery: "If this is incorrect, post an explicit offsetting transaction instead of editing history.",
|
||||
confirmLabel: `Post ${transactionLabel.toLowerCase()}`,
|
||||
confirmationLabel:
|
||||
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||
? "Type item SKU to confirm:"
|
||||
: undefined,
|
||||
confirmationValue:
|
||||
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||
? item.sku
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
setPendingConfirmation({
|
||||
kind: "transfer",
|
||||
title: "Post inventory transfer",
|
||||
description: `Move ${transferForm.quantity} units of ${item.sku} between the selected source and destination locations.`,
|
||||
impact: "This creates paired stock movement entries and changes both source and destination availability immediately.",
|
||||
recovery: "If the move was entered incorrectly, post a reversing transfer back to the original location.",
|
||||
confirmLabel: "Post transfer",
|
||||
});
|
||||
}
|
||||
|
||||
function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
setPendingConfirmation({
|
||||
kind: "reservation",
|
||||
title: "Create manual reservation",
|
||||
description: `Reserve ${reservationForm.quantity} units of ${item.sku}${reservationForm.locationId ? " at the selected location" : ""}.`,
|
||||
impact: "This reduces available quantity used by planning, purchasing, manufacturing, and readiness views.",
|
||||
recovery: "Add the correcting reservation entry if this hold should be reduced or removed.",
|
||||
confirmLabel: "Create reservation",
|
||||
});
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
@@ -530,6 +599,41 @@ export function InventoryDetailPage() {
|
||||
</section>
|
||||
|
||||
<InventoryAttachmentsPanel itemId={item.id} />
|
||||
<ConfirmActionDialog
|
||||
open={pendingConfirmation != null}
|
||||
title={pendingConfirmation?.title ?? "Confirm inventory action"}
|
||||
description={pendingConfirmation?.description ?? ""}
|
||||
impact={pendingConfirmation?.impact}
|
||||
recovery={pendingConfirmation?.recovery}
|
||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||
isConfirming={
|
||||
(pendingConfirmation?.kind === "transaction" && isSavingTransaction) ||
|
||||
(pendingConfirmation?.kind === "transfer" && isSavingTransfer) ||
|
||||
(pendingConfirmation?.kind === "reservation" && isSavingReservation)
|
||||
}
|
||||
onClose={() => {
|
||||
if (!isSavingTransaction && !isSavingTransfer && !isSavingReservation) {
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "transaction") {
|
||||
await submitTransaction();
|
||||
} else if (pendingConfirmation.kind === "transfer") {
|
||||
await submitTransfer();
|
||||
} else {
|
||||
await submitReservation();
|
||||
}
|
||||
|
||||
setPendingConfirmation(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Link, useParams } from "react-router-dom";
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { emptyCompletionInput, emptyMaterialIssueInput, workOrderStatusOptions } from "./config";
|
||||
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
|
||||
|
||||
@@ -21,6 +22,20 @@ export function WorkOrderDetailPage() {
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
||||
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
kind: "status" | "issue" | "completion";
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
recovery: string;
|
||||
confirmLabel: string;
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
nextStatus?: WorkOrderStatus;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
||||
|
||||
@@ -56,7 +71,7 @@ export function WorkOrderDetailPage() {
|
||||
[issueForm.warehouseId, locationOptions]
|
||||
);
|
||||
|
||||
async function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||
async function applyStatusChange(nextStatus: WorkOrderStatus) {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
@@ -66,7 +81,7 @@ export function WorkOrderDetailPage() {
|
||||
try {
|
||||
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, nextStatus);
|
||||
setWorkOrder(nextWorkOrder);
|
||||
setStatus("Work-order status updated.");
|
||||
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
|
||||
setStatus(message);
|
||||
@@ -75,8 +90,7 @@ export function WorkOrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function submitIssue() {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
@@ -91,7 +105,7 @@ export function WorkOrderDetailPage() {
|
||||
warehouseId: nextWorkOrder.warehouseId,
|
||||
locationId: nextWorkOrder.locationId,
|
||||
});
|
||||
setStatus("Material issue posted.");
|
||||
setStatus("Material issue posted. This consumed inventory immediately; post a correcting stock movement if the issue quantity was wrong.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to post material issue.";
|
||||
setStatus(message);
|
||||
@@ -100,8 +114,7 @@ export function WorkOrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function submitCompletion() {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
@@ -115,7 +128,7 @@ export function WorkOrderDetailPage() {
|
||||
...emptyCompletionInput,
|
||||
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
|
||||
});
|
||||
setStatus("Completion posted.");
|
||||
setStatus("Completion posted. Finished-goods stock has been received; verify the remaining quantity and post a correcting transaction if this completion was overstated.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to post completion.";
|
||||
setStatus(message);
|
||||
@@ -124,6 +137,64 @@ export function WorkOrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||
if (!workOrder) {
|
||||
return;
|
||||
}
|
||||
const option = workOrderStatusOptions.find((entry) => entry.value === nextStatus);
|
||||
setPendingConfirmation({
|
||||
kind: "status",
|
||||
title: `Change status to ${option?.label ?? nextStatus}`,
|
||||
description: `Update work order ${workOrder.workOrderNumber} from ${workOrder.status} to ${nextStatus}.`,
|
||||
impact:
|
||||
nextStatus === "CANCELLED"
|
||||
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
|
||||
: nextStatus === "COMPLETE"
|
||||
? "Completing the work order signals execution closure and can change readiness views across the system."
|
||||
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
|
||||
recovery: "If this status was selected in error, set the work order back to the correct state immediately after review.",
|
||||
confirmLabel: `Set ${option?.label ?? nextStatus}`,
|
||||
confirmationLabel: nextStatus === "CANCELLED" ? "Type work-order number to confirm:" : undefined,
|
||||
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
|
||||
nextStatus,
|
||||
});
|
||||
}
|
||||
|
||||
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!workOrder) {
|
||||
return;
|
||||
}
|
||||
const component = workOrder.materialRequirements.find((requirement) => requirement.componentItemId === issueForm.componentItemId);
|
||||
setPendingConfirmation({
|
||||
kind: "issue",
|
||||
title: "Post material issue",
|
||||
description: `Issue ${issueForm.quantity} units of ${component?.componentSku ?? "the selected component"} to work order ${workOrder.workOrderNumber}.`,
|
||||
impact: "This consumes component inventory immediately and updates work-order material history.",
|
||||
recovery: "If the wrong quantity was issued, post a correcting stock transaction and note the reason on the work order.",
|
||||
confirmLabel: "Post issue",
|
||||
confirmationLabel: "Type work-order number to confirm:",
|
||||
confirmationValue: workOrder.workOrderNumber,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!workOrder) {
|
||||
return;
|
||||
}
|
||||
setPendingConfirmation({
|
||||
kind: "completion",
|
||||
title: "Post production completion",
|
||||
description: `Receive ${completionForm.quantity} finished units into ${workOrder.warehouseCode} / ${workOrder.locationCode}.`,
|
||||
impact: "This increases finished-goods inventory immediately and advances the execution history for this work order.",
|
||||
recovery: "If the completion quantity is wrong, post the correcting inventory movement and verify the work-order remaining quantity.",
|
||||
confirmLabel: "Post completion",
|
||||
confirmationLabel: completionForm.quantity >= workOrder.dueQuantity ? "Type work-order number to confirm:" : undefined,
|
||||
confirmationValue: completionForm.quantity >= workOrder.dueQuantity ? workOrder.workOrderNumber : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!workOrder) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
@@ -375,6 +446,41 @@ export function WorkOrderDetailPage() {
|
||||
emptyMessage="No manufacturing attachments have been uploaded for this work order yet."
|
||||
/>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||
<ConfirmActionDialog
|
||||
open={pendingConfirmation != null}
|
||||
title={pendingConfirmation?.title ?? "Confirm manufacturing action"}
|
||||
description={pendingConfirmation?.description ?? ""}
|
||||
impact={pendingConfirmation?.impact}
|
||||
recovery={pendingConfirmation?.recovery}
|
||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||
isConfirming={
|
||||
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
||||
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
|
||||
(pendingConfirmation?.kind === "completion" && isPostingCompletion)
|
||||
}
|
||||
onClose={() => {
|
||||
if (!isUpdatingStatus && !isPostingIssue && !isPostingCompletion) {
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
|
||||
await applyStatusChange(pendingConfirmation.nextStatus);
|
||||
} else if (pendingConfirmation.kind === "issue") {
|
||||
await submitIssue();
|
||||
} else if (pendingConfirmation.kind === "completion") {
|
||||
await submitCompletion();
|
||||
}
|
||||
|
||||
setPendingConfirmation(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { AdminPermissionOptionDto, AdminRoleDto, AdminRoleInput, AdminUserDto, AdminUserInput } from "@mrp/shared";
|
||||
import type {
|
||||
AdminAuthSessionDto,
|
||||
AdminPermissionOptionDto,
|
||||
AdminRoleDto,
|
||||
AdminRoleInput,
|
||||
AdminUserDto,
|
||||
AdminUserInput,
|
||||
} from "@mrp/shared";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
|
||||
const emptyUserForm: AdminUserInput = {
|
||||
email: "",
|
||||
@@ -21,15 +29,33 @@ const emptyRoleForm: AdminRoleInput = {
|
||||
};
|
||||
|
||||
export function UserManagementPage() {
|
||||
const { token } = useAuth();
|
||||
const { token, user: authUser, logout } = useAuth();
|
||||
const [users, setUsers] = useState<AdminUserDto[]>([]);
|
||||
const [roles, setRoles] = useState<AdminRoleDto[]>([]);
|
||||
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
|
||||
const [sessions, setSessions] = useState<AdminAuthSessionDto[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>("new");
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
|
||||
const [sessionUserFilter, setSessionUserFilter] = useState<string>("all");
|
||||
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
|
||||
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
|
||||
const [status, setStatus] = useState("Loading admin access controls...");
|
||||
const [isConfirmingAction, setIsConfirmingAction] = useState(false);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
kind: "deactivate-user" | "revoke-session";
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
recovery: string;
|
||||
confirmLabel: string;
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -38,14 +64,15 @@ export function UserManagementPage() {
|
||||
|
||||
let active = true;
|
||||
|
||||
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token)])
|
||||
.then(([nextUsers, nextRoles, nextPermissions]) => {
|
||||
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token), api.getAdminSessions(token)])
|
||||
.then(([nextUsers, nextRoles, nextPermissions, nextSessions]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setUsers(nextUsers);
|
||||
setRoles(nextRoles);
|
||||
setPermissions(nextPermissions);
|
||||
setSessions(nextSessions);
|
||||
setStatus("User management loaded.");
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
@@ -108,19 +135,41 @@ export function UserManagementPage() {
|
||||
const authToken = token;
|
||||
|
||||
async function refreshData(nextStatus: string) {
|
||||
const [nextUsers, nextRoles, nextPermissions] = await Promise.all([
|
||||
const [nextUsers, nextRoles, nextPermissions, nextSessions] = await Promise.all([
|
||||
api.getAdminUsers(authToken),
|
||||
api.getAdminRoles(authToken),
|
||||
api.getAdminPermissions(authToken),
|
||||
api.getAdminSessions(authToken),
|
||||
]);
|
||||
setUsers(nextUsers);
|
||||
setRoles(nextRoles);
|
||||
setPermissions(nextPermissions);
|
||||
setSessions(nextSessions);
|
||||
setStatus(nextStatus);
|
||||
}
|
||||
|
||||
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const selectedUser = users.find((entry) => entry.id === selectedUserId);
|
||||
if (selectedUser && selectedUser.isActive && !userForm.isActive) {
|
||||
setPendingConfirmation({
|
||||
kind: "deactivate-user",
|
||||
title: `Deactivate ${selectedUser.firstName} ${selectedUser.lastName}`,
|
||||
description: `Disable sign-in for ${selectedUser.email}. Existing active sessions will remain revoked only if you separately revoke them below.`,
|
||||
impact: "The user will be blocked from new sign-ins as soon as this save completes.",
|
||||
recovery: "Re-enable the account later if the change was made in error, and revoke live sessions separately if immediate cut-off is required.",
|
||||
confirmLabel: "Deactivate user",
|
||||
confirmationLabel: "Type user email to confirm:",
|
||||
confirmationValue: selectedUser.email,
|
||||
userId: selectedUser.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await saveUser();
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
if (selectedUserId === "new") {
|
||||
const createdUser = await api.createAdminUser(authToken, userForm);
|
||||
await refreshData(`Created user ${createdUser.email}.`);
|
||||
@@ -165,6 +214,21 @@ export function UserManagementPage() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSessionRevoke(sessionId: string, isCurrentSession: boolean) {
|
||||
await api.revokeAdminSession(authToken, sessionId);
|
||||
if (isCurrentSession) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshData("Revoked session. The user must sign in again to restore access unless their account is inactive.");
|
||||
}
|
||||
|
||||
const filteredSessions = sessions.filter((session) => sessionUserFilter === "all" || session.userId === sessionUserFilter);
|
||||
const activeSessionCount = sessions.filter((session) => session.status === "ACTIVE").length;
|
||||
const revokedSessionCount = sessions.filter((session) => session.status === "REVOKED").length;
|
||||
const expiredSessionCount = sessions.filter((session) => session.status === "EXPIRED").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
@@ -358,6 +422,159 @@ export function UserManagementPage() {
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Sessions</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Active sign-ins and revocation control</h3>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
||||
Review recent authenticated sessions, see their current state, and revoke stale or risky access without changing the user record.
|
||||
</p>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Filter by user</span>
|
||||
<select
|
||||
value={sessionUserFilter}
|
||||
onChange={(event) => setSessionUserFilter(event.target.value)}
|
||||
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
>
|
||||
<option value="all">All users</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.firstName} {user.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Active</p>
|
||||
<p className="mt-2 text-2xl font-bold text-text">{activeSessionCount}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Revoked</p>
|
||||
<p className="mt-2 text-2xl font-bold text-text">{revokedSessionCount}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Expired</p>
|
||||
<p className="mt-2 text-2xl font-bold text-text">{expiredSessionCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
{filteredSessions.map((session) => (
|
||||
<div key={session.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-text">{session.userName}</p>
|
||||
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
{session.status}
|
||||
</span>
|
||||
{session.isCurrent ? (
|
||||
<span className="rounded-full bg-brand px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
|
||||
Current
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted">{session.userEmail}</p>
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted md:grid-cols-2 xl:grid-cols-4">
|
||||
<p>Started: {new Date(session.createdAt).toLocaleString()}</p>
|
||||
<p>Last seen: {new Date(session.lastSeenAt).toLocaleString()}</p>
|
||||
<p>Expires: {new Date(session.expiresAt).toLocaleString()}</p>
|
||||
<p>IP: {session.ipAddress || "Unknown"}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">Agent: {session.userAgent || "Unknown"}</p>
|
||||
{session.revokedAt ? (
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Revoked {new Date(session.revokedAt).toLocaleString()}
|
||||
{session.revokedByName ? ` by ${session.revokedByName}` : ""}.
|
||||
{session.revokedReason ? ` ${session.revokedReason}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{session.status === "ACTIVE" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPendingConfirmation({
|
||||
kind: "revoke-session",
|
||||
title: session.isCurrent ? "Revoke current session" : `Revoke session for ${session.userName}`,
|
||||
description: session.isCurrent
|
||||
? "Revoke the session you are using right now. Your current browser session will lose access immediately."
|
||||
: `Revoke the selected active session for ${session.userEmail}.`,
|
||||
impact: "The selected token becomes unusable immediately.",
|
||||
recovery: "The user can sign in again unless the account itself is inactive. Review the remaining session list after revocation.",
|
||||
confirmLabel: "Revoke session",
|
||||
confirmationLabel: session.isCurrent ? "Type REVOKE to confirm:" : undefined,
|
||||
confirmationValue: session.isCurrent ? "REVOKE" : undefined,
|
||||
sessionId: session.id,
|
||||
})
|
||||
}
|
||||
className="rounded-2xl border border-red-300 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
|
||||
>
|
||||
Revoke session
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-line/70 bg-page/40 px-3 py-6 text-sm text-muted">
|
||||
No sessions match the current filter.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmActionDialog
|
||||
open={pendingConfirmation != null}
|
||||
title={pendingConfirmation?.title ?? "Confirm admin action"}
|
||||
description={pendingConfirmation?.description ?? ""}
|
||||
impact={pendingConfirmation?.impact}
|
||||
recovery={pendingConfirmation?.recovery}
|
||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||
isConfirming={isConfirmingAction}
|
||||
onClose={() => {
|
||||
if (!isConfirmingAction) {
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmingAction(true);
|
||||
|
||||
try {
|
||||
if (pendingConfirmation.kind === "deactivate-user" && pendingConfirmation.userId) {
|
||||
await saveUser();
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "revoke-session" && pendingConfirmation.sessionId) {
|
||||
const isCurrentSession = sessions.find((session) => session.id === pendingConfirmation.sessionId)?.isCurrent ?? false;
|
||||
await handleSessionRevoke(pendingConfirmation.sessionId, isCurrentSession);
|
||||
}
|
||||
|
||||
if (
|
||||
pendingConfirmation.kind === "deactivate-user" &&
|
||||
pendingConfirmation.userId &&
|
||||
pendingConfirmation.userId === authUser?.id
|
||||
) {
|
||||
setStatus("Your own account was deactivated. Sign-in will fail after this session ends unless another admin re-enables the account.");
|
||||
}
|
||||
|
||||
setPendingConfirmation(null);
|
||||
} finally {
|
||||
setIsConfirmingAction(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user