This commit is contained in:
2026-03-15 19:40:35 -05:00
parent 275c73b584
commit dcac4f135d
17 changed files with 659 additions and 318 deletions

View File

@@ -4,6 +4,7 @@ import type { ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryTypeOptions, inventoryUnitOptions } from "./config";
@@ -26,6 +27,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const [vendorPickerOpen, setVendorPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
const [isSaving, setIsSaving] = useState(false);
const [pendingRemoval, setPendingRemoval] = useState<{ kind: "operation" | "bom-line"; index: number } | null>(null);
function getComponentOption(componentItemId: string) {
return componentOptions.find((option) => option.id === componentItemId) ?? null;
@@ -192,6 +194,12 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
setActiveComponentPicker((current) => (current === index ? null : current != null && current > index ? current - 1 : current));
}
const pendingRemovalDetail = pendingRemoval
? pendingRemoval.kind === "operation"
? { label: form.operations[pendingRemoval.index]?.stationId || "this routing operation", typeLabel: "routing operation" }
: { label: getComponentSku(form.bomLines[pendingRemoval.index]?.componentItemId ?? "") || "this BOM line", typeLabel: "BOM line" }
: null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
@@ -472,7 +480,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
<input type="number" min={0} step={10} value={operation.position} onChange={(event) => updateOperation(index, { ...operation, position: Number(event.target.value) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end">
<button type="button" onClick={() => removeOperation(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
<button type="button" onClick={() => setPendingRemoval({ kind: "operation", index })} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
Remove
</button>
</div>
@@ -619,7 +627,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
<div className="flex items-end">
<button
type="button"
onClick={() => removeBomLine(index)}
onClick={() => setPendingRemoval({ kind: "bom-line", index })}
className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300"
>
Remove
@@ -649,6 +657,31 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
</button>
</div>
</section>
<ConfirmActionDialog
open={pendingRemoval != null}
title={pendingRemoval?.kind === "operation" ? "Remove routing operation" : "Remove BOM line"}
description={
pendingRemoval && pendingRemovalDetail
? `Remove ${pendingRemovalDetail.label} from the item ${pendingRemovalDetail.typeLabel} draft.`
: "Remove this draft row."
}
impact={
pendingRemoval?.kind === "operation"
? "The operation will no longer be copied into new work orders from this item."
: "The component requirement will be removed from the BOM draft immediately."
}
recovery="Add the row back before saving if this change was accidental."
confirmLabel={pendingRemoval?.kind === "operation" ? "Remove operation" : "Remove BOM line"}
onClose={() => setPendingRemoval(null)}
onConfirm={() => {
if (pendingRemoval?.kind === "operation") {
removeOperation(pendingRemoval.index);
} else if (pendingRemoval?.kind === "bom-line") {
removeBomLine(pendingRemoval.index);
}
setPendingRemoval(null);
}}
/>
</form>
);
}

View File

@@ -2,6 +2,7 @@ import type { WarehouseInput, WarehouseLocationInput } from "@mrp/shared/dist/in
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyWarehouseInput, emptyWarehouseLocationInput } from "./config";
@@ -13,6 +14,7 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
const [form, setForm] = useState<WarehouseInput>(emptyWarehouseInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new warehouse." : "Loading warehouse...");
const [isSaving, setIsSaving] = useState(false);
const [pendingLocationRemovalIndex, setPendingLocationRemovalIndex] = useState<number | null>(null);
useEffect(() => {
if (mode !== "edit" || !token || !warehouseId) {
@@ -67,6 +69,8 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
}));
}
const pendingLocationRemoval = pendingLocationRemovalIndex != null ? form.locations[pendingLocationRemovalIndex] : null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
@@ -147,7 +151,7 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
<input value={location.name} onChange={(event) => updateLocation(index, { ...location, name: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end">
<button type="button" onClick={() => removeLocation(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
<button type="button" onClick={() => setPendingLocationRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
Remove
</button>
</div>
@@ -167,6 +171,21 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
</button>
</div>
</section>
<ConfirmActionDialog
open={pendingLocationRemoval != null}
title="Remove warehouse location"
description={pendingLocationRemoval ? `Remove location ${pendingLocationRemoval.code || pendingLocationRemoval.name || "from this warehouse draft"}.` : "Remove this location."}
impact="The location will be removed from the warehouse edit form immediately."
recovery="Add the location back before saving if it should remain part of this warehouse."
confirmLabel="Remove location"
onClose={() => setPendingLocationRemovalIndex(null)}
onConfirm={() => {
if (pendingLocationRemovalIndex != null) {
removeLocation(pendingLocationRemovalIndex);
}
setPendingLocationRemovalIndex(null);
}}
/>
</form>
);
}

View File

@@ -8,10 +8,17 @@ import type {
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyProjectInput, projectPriorityOptions, projectStatusOptions } from "./config";
type ProjectPendingConfirmation =
| { kind: "change-customer"; customerId: string; customerName: string }
| { kind: "unlink-quote" }
| { kind: "unlink-order" }
| { kind: "unlink-shipment" };
export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const { token, user } = useAuth();
const navigate = useNavigate();
@@ -34,6 +41,7 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const [shipmentPickerOpen, setShipmentPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
const [isSaving, setIsSaving] = useState(false);
const [pendingConfirmation, setPendingConfirmation] = useState<ProjectPendingConfirmation | null>(null);
useEffect(() => {
if (!token) {
@@ -103,6 +111,43 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
}));
}
function hasLinkedCommercialRecords() {
return Boolean(form.salesQuoteId || form.salesOrderId || form.shipmentId);
}
function applyCustomerSelection(customerId: string, customerName: string) {
updateField("customerId", customerId);
setCustomerSearchTerm(customerName);
setCustomerPickerOpen(false);
}
function requestCustomerSelection(customerId: string, customerName: string) {
if (form.customerId && form.customerId !== customerId && hasLinkedCommercialRecords()) {
setPendingConfirmation({ kind: "change-customer", customerId, customerName });
return;
}
applyCustomerSelection(customerId, customerName);
}
function unlinkQuote() {
updateField("salesQuoteId", null);
setQuoteSearchTerm("");
setQuotePickerOpen(false);
}
function unlinkOrder() {
updateField("salesOrderId", null);
setOrderSearchTerm("");
setOrderPickerOpen(false);
}
function unlinkShipment() {
updateField("shipmentId", null);
setShipmentSearchTerm("");
setShipmentPickerOpen(false);
}
function restoreSearchTerms() {
const selectedCustomer = customerOptions.find((customer) => customer.id === form.customerId);
const selectedOwner = ownerOptions.find((owner) => owner.id === form.ownerId);
@@ -158,13 +203,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
<div className="relative">
<input
value={customerSearchTerm}
onChange={(event) => {
setCustomerSearchTerm(event.target.value);
updateField("customerId", "");
setCustomerPickerOpen(true);
}}
<input
value={customerSearchTerm}
onChange={(event) => {
setCustomerSearchTerm(event.target.value);
setCustomerPickerOpen(true);
}}
onFocus={() => setCustomerPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setCustomerPickerOpen(false);
@@ -187,9 +231,7 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
.map((customer) => (
<button key={customer.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("customerId", customer.id);
setCustomerSearchTerm(customer.name);
setCustomerPickerOpen(false);
requestCustomerSelection(customer.id, customer.name);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{customer.name}</div>
<div className="mt-1 text-xs text-muted">{customer.email}</div>
@@ -274,13 +316,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quote</span>
<div className="relative">
<input
value={quoteSearchTerm}
onChange={(event) => {
setQuoteSearchTerm(event.target.value);
updateField("salesQuoteId", null);
setQuotePickerOpen(true);
}}
<input
value={quoteSearchTerm}
onChange={(event) => {
setQuoteSearchTerm(event.target.value);
setQuotePickerOpen(true);
}}
onFocus={() => setQuotePickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setQuotePickerOpen(false);
@@ -293,9 +334,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesQuoteId", null);
setQuoteSearchTerm("");
setQuotePickerOpen(false);
if (form.salesQuoteId) {
setPendingConfirmation({ kind: "unlink-quote" });
} else {
unlinkQuote();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked quote</div>
</button>
@@ -326,13 +369,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Sales order</span>
<div className="relative">
<input
value={orderSearchTerm}
onChange={(event) => {
setOrderSearchTerm(event.target.value);
updateField("salesOrderId", null);
setOrderPickerOpen(true);
}}
<input
value={orderSearchTerm}
onChange={(event) => {
setOrderSearchTerm(event.target.value);
setOrderPickerOpen(true);
}}
onFocus={() => setOrderPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setOrderPickerOpen(false);
@@ -345,9 +387,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", null);
setOrderSearchTerm("");
setOrderPickerOpen(false);
if (form.salesOrderId) {
setPendingConfirmation({ kind: "unlink-order" });
} else {
unlinkOrder();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked sales order</div>
</button>
@@ -378,13 +422,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Shipment</span>
<div className="relative">
<input
value={shipmentSearchTerm}
onChange={(event) => {
setShipmentSearchTerm(event.target.value);
updateField("shipmentId", null);
setShipmentPickerOpen(true);
}}
<input
value={shipmentSearchTerm}
onChange={(event) => {
setShipmentSearchTerm(event.target.value);
setShipmentPickerOpen(true);
}}
onFocus={() => setShipmentPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setShipmentPickerOpen(false);
@@ -397,9 +440,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("shipmentId", null);
setShipmentSearchTerm("");
setShipmentPickerOpen(false);
if (form.shipmentId) {
setPendingConfirmation({ kind: "unlink-shipment" });
} else {
unlinkShipment();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked shipment</div>
</button>
@@ -439,6 +484,60 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
</button>
</div>
</section>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={
pendingConfirmation?.kind === "change-customer"
? "Change project customer"
: pendingConfirmation?.kind === "unlink-quote"
? "Remove linked quote"
: pendingConfirmation?.kind === "unlink-order"
? "Remove linked sales order"
: "Remove linked shipment"
}
description={
pendingConfirmation?.kind === "change-customer"
? `Switch this project to ${pendingConfirmation.customerName}. Existing quote, sales order, and shipment links will be cleared.`
: pendingConfirmation?.kind === "unlink-quote"
? "Remove the currently linked quote from this project draft."
: pendingConfirmation?.kind === "unlink-order"
? "Remove the currently linked sales order from this project draft."
: "Remove the currently linked shipment from this project draft."
}
impact={
pendingConfirmation?.kind === "change-customer"
? "Commercial and delivery linkage tied to the previous customer will be cleared immediately from the draft."
: "The project will no longer point to that related record after you save this edit."
}
recovery={
pendingConfirmation?.kind === "change-customer"
? "Re-link the correct quote, order, and shipment before saving if the customer change was accidental."
: "Pick the related record again before saving if this unlink was a mistake."
}
confirmLabel={
pendingConfirmation?.kind === "change-customer"
? "Change customer"
: "Remove link"
}
onClose={() => setPendingConfirmation(null)}
onConfirm={() => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "change-customer") {
applyCustomerSelection(pendingConfirmation.customerId, pendingConfirmation.customerName);
} else if (pendingConfirmation.kind === "unlink-quote") {
unlinkQuote();
} else if (pendingConfirmation.kind === "unlink-order") {
unlinkOrder();
} else if (pendingConfirmation.kind === "unlink-shipment") {
unlinkShipment();
}
setPendingConfirmation(null);
}}
/>
</form>
);
}

View File

@@ -2,6 +2,7 @@ import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, Pur
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { inventoryUnitOptions } from "../inventory/config";
@@ -24,6 +25,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [pendingLineRemovalIndex, setPendingLineRemovalIndex] = useState<number | null>(null);
function collectRecommendedPurchaseNodes(node: SalesOrderPlanningNodeDto): SalesOrderPlanningNodeDto[] {
const nodes = node.recommendedPurchaseQuantity > 0 ? [node] : [];
@@ -212,6 +214,15 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
setLineSearchTerms((current) => current.filter((_term, termIndex) => termIndex !== index));
}
const pendingLineRemoval =
pendingLineRemovalIndex != null
? {
index: pendingLineRemovalIndex,
line: form.lines[pendingLineRemovalIndex],
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
}
: null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
@@ -425,7 +436,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
<input type="number" min={0} step={0.01} value={line.unitCost} onChange={(event) => updateLine(index, { ...line, unitCost: Number(event.target.value) })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end"><div className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text">${(line.quantity * line.unitCost).toFixed(2)}</div></div>
<div className="flex items-end"><button type="button" onClick={() => removeLine(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">Remove</button></div>
<div className="flex items-end"><button type="button" onClick={() => setPendingLineRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">Remove</button></div>
</div>
</div>
))}
@@ -444,6 +455,25 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
</button>
</div>
</section>
<ConfirmActionDialog
open={pendingLineRemoval != null}
title="Remove purchase line"
description={
pendingLineRemoval
? `Remove ${pendingLineRemoval.sku || pendingLineRemoval.line?.description || "this line"} from the purchase order draft.`
: "Remove this purchase line."
}
impact="The line will be removed from the draft immediately and purchasing totals will recalculate."
recovery="Re-add the line before saving if the removal was accidental."
confirmLabel="Remove line"
onClose={() => setPendingLineRemovalIndex(null)}
onConfirm={() => {
if (pendingLineRemoval) {
removeLine(pendingLineRemoval.index);
}
setPendingLineRemovalIndex(null);
}}
/>
</form>
);
}

View File

@@ -3,6 +3,7 @@ import type { SalesCustomerOptionDto, SalesDocumentDetailDto, SalesDocumentInput
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { inventoryUnitOptions } from "../inventory/config";
@@ -23,6 +24,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [pendingLineRemovalIndex, setPendingLineRemovalIndex] = useState<number | null>(null);
const subtotal = form.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
const discountAmount = subtotal * (form.discountPercent / 100);
@@ -129,6 +131,15 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
setLineSearchTerms((current: string[]) => current.filter((_term: string, termIndex: number) => termIndex !== index));
}
const pendingLineRemoval =
pendingLineRemovalIndex != null
? {
index: pendingLineRemovalIndex,
line: form.lines[pendingLineRemovalIndex],
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
}
: null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
@@ -431,7 +442,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
</div>
</div>
<div className="flex items-end">
<button type="button" onClick={() => removeLine(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
<button type="button" onClick={() => setPendingLineRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
Remove
</button>
</div>
@@ -465,6 +476,26 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
</button>
</div>
</section>
<ConfirmActionDialog
open={pendingLineRemoval != null}
title={`Remove ${config.singularLabel.toLowerCase()} line`}
description={
pendingLineRemoval
? `Remove ${pendingLineRemoval.sku || pendingLineRemoval.line?.description || "this line"} from the ${config.singularLabel.toLowerCase()}.`
: "Remove this line."
}
impact="The line will be dropped from the document draft immediately and totals will recalculate."
recovery="Add the line back manually before saving if this removal was a mistake."
confirmLabel="Remove line"
isConfirming={false}
onClose={() => setPendingLineRemovalIndex(null)}
onConfirm={() => {
if (pendingLineRemoval) {
removeLine(pendingLineRemoval.index);
}
setPendingLineRemovalIndex(null);
}}
/>
</form>
);
}

View File

@@ -96,6 +96,7 @@ export function AdminDiagnosticsPage() {
["Audit events", diagnostics.auditEventCount.toString()],
["Support logs", diagnostics.supportLogCount.toString()],
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
["Sessions to review", diagnostics.reviewSessionCount.toString()],
["Sales docs", diagnostics.salesDocumentCount.toString()],
["Work orders", diagnostics.workOrderCount.toString()],
["Projects", diagnostics.projectCount.toString()],
@@ -108,6 +109,7 @@ export function AdminDiagnosticsPage() {
["Uploads directory", diagnostics.uploadsDir],
["Client origin", diagnostics.clientOrigin],
["Company profile", diagnostics.companyProfilePresent ? "Present" : "Missing"],
["Active sessions", diagnostics.activeSessionCount.toString()],
["Roles / permissions", `${diagnostics.roleCount} / ${diagnostics.permissionCount}`],
["Customers / vendors", `${diagnostics.customerCount} / ${diagnostics.vendorCount}`],
["Inventory / warehouses", `${diagnostics.inventoryItemCount} / ${diagnostics.warehouseCount}`],

View File

@@ -37,6 +37,9 @@ export function UserManagementPage() {
const [selectedUserId, setSelectedUserId] = useState<string>("new");
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
const [sessionUserFilter, setSessionUserFilter] = useState<string>("all");
const [sessionStatusFilter, setSessionStatusFilter] = useState<"ALL" | AdminAuthSessionDto["status"]>("ALL");
const [sessionReviewFilter, setSessionReviewFilter] = useState<"ALL" | AdminAuthSessionDto["reviewState"]>("ALL");
const [sessionQuery, setSessionQuery] = useState("");
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
const [status, setStatus] = useState("Loading admin access controls...");
@@ -224,10 +227,36 @@ export function UserManagementPage() {
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 normalizedSessionQuery = sessionQuery.trim().toLowerCase();
const filteredSessions = sessions.filter((session) => {
if (sessionUserFilter !== "all" && session.userId !== sessionUserFilter) {
return false;
}
if (sessionStatusFilter !== "ALL" && session.status !== sessionStatusFilter) {
return false;
}
if (sessionReviewFilter !== "ALL" && session.reviewState !== sessionReviewFilter) {
return false;
}
if (!normalizedSessionQuery) {
return true;
}
return (
session.userName.toLowerCase().includes(normalizedSessionQuery) ||
session.userEmail.toLowerCase().includes(normalizedSessionQuery) ||
(session.ipAddress ?? "").toLowerCase().includes(normalizedSessionQuery) ||
(session.userAgent ?? "").toLowerCase().includes(normalizedSessionQuery) ||
session.reviewReasons.some((reason) => reason.toLowerCase().includes(normalizedSessionQuery))
);
});
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;
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
return (
<div className="space-y-6">
@@ -432,24 +461,60 @@ export function UserManagementPage() {
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 className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
<input
value={sessionQuery}
onChange={(event) => setSessionQuery(event.target.value)}
placeholder="User, email, IP, agent, review reason"
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">User</span>
<select
value={sessionUserFilter}
onChange={(event) => setSessionUserFilter(event.target.value)}
className="w-full 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>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select
value={sessionStatusFilter}
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="ALL">All statuses</option>
<option value="ACTIVE">Active</option>
<option value="EXPIRED">Expired</option>
<option value="REVOKED">Revoked</option>
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Review</span>
<select
value={sessionReviewFilter}
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="ALL">All sessions</option>
<option value="REVIEW">Needs review</option>
<option value="NORMAL">Normal</option>
</select>
</label>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
<div className="mt-5 grid gap-3 md:grid-cols-4">
<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>
@@ -462,6 +527,10 @@ export function UserManagementPage() {
<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 className="rounded-2xl border border-amber-300/60 bg-amber-50 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">Needs Review</p>
<p className="mt-2 text-2xl font-bold text-amber-900">{reviewSessionCount}</p>
</div>
</div>
<div className="mt-5 grid gap-3">
@@ -474,6 +543,11 @@ export function UserManagementPage() {
<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.reviewState === "REVIEW" ? (
<span className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-800">
Review
</span>
) : null}
{session.isCurrent ? (
<span className="rounded-full bg-brand px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
Current
@@ -488,6 +562,15 @@ export function UserManagementPage() {
<p>IP: {session.ipAddress || "Unknown"}</p>
</div>
<p className="mt-2 text-xs text-muted">Agent: {session.userAgent || "Unknown"}</p>
{session.reviewReasons.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{session.reviewReasons.map((reason) => (
<span key={reason} className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-1 text-[11px] font-semibold text-amber-800">
{reason}
</span>
))}
</div>
) : null}
{session.revokedAt ? (
<p className="mt-2 text-xs text-muted">
Revoked {new Date(session.revokedAt).toLocaleString()}