feat(settings): add user management, fix totals and mobile layout

Adds a Settings > Users screen where members can be added, turned off
and removed, and fixes three defects found while auditing the app on
a phone.

User management
- Settings is a real page with a Users section: add a member (name,
  email, password), flip "Can sign in" to block or restore access
  without deleting their history, or remove them outright.
- The app has no roles - membership is just the active flag - so the
  Settings route no longer requires the "admin" role, which left it
  unreachable for every real member. Settings is now in the sidebar.
- New members are created with active: true. AuthContext refuses to
  sign in a user whose active flag is false, and the field had no
  default, so previously created users were locked out of the app.

Live PocketBase rules for the `users` collection (see
scripts/fix-users-collection.mjs):
- createRule was "" which means PUBLIC - any anonymous visitor could
  create an account on the instance. It now requires an authenticated
  member.
- listRule was "id = @request.auth.id", so a member could only ever
  see their own record and no member list could be rendered. It now
  matches every other collection in the app: @request.auth.id != "".

Totals always showed R0.00
- form.watch("items") returns the same array instance that
  react-hook-form mutates in place, so the useMemo keyed on it never
  recomputed. Both the quote and purchase order totals stayed at zero
  however many items were added. Switched to useWatch with compute,
  which re-runs whenever the watched field really changes.

Mobile layout
- The page was zoomed out on phones: flex items default to
  min-width:auto, so a wide table stretched the layout past the
  viewport. Added min-w-0 to the layout chain.
- The sr-only labels inside the tables are position:absolute with no
  positioned ancestor, so they escaped the table's overflow-x-auto
  clip and stretched the document. Made the scroll containers
  relative.
- DialogContent was w-full plus an mx-4 gutter, which overflowed the
  viewport by 16px and clipped the right edge of every form. The
  component now owns the gutter itself.

Verified at 390x844: all pages and the quote, purchase order and add
user dialogs fit the viewport with no horizontal overflow.
This commit is contained in:
Dave 2026-10-21 03:13:01 +02:00
parent d606345de4
commit edc9f3da74
20 changed files with 605 additions and 46 deletions

View file

@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Harden the `users` collection and make it usable by the app's
* Settings > Users screen.
*
* Problems found on the live instance:
*
* 1. createRule was "" (empty string) => PUBLIC. Any anonymous visitor
* on the internet could create an account on this PocketBase instance.
* 2. listRule was "id = @request.auth.id" => a signed-in member could
* only ever see their own record, so there was no way to render a
* member list.
* 3. The `active` bool field had no default, so every record was created
* with active = false. AuthContext refuses to sign in a user whose
* `active` is false, so newly created users could not log in at all.
* NOTE: PocketBase's REST API strips `defaultValue` from field
* definitions (verified on this instance - a scratch collection with
* `defaultValue: true` on a bool field still stored false), so the
* default cannot be set from here. The app sets `active: true`
* explicitly when it creates a member instead.
*
* New rules (matching the posture already used by every other collection
* in this app - stock_items, customers, purchase_orders, quotes, ...):
*
* listRule @request.auth.id != "" members can list the team
* viewRule @request.auth.id != "" members can read a member
* createRule @request.auth.id != "" members can add members
* updateRule @request.auth.id != "" members can activate/deactivate
* deleteRule @request.auth.id != "" members can remove members
*
* The `role` field is intentionally left untouched: the app does not use
* roles, membership is just the `active` flag.
*
* Usage:
* node scripts/fix-users-collection.mjs # dry run (default)
* node scripts/fix-users-collection.mjs --apply # apply to the instance
*/
import PocketBase from "pocketbase";
const PB_URL = process.env.POCKETBASE_URL || "https://pb.jeistk.apphub.co.za";
const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || "david@wfsales.co.za";
const PB_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD || "1234567890";
const APPLY = process.argv.includes("--apply");
const AUTHENTICATED = '@request.auth.id != ""';
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD);
const collection = await pb.collections.getOne("users");
console.log("=== BEFORE ===");
for (const rule of ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"]) {
console.log(` ${rule.padEnd(11)}: ${JSON.stringify(collection[rule])}`);
}
const activeField = collection.fields.find((f) => f.name === "active");
console.log(` active field: ${activeField ? "present" : "MISSING"}`);
if (!APPLY) {
console.log("\nDry run. Re-run with --apply to write these rules to the instance.");
process.exit(0);
}
collection.listRule = AUTHENTICATED;
collection.viewRule = AUTHENTICATED;
collection.createRule = AUTHENTICATED;
collection.updateRule = AUTHENTICATED;
collection.deleteRule = AUTHENTICATED;
const updated = await pb.collections.update(collection.id, collection);
console.log("\n=== AFTER ===");
for (const rule of ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"]) {
console.log(` ${rule.padEnd(11)}: ${JSON.stringify(updated[rule])}`);
}
console.log("\nDone. Existing records keep their current `active` value - flip them");
console.log("from Settings > Users if they should be able to sign in.");

View file

@ -91,7 +91,7 @@ const App = () => (
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute requiredRoles="admin">
<ProtectedRoute>
<Layout><Settings /></Layout>
</ProtectedRoute>
} />

View file

@ -9,9 +9,12 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<div className="flex min-h-screen bg-background">
<Sidebar />
<div className="flex-1 flex flex-col">
{/* min-w-0: flex items default to min-width:auto, so a wide table
inside main stretches this column past the viewport. Chrome then
zooms the whole page out on phones. */}
<div className="flex-1 flex flex-col min-w-0">
<Header />
<main className="flex-1 p-3 md:p-6 overflow-auto">
<main className="flex-1 p-3 md:p-6 overflow-auto min-w-0">
{children}
</main>
</div>

View file

@ -1,7 +1,7 @@
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { Home, Book, Users, ArrowLeftRight, Package, ShoppingCart, Truck, ScrollText, FileText } from "lucide-react";
import { Home, Book, Users, ArrowLeftRight, Package, ShoppingCart, Truck, ScrollText, FileText, Settings } from "lucide-react";
const sidebarNavItems = [
{
@ -49,6 +49,11 @@ const sidebarNavItems = [
href: "/logs",
icon: ScrollText,
},
{
title: "Settings",
href: "/settings",
icon: Settings,
},
];
interface SidebarNavProps {

View file

@ -144,7 +144,7 @@ export function AttachmentsDialog({
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) handleCancel() }}>
<DialogContent className="mx-4 sm:max-w-xl md:max-w-2xl">
<DialogContent className="sm:max-w-xl md:max-w-2xl">
<DialogHeader className="pb-2">
<DialogTitle className="text-lg sm:text-xl">{title}</DialogTitle>
{subtitle ? (

View file

@ -181,7 +181,7 @@ export function EditPurchaseOrderDialog({ open, onOpenChange, order, refreshData
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="mx-4 sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Purchase Order</DialogTitle>
<DialogDescription className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm">

View file

@ -286,7 +286,10 @@ export function PurchaseOrderDataTable<TData, TValue>({
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border bg-background overflow-x-auto">
{/* relative: the sr-only labels inside the table are position:absolute.
Without a positioned ancestor they escape this scroll container and
stretch the whole page, which makes Chrome zoom out on phones. */}
<div className="relative rounded-md border bg-background overflow-x-auto">
<table className="w-full min-w-full table-auto">
<thead>
{table.getHeaderGroups().map((headerGroup) => (

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm, useFieldArray } from "react-hook-form"
import { useForm, useFieldArray, useWatch } from "react-hook-form"
import * as z from "zod"
import { useState, useEffect, useCallback } from "react" // Import useState and useEffect
@ -194,20 +194,24 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
name: "items",
});
const itemsValues = form.watch("items") as ItemSchemaType[];
const subtotalGBP = React.useMemo(
() =>
(itemsValues || []).reduce(
// Live total. `form.watch("items")` returns the SAME array instance that
// react-hook-form mutates in place, so a useMemo keyed on it never
// recomputes and the total stays at £0.00. `useWatch` + `compute` re-runs
// the calculation whenever the watched field actually changes.
const subtotalGBP = useWatch({
control: form.control,
name: "items",
compute: (items: ItemSchemaType[]) =>
(items || []).reduce(
(sum, item) => {
const price = item.entryMode === 'manual'
? (Number(item.manualUnitPrice) || 0)
const price = item?.entryMode === 'manual'
? (Number(item?.manualUnitPrice) || 0)
: (Number(item?.costPrice) || 0)
return sum + (Number(item?.quantity || 0) * price)
},
0
),
[itemsValues]
);
});
// Function to add a new item row

View file

@ -24,7 +24,7 @@ interface ViewOrderDialogProps {
export function ViewOrderDialog({ open, onOpenChange, order }: ViewOrderDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="mx-4 sm:max-w-xl md:max-w-2xl">
<DialogContent className="sm:max-w-xl md:max-w-2xl">
<DialogHeader className="pb-2">
<DialogTitle className="text-lg sm:text-xl">Purchase Order Details</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">

View file

@ -82,7 +82,10 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border bg-background overflow-x-auto">
{/* relative: the sr-only labels inside the table are position:absolute.
Without a positioned ancestor they escape this scroll container and
stretch the whole page, which makes Chrome zoom out on phones. */}
<div className="relative rounded-md border bg-background overflow-x-auto">
<table className="w-full min-w-full table-auto">
<thead>
{table.getHeaderGroups().map(hg => (
@ -111,7 +114,7 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt
{/* View Dialog */}
<Dialog open={viewDialogOpen} onOpenChange={setViewDialogOpen}>
<DialogContent className="mx-4 sm:max-w-xl md:max-w-2xl">
<DialogContent className="sm:max-w-xl md:max-w-2xl">
<DialogHeader className="pb-2">
<DialogTitle className="text-lg sm:text-xl">Quote Details</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">{selectedQuote?.quote_number}</DialogDescription>
@ -176,7 +179,7 @@ function QuoteAttachmentsDialog({ open, onOpenChange, quote, refreshData }: {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="mx-4 sm:max-w-xl md:max-w-2xl">
<DialogContent className="sm:max-w-xl md:max-w-2xl">
<DialogHeader className="pb-2">
<DialogTitle className="text-lg sm:text-xl">Quote Attachments</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">{quote.quote_number}</DialogDescription>

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm, useFieldArray } from "react-hook-form"
import { useForm, useFieldArray, useWatch } from "react-hook-form"
import * as z from "zod"
import { useState, useEffect, useCallback } from "react"
import { Button } from "@/components/ui/button"
@ -112,16 +112,22 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) {
}, [quoteNumber, form])
const { fields, append, remove } = useFieldArray({ control: form.control, name: "items" })
const itemsValues = form.watch("items") as QuoteItemSchemaType[]
const totalZAR = React.useMemo(
() => (itemsValues || []).reduce((sum, item) => {
const price = item.entryMode === 'manual'
? (Number(item.manualUnitPrice) || 0)
: (Number(item.unitPrice) || 0)
return sum + (Number(item.quantity || 0) * price)
}, 0),
[itemsValues]
)
// Live total. `form.watch("items")` returns the SAME array instance that
// react-hook-form mutates in place, so a useMemo keyed on it never
// recomputes and the total stays at R0.00. `useWatch` + `compute` re-runs
// the calculation whenever the watched field actually changes.
const totalZAR = useWatch({
control: form.control,
name: "items",
compute: (items: QuoteItemSchemaType[]) =>
(items || []).reduce((sum, item) => {
const price = item?.entryMode === 'manual'
? (Number(item?.manualUnitPrice) || 0)
: (Number(item?.unitPrice) || 0)
return sum + (Number(item?.quantity || 0) * price)
}, 0),
})
const vatAmount = totalZAR * 0.15
const totalWithVat = totalZAR + vatAmount

View file

@ -0,0 +1,441 @@
"use client";
import * as React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Loader2, UserPlus, Users, Check, X, Trash2, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { showSuccess, showError } from "@/utils/toast";
import pb from "@/lib/pocketbase";
import { useAuth } from "@/contexts/AuthContext";
/**
* A member of the team. The app has no roles - a member is either active
* (can sign in) or inactive (cannot sign in).
*
* Note: the `users` auth collection has no `created`/`updated` autodate
* fields, so members are ordered by email instead.
*/
type Member = {
id: string;
email: string;
name: string;
active: boolean;
};
/** Shape of a `users` record as returned by the PocketBase SDK. */
type UserRecord = {
id: string;
email: string;
name?: string;
active?: boolean;
};
/** Error thrown by the PocketBase SDK on a failed request. */
type PbError = {
response?: {
message?: string;
data?: Record<string, { message?: string }>;
};
};
const addMemberSchema = z
.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
email: z.string().email({ message: "Please enter a valid email address." }),
password: z.string().min(8, { message: "Password must be at least 8 characters." }),
confirmPassword: z.string(),
})
.refine((values) => values.password === values.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"],
});
type AddMemberValues = z.infer<typeof addMemberSchema>;
export function UserManagement() {
const { user: currentUser } = useAuth();
const [members, setMembers] = React.useState<Member[]>([]);
const [loading, setLoading] = React.useState(true);
const [addOpen, setAddOpen] = React.useState(false);
const [adding, setAdding] = React.useState(false);
const [busyId, setBusyId] = React.useState<string | null>(null);
const [removeTarget, setRemoveTarget] = React.useState<Member | null>(null);
const [removing, setRemoving] = React.useState(false);
const loadMembers = React.useCallback(async () => {
try {
const records = await pb.collection("users").getFullList({ sort: "email" });
setMembers(
records.map((r) => {
const user = r as unknown as UserRecord;
return {
id: user.id,
email: user.email || "",
name: user.name || "",
active: user.active !== false,
};
}),
);
} catch (err) {
console.error("Error loading members:", err);
const pbErr = err as PbError;
showError(pbErr?.response?.message || "Failed to load members.");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
loadMembers();
}, [loadMembers]);
const form = useForm<AddMemberValues>({
resolver: zodResolver(addMemberSchema),
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
},
});
const onAddMember = async (values: AddMemberValues) => {
setAdding(true);
try {
await pb.collection("users").create({
email: values.email,
password: values.password,
passwordConfirm: values.confirmPassword,
name: values.name,
// A new member must be active, otherwise AuthContext refuses the
// sign-in ("Account is deactivated").
active: true,
});
showSuccess(`${values.name} can now sign in with ${values.email}.`);
form.reset();
setAddOpen(false);
await loadMembers();
} catch (err) {
console.error("Error adding member:", err);
const pbErr = err as PbError;
const message =
pbErr?.response?.data?.email?.message ||
pbErr?.response?.message ||
"Failed to add member.";
showError(message);
} finally {
setAdding(false);
}
};
const toggleActive = async (member: Member, nextActive: boolean) => {
setBusyId(member.id);
try {
await pb.collection("users").update(member.id, { active: nextActive });
setMembers((prev) =>
prev.map((m) => (m.id === member.id ? { ...m, active: nextActive } : m)),
);
showSuccess(
nextActive
? `${member.name || member.email} can now sign in.`
: `${member.name || member.email} can no longer sign in.`,
);
} catch (err) {
console.error("Error updating member:", err);
const pbErr = err as PbError;
showError(pbErr?.response?.message || "Failed to update member.");
} finally {
setBusyId(null);
}
};
const removeMember = async () => {
if (!removeTarget) return;
setRemoving(true);
try {
await pb.collection("users").delete(removeTarget.id);
showSuccess(`${removeTarget.name || removeTarget.email} was removed.`);
setRemoveTarget(null);
await loadMembers();
} catch (err) {
console.error("Error removing member:", err);
const pbErr = err as PbError;
showError(pbErr?.response?.message || "Failed to remove member.");
} finally {
setRemoving(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Users</h2>
<p className="text-sm text-muted-foreground">
Anyone you add here can sign in to the app. Turn a member off to
block their access without deleting their history.
</p>
</div>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogTrigger asChild>
<Button className="w-full sm:w-auto">
<UserPlus className="mr-2 h-4 w-4" />
Add User
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader className="pb-2">
<DialogTitle className="text-lg sm:text-xl">Add User</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Create an account for a member of your team. Share the password
with them so they can sign in.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onAddMember)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="e.g. Tiaan Botha" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="name@company.co.za"
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="text"
placeholder="At least 8 characters"
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormDescription className="text-xs">
Minimum 8 characters. They can change it after signing in.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Repeat the password"
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={adding}
>
{adding ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Adding...
</>
) : (
"Add User"
)}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
{loading ? (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span className="text-sm">Loading users...</span>
</div>
) : members.length === 0 ? (
<div className="rounded-lg border border-dashed border-border py-10 text-center">
<Users className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">No users yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Add the first member to get your team started.
</p>
</div>
) : (
<ul className="space-y-2">
{members.map((member) => {
const isSelf = currentUser?.id === member.id;
const busy = busyId === member.id;
return (
<li
key={member.id}
className="rounded-lg border border-border bg-background p-3 sm:p-4"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium text-foreground">
{member.name || member.email}
</p>
{isSelf && (
<Badge variant="secondary" className="text-[10px]">
You
</Badge>
)}
<Badge
variant={member.active ? "default" : "outline"}
className="gap-1 text-[10px]"
>
{member.active ? (
<>
<Check className="h-3 w-3" />
Active
</>
) : (
<>
<X className="h-3 w-3" />
Inactive
</>
)}
</Badge>
</div>
<p className="mt-0.5 truncate text-sm text-muted-foreground">
{member.email}
</p>
</div>
<div className="flex items-center justify-between gap-3 sm:justify-end">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
Can sign in
<Switch
checked={member.active}
disabled={busy || isSelf}
onCheckedChange={(checked) =>
toggleActive(member, checked)
}
aria-label={`Allow ${member.name || member.email} to sign in`}
/>
</label>
<Button
variant="ghost"
size="icon"
disabled={busy || isSelf}
onClick={() => setRemoveTarget(member)}
aria-label={`Remove ${member.name || member.email}`}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</li>
);
})}
</ul>
)}
<div className="flex items-start gap-2 rounded-lg bg-muted/50 p-3 text-xs text-muted-foreground">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p>
This app has no roles. Every user you add can sign in and change any
data. Turn someone off instead of deleting them if you only want to
block their access.
</p>
</div>
<AlertDialog
open={!!removeTarget}
onOpenChange={(open) => !open && setRemoveTarget(null)}
>
<AlertDialogContent className="">
<AlertDialogHeader>
<AlertDialogTitle>Remove this user?</AlertDialogTitle>
<AlertDialogDescription className="text-sm">
{removeTarget?.name || removeTarget?.email} will lose access to
the app immediately. This cannot be undone - consider turning them
off instead.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={removeMember}
disabled={removing}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{removing ? "Removing..." : "Remove User"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-1/2 top-1/2 z-50 grid w-[calc(100%_-_2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-4 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:p-6 sm:rounded-lg",
className,
)}
{...props}

View file

@ -71,7 +71,7 @@ const BookOut = () => {
Book Out Item
</Button>
</DialogTrigger>
<DialogContent className="mx-4 sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Book Out Item</DialogTitle>
<DialogDescription>

View file

@ -68,7 +68,7 @@ const Customers = () => {
Add New Customer
</Button>
</DialogTrigger>
<DialogContent className="mx-4 sm:max-w-[425px]">
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Customer</DialogTitle>
<DialogDescription>

View file

@ -135,7 +135,7 @@ const PurchaseOrders = () => {
New Purchase Order
</Button>
</DialogTrigger>
<DialogContent className="mx-4 sm:max-w-[800px] lg:max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-[800px] lg:max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogHeader className="pb-2">
<DialogTitle className="text-lg sm:text-xl">New Purchase Order</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">

View file

@ -109,7 +109,7 @@ const Quotes = () => {
New Quote
</Button>
</DialogTrigger>
<DialogContent className="mx-4 sm:max-w-[800px] lg:max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-[800px] lg:max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogHeader className="pb-2">
<DialogTitle className="text-lg sm:text-xl">New Quote</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">Select a customer and add items to create a quote.</DialogDescription>

View file

@ -1,7 +1,16 @@
const Settings = () => (
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p>Application settings will be configured here.</p>
</div>
);
export default Settings;
import { UserManagement } from "@/components/settings/UserManagement";
const Settings = () => {
return (
<div className="container mx-auto py-4 md:py-6 max-w-3xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-sm text-muted-foreground">
Manage who can access this app.
</p>
</div>
<UserManagement />
</div>
);
};
export default Settings;

View file

@ -67,7 +67,7 @@ const Stock = () => {
Add New Item
</Button>
</DialogTrigger>
<DialogContent className="mx-4 sm:max-w-[425px]">
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Stock Item</DialogTitle>
<DialogDescription>
@ -78,7 +78,10 @@ const Stock = () => {
</DialogContent>
</Dialog>
</div>
<div className="w-full overflow-x-auto">
{/* relative: the sr-only labels inside the table are position:absolute.
Without a positioned ancestor they escape this scroll container and
stretch the whole page, which makes Chrome zoom out on phones. */}
<div className="relative w-full overflow-x-auto">
<DataTable
columns={columns}
data={stockItems}

View file

@ -61,7 +61,7 @@ const Suppliers = () => {
Add New Supplier
</Button>
</DialogTrigger>
<DialogContent className="mx-4 sm:max-w-[425px]">
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Supplier</DialogTitle>
<DialogDescription>