user and roles functional

This commit is contained in:
shafi54 2026-05-24 00:03:44 +05:30
parent 86ff92511e
commit 302866dc58
29 changed files with 2190 additions and 78 deletions

View file

@ -8,6 +8,7 @@ import {
createEnterpriseRepo,
createStaffRepo,
createEnterpriseStaffRepo,
createRolesRepo,
type StorageSpacesRepo,
type DistributorsRepo,
type ProductsRepo,
@ -17,6 +18,7 @@ import {
type EnterpriseRepo,
type StaffRepo,
type EnterpriseStaffRepo,
type RolesRepo,
} from "data-manager-sqlite";
export class DataManager {
@ -29,6 +31,7 @@ export class DataManager {
readonly enterprises: EnterpriseRepo;
readonly staff: StaffRepo;
readonly enterpriseStaff: EnterpriseStaffRepo;
readonly roles: RolesRepo;
constructor() {
const { repo: storageSpacesRepo } = createStorageSpacesRepo();
@ -40,6 +43,7 @@ export class DataManager {
const { repo: enterpriseRepo } = createEnterpriseRepo();
const { repo: staffRepo } = createStaffRepo();
const { repo: enterpriseStaffRepo } = createEnterpriseStaffRepo();
const { repo: rolesRepo } = createRolesRepo();
this.storageSpaces = storageSpacesRepo;
this.distributors = distributorsRepo;
@ -50,5 +54,6 @@ export class DataManager {
this.enterprises = enterpriseRepo;
this.staff = staffRepo;
this.enterpriseStaff = enterpriseStaffRepo;
this.roles = rolesRepo;
}
}

View file

@ -0,0 +1,14 @@
import { z } from "zod";
import { protectedProcedure, router } from "../../init";
import { dataManager } from "../../../lib/data-manager-instance";
export const RoleSchema = z.object({
id: z.number().int(),
name: z.string(),
});
export const rolesRouter = router({
list: protectedProcedure
.output(z.array(RoleSchema))
.query(() => dataManager.roles.listRoles()),
});

View file

@ -0,0 +1,72 @@
import { z } from "zod";
import { protectedProcedure, router } from "../../init";
import { dataManager } from "../../../lib/data-manager-instance";
export const StaffRoleSchema = z.object({ id: z.number().int(), name: z.string() });
export const StaffWithRolesSchema = z.object({
id: z.number().int(),
name: z.string(),
username: z.string(),
email: z.string().nullable(),
mobile: z.string().nullable(),
added_on: z.string(),
is_password_reset_needed: z.boolean(),
roles: z.array(StaffRoleSchema),
});
export const CreateStaffInput = z.object({
name: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
email: z.string().nullable().optional(),
mobile: z.string().nullable().optional(),
added_on: z.string(),
role_ids: z.array(z.number().int()).default([]),
});
export const UpdateStaffInput = z.object({
id: z.number().int(),
name: z.string().min(1).optional(),
username: z.string().min(1).optional(),
email: z.string().nullable().optional(),
mobile: z.string().nullable().optional(),
is_password_reset_needed: z.boolean().optional(),
role_ids: z.array(z.number().int()).optional(),
});
export const staffManagementRouter = router({
list: protectedProcedure
.output(z.array(StaffWithRolesSchema))
.query(({ ctx }) =>
dataManager.staff.getStaffByEnterprise(ctx.staff.enterpriseId),
),
byId: protectedProcedure
.input(z.object({ id: z.number().int() }))
.output(StaffWithRolesSchema.nullable())
.query(({ input }) => dataManager.staff.getStaffById(input.id)),
create: protectedProcedure
.input(CreateStaffInput)
.output(StaffWithRolesSchema)
.mutation(({ ctx, input }) =>
dataManager.staff.createStaff(input, ctx.staff.enterpriseId),
),
update: protectedProcedure
.input(UpdateStaffInput)
.output(StaffWithRolesSchema.nullable())
.mutation(({ input }) => {
const { id, ...patch } = input;
return dataManager.staff.updateStaff(id, patch);
}),
remove: protectedProcedure
.input(z.object({ id: z.number().int() }))
.output(z.object({ ok: z.boolean() }))
.mutation(async ({ input }) => {
const ok = await dataManager.staff.deleteStaff(input.id);
return { ok };
}),
});

View file

@ -6,6 +6,8 @@ import { drugInfoRouter } from "./pharmanager/v1/drugInfo";
import { unitsRouter } from "./pharmanager/v1/units";
import { stockRouter } from "./pharmanager/v1/stock";
import { authRouter } from "./pharmanager/v1/auth";
import { staffManagementRouter } from "./pharmanager/v1/staffManagement";
import { rolesRouter } from "./pharmanager/v1/roles";
export const appRouter = router({
storage: storageRouter,
@ -15,6 +17,8 @@ export const appRouter = router({
units: unitsRouter,
stock: stockRouter,
auth: authRouter,
staffManagement: staffManagementRouter,
roles: rolesRouter,
});
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,157 @@
import {
forwardRef,
useState,
useRef,
useEffect,
type ComponentPropsWithoutRef,
} from "react";
import { ChevronsUpDown, X } from "lucide-react";
import { cn } from "#/lib/cn";
import { Checkbox } from "./Checkbox";
export interface SelectOption {
value: string;
label: string;
}
interface SelectBaseProps {
options: SelectOption[];
multiple?: boolean;
placeholder?: string;
value?: string | string[];
onChangeValue?: (value: string | string[]) => void;
}
export type SelectProps = Omit<
ComponentPropsWithoutRef<"select">,
"multiple"
> &
SelectBaseProps;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
(
{
options,
multiple,
placeholder,
value: controlledValue,
onChangeValue,
className,
...props
},
ref,
) => {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, []);
if (multiple) {
const selected = Array.isArray(controlledValue)
? controlledValue
: [];
const selectedLabels = options
.filter((o) => selected.includes(o.value))
.map((o) => o.label);
function toggle(val: string) {
const next = selected.includes(val)
? selected.filter((v) => v !== val)
: [...selected, val];
onChangeValue?.(next);
}
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
"w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-left bg-white",
"focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600",
"flex items-center justify-between gap-2 min-h-[42px]",
)}
>
<div className="flex flex-wrap gap-1 flex-1">
{selectedLabels.length > 0 ? (
selectedLabels.map((label) => (
<span
key={label}
className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium"
>
{label}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const opt = options.find(
(o) => o.label === label,
);
if (opt) toggle(opt.value);
}}
className="ml-0.5 hover:bg-blue-100 rounded-full"
>
<X className="w-3 h-3" />
</button>
</span>
))
) : (
<span className="text-slate-400">
{placeholder || "Select..."}
</span>
)}
</div>
<ChevronsUpDown className="w-4 h-4 text-slate-400 shrink-0" />
</button>
{open && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-slate-200 rounded-md shadow-lg max-h-52 overflow-y-auto p-2">
<div className="space-y-1">
{options.map((opt) => (
<Checkbox
key={opt.value}
checked={selected.includes(opt.value)}
onChange={() => toggle(opt.value)}
label={opt.label}
/>
))}
</div>
</div>
)}
</div>
);
}
return (
<select
ref={ref}
className={cn(
"w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600",
className,
)}
{...props}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
},
);
Select.displayName = "Select";

View file

@ -10,3 +10,5 @@ export { BackLink } from "./BackLink";
export { FormField } from "./FormField";
export { EmptyState } from "./EmptyState";
export { SearchToolbar } from "./SearchToolbar";
export { Select } from "./Select";
export type { SelectOption } from "./Select";

View file

@ -21,7 +21,7 @@ if (!rootElement) throw new Error("Root element not found");
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<TrpcProvider baseUrl="http://localhost:3001">
<TrpcProvider>
<RouterProvider router={router} />
</TrpcProvider>,
);

View file

@ -21,12 +21,15 @@ import { Route as BillingRouteImport } from './routes/billing'
import { Route as IndexRouteImport } from './routes/index'
import { Route as StorageIndexRouteImport } from './routes/storage/index'
import { Route as StockIndexRouteImport } from './routes/stock/index'
import { Route as StaffIndexRouteImport } from './routes/staff/index'
import { Route as ProductsIndexRouteImport } from './routes/products/index'
import { Route as DistributorsIndexRouteImport } from './routes/distributors/index'
import { Route as StorageAddRouteImport } from './routes/storage/add'
import { Route as StorageIdRouteImport } from './routes/storage/$id'
import { Route as StockAddRouteImport } from './routes/stock/add'
import { Route as StockIdRouteImport } from './routes/stock/$id'
import { Route as StaffAddRouteImport } from './routes/staff/add'
import { Route as StaffIdRouteImport } from './routes/staff/$id'
import { Route as ProductsAddRouteImport } from './routes/products/add'
import { Route as ProductsIdRouteImport } from './routes/products/$id'
import { Route as DistributorsAddRouteImport } from './routes/distributors/add'
@ -92,6 +95,11 @@ const StockIndexRoute = StockIndexRouteImport.update({
path: '/',
getParentRoute: () => StockRoute,
} as any)
const StaffIndexRoute = StaffIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => StaffRoute,
} as any)
const ProductsIndexRoute = ProductsIndexRouteImport.update({
id: '/',
path: '/',
@ -122,6 +130,16 @@ const StockIdRoute = StockIdRouteImport.update({
path: '/$id',
getParentRoute: () => StockRoute,
} as any)
const StaffAddRoute = StaffAddRouteImport.update({
id: '/add',
path: '/add',
getParentRoute: () => StaffRoute,
} as any)
const StaffIdRoute = StaffIdRouteImport.update({
id: '/$id',
path: '/$id',
getParentRoute: () => StaffRoute,
} as any)
const ProductsAddRoute = ProductsAddRouteImport.update({
id: '/add',
path: '/add',
@ -151,19 +169,22 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/products': typeof ProductsRouteWithChildren
'/profile': typeof ProfileRoute
'/staff': typeof StaffRoute
'/staff': typeof StaffRouteWithChildren
'/stock': typeof StockRouteWithChildren
'/storage': typeof StorageRouteWithChildren
'/distributors/$id': typeof DistributorsIdRoute
'/distributors/add': typeof DistributorsAddRoute
'/products/$id': typeof ProductsIdRoute
'/products/add': typeof ProductsAddRoute
'/staff/$id': typeof StaffIdRoute
'/staff/add': typeof StaffAddRoute
'/stock/$id': typeof StockIdRoute
'/stock/add': typeof StockAddRoute
'/storage/$id': typeof StorageIdRoute
'/storage/add': typeof StorageAddRoute
'/distributors/': typeof DistributorsIndexRoute
'/products/': typeof ProductsIndexRoute
'/staff/': typeof StaffIndexRoute
'/stock/': typeof StockIndexRoute
'/storage/': typeof StorageIndexRoute
}
@ -173,17 +194,19 @@ export interface FileRoutesByTo {
'/customers': typeof CustomersRoute
'/login': typeof LoginRoute
'/profile': typeof ProfileRoute
'/staff': typeof StaffRoute
'/distributors/$id': typeof DistributorsIdRoute
'/distributors/add': typeof DistributorsAddRoute
'/products/$id': typeof ProductsIdRoute
'/products/add': typeof ProductsAddRoute
'/staff/$id': typeof StaffIdRoute
'/staff/add': typeof StaffAddRoute
'/stock/$id': typeof StockIdRoute
'/stock/add': typeof StockAddRoute
'/storage/$id': typeof StorageIdRoute
'/storage/add': typeof StorageAddRoute
'/distributors': typeof DistributorsIndexRoute
'/products': typeof ProductsIndexRoute
'/staff': typeof StaffIndexRoute
'/stock': typeof StockIndexRoute
'/storage': typeof StorageIndexRoute
}
@ -196,19 +219,22 @@ export interface FileRoutesById {
'/login': typeof LoginRoute
'/products': typeof ProductsRouteWithChildren
'/profile': typeof ProfileRoute
'/staff': typeof StaffRoute
'/staff': typeof StaffRouteWithChildren
'/stock': typeof StockRouteWithChildren
'/storage': typeof StorageRouteWithChildren
'/distributors/$id': typeof DistributorsIdRoute
'/distributors/add': typeof DistributorsAddRoute
'/products/$id': typeof ProductsIdRoute
'/products/add': typeof ProductsAddRoute
'/staff/$id': typeof StaffIdRoute
'/staff/add': typeof StaffAddRoute
'/stock/$id': typeof StockIdRoute
'/stock/add': typeof StockAddRoute
'/storage/$id': typeof StorageIdRoute
'/storage/add': typeof StorageAddRoute
'/distributors/': typeof DistributorsIndexRoute
'/products/': typeof ProductsIndexRoute
'/staff/': typeof StaffIndexRoute
'/stock/': typeof StockIndexRoute
'/storage/': typeof StorageIndexRoute
}
@ -229,12 +255,15 @@ export interface FileRouteTypes {
| '/distributors/add'
| '/products/$id'
| '/products/add'
| '/staff/$id'
| '/staff/add'
| '/stock/$id'
| '/stock/add'
| '/storage/$id'
| '/storage/add'
| '/distributors/'
| '/products/'
| '/staff/'
| '/stock/'
| '/storage/'
fileRoutesByTo: FileRoutesByTo
@ -244,17 +273,19 @@ export interface FileRouteTypes {
| '/customers'
| '/login'
| '/profile'
| '/staff'
| '/distributors/$id'
| '/distributors/add'
| '/products/$id'
| '/products/add'
| '/staff/$id'
| '/staff/add'
| '/stock/$id'
| '/stock/add'
| '/storage/$id'
| '/storage/add'
| '/distributors'
| '/products'
| '/staff'
| '/stock'
| '/storage'
id:
@ -273,12 +304,15 @@ export interface FileRouteTypes {
| '/distributors/add'
| '/products/$id'
| '/products/add'
| '/staff/$id'
| '/staff/add'
| '/stock/$id'
| '/stock/add'
| '/storage/$id'
| '/storage/add'
| '/distributors/'
| '/products/'
| '/staff/'
| '/stock/'
| '/storage/'
fileRoutesById: FileRoutesById
@ -291,7 +325,7 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute
ProductsRoute: typeof ProductsRouteWithChildren
ProfileRoute: typeof ProfileRoute
StaffRoute: typeof StaffRoute
StaffRoute: typeof StaffRouteWithChildren
StockRoute: typeof StockRouteWithChildren
StorageRoute: typeof StorageRouteWithChildren
}
@ -382,6 +416,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StockIndexRouteImport
parentRoute: typeof StockRoute
}
'/staff/': {
id: '/staff/'
path: '/'
fullPath: '/staff/'
preLoaderRoute: typeof StaffIndexRouteImport
parentRoute: typeof StaffRoute
}
'/products/': {
id: '/products/'
path: '/'
@ -424,6 +465,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StockIdRouteImport
parentRoute: typeof StockRoute
}
'/staff/add': {
id: '/staff/add'
path: '/add'
fullPath: '/staff/add'
preLoaderRoute: typeof StaffAddRouteImport
parentRoute: typeof StaffRoute
}
'/staff/$id': {
id: '/staff/$id'
path: '/$id'
fullPath: '/staff/$id'
preLoaderRoute: typeof StaffIdRouteImport
parentRoute: typeof StaffRoute
}
'/products/add': {
id: '/products/add'
path: '/add'
@ -487,6 +542,20 @@ const ProductsRouteWithChildren = ProductsRoute._addFileChildren(
ProductsRouteChildren,
)
interface StaffRouteChildren {
StaffIdRoute: typeof StaffIdRoute
StaffAddRoute: typeof StaffAddRoute
StaffIndexRoute: typeof StaffIndexRoute
}
const StaffRouteChildren: StaffRouteChildren = {
StaffIdRoute: StaffIdRoute,
StaffAddRoute: StaffAddRoute,
StaffIndexRoute: StaffIndexRoute,
}
const StaffRouteWithChildren = StaffRoute._addFileChildren(StaffRouteChildren)
interface StockRouteChildren {
StockIdRoute: typeof StockIdRoute
StockAddRoute: typeof StockAddRoute
@ -524,7 +593,7 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute,
ProductsRoute: ProductsRouteWithChildren,
ProfileRoute: ProfileRoute,
StaffRoute: StaffRoute,
StaffRoute: StaffRouteWithChildren,
StockRoute: StockRouteWithChildren,
StorageRoute: StorageRouteWithChildren,
}

View file

@ -13,7 +13,7 @@ import {
useListDistributors,
trpc,
} from "shared-react";
import { Button, Input, Checkbox, buttonVariants } from "#/components/ui";
import { Button, Input, Checkbox, Select, buttonVariants } from "#/components/ui";
import { CreateProductInput } from "@repo/shared";
const formSchema = CreateProductInput.extend({
@ -206,14 +206,10 @@ function AddProductPage() {
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">Category</label>
<select
<Select
{...register("category")}
className="w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
options={CATEGORIES.map((c) => ({ value: c, label: c }))}
/>
</div>
{showUnitsPerStrip && (
@ -236,29 +232,21 @@ function AddProductPage() {
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Product Unit <span className="text-red-600">*</span>
</label>
<select
<Select
{...register("unit_name")}
className={`w-full px-3.5 py-2.5 border rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 ${errors.unit_name ? "border-red-600" : "border-slate-200 focus:border-blue-600"}`}
>
<option value="">Select...</option>
{(unitList ?? []).map((u) => (
<option key={u.id} value={u.name}>{u.name}</option>
))}
</select>
options={(unitList ?? []).map((u) => ({ value: u.name, label: u.name }))}
placeholder="Select..."
/>
{errors.unit_name && <p className="text-sm text-red-600 mt-1">{errors.unit_name.message}</p>}
</div>
<div className={showUnitsPerStrip ? "" : "col-span-full"}>
<label className="block text-sm font-medium text-slate-900 mb-1.5">Distributor</label>
<select
<Select
{...register("distributor_id")}
className="w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
>
<option value="">None</option>
{(distributorList ?? []).map((d) => (
<option key={d.id} value={d.id}>{d.agency}</option>
))}
</select>
options={(distributorList ?? []).map((d) => ({ value: String(d.id), label: d.agency }))}
placeholder="None"
/>
</div>
{/* Composition */}
@ -294,15 +282,11 @@ function AddProductPage() {
</div>
<div className="w-28">
<label className="block text-xs font-medium text-slate-600 mb-1">Unit</label>
<select
<Select
{...register(`compositions.${index}.unit_name`)}
className={`w-full px-3 py-2 border rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 ${errors.compositions?.[index]?.unit_name ? "border-red-600" : "border-slate-200 focus:border-blue-600"}`}
>
<option value="">Select...</option>
{(unitList ?? []).map((u) => (
<option key={u.id} value={u.name}>{u.name}</option>
))}
</select>
options={(unitList ?? []).map((u) => ({ value: u.name, label: u.name }))}
placeholder="Select..."
/>
</div>
<Button
type="button"

View file

@ -1,13 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/staff")({
component: StaffPage,
staticData: {
title: "Staff",
subtitle: "Staff management with roles & permissions",
},
component: () => <Outlet />,
});
function StaffPage() {
return <div>Staff</div>;
}

View file

@ -0,0 +1,140 @@
import { createFileRoute } from "@tanstack/react-router";
import { Pencil, Trash2, Users, Shield, Calendar, Mail, Phone, User } from "lucide-react";
import { Button, DetailRow, BackLink, EmptyState } from "#/components/ui";
import { useGetStaffById, useRemoveStaff, trpc } from "shared-react";
function fmtDate(d: string): string {
const date = new Date(d + "T00:00:00");
return date.toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" });
}
function roleBadgeClass(name: string): string {
const l = name.toLowerCase();
if (l.includes("admin")) return "bg-red-50 text-red-600";
if (l.includes("pharmacist")) return "bg-blue-50 text-blue-600";
if (l.includes("cashier")) return "bg-emerald-50 text-emerald-600";
if (l.includes("manager")) return "bg-amber-50 text-amber-600";
return "bg-slate-100 text-slate-600";
}
export const Route = createFileRoute("/staff/$id")({
component: StaffDetailsPage,
staticData: {
title: "Staff Details",
subtitle: "Staff member information",
},
});
function StaffDetailsPage() {
const { id } = Route.useParams();
const staffId = Number(id);
const { data: staff, isLoading, error } = useGetStaffById(staffId);
const removeMutation = useRemoveStaff();
const utils = trpc.useUtils();
function handleDelete() {
if (!staff) return;
if (!confirm(`Delete ${staff.name}?`)) return;
removeMutation.mutate(
{ id: staff.id },
{
onSuccess: () => {
utils.staffManagement.list.invalidate();
window.location.href = "/staff";
},
},
);
}
if (isLoading) return <div className="text-sm text-slate-600 py-8">Loading staff details...</div>;
if (error || !staff) {
return (
<EmptyState
icon={Users}
title="Staff not found"
description="The staff member you're looking for doesn't exist."
actionLabel="Back to Staff"
actionTo="/staff"
/>
);
}
const initials = staff.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<div>
<BackLink to="/staff" label="Staff" />
<div className="flex items-center gap-5 bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-6 mb-5 max-w-[800px]">
<div className="w-16 h-16 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold text-[22px] shrink-0">
{initials}
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-slate-900">{staff.name}</h2>
<div className="flex flex-wrap gap-2 mt-1.5">
{staff.roles.length > 0 ? (
staff.roles.map((r) => (
<span
key={r.id}
className={`inline-block px-3 py-0.5 rounded-full text-[13px] font-medium ${roleBadgeClass(r.name)}`}
>
{r.name}
</span>
))
) : (
<span className="text-sm text-slate-400">No role assigned</span>
)}
</div>
</div>
<div className="flex gap-2">
<Button variant="primary">
<Pencil className="w-[15px] h-[15px]" />
Edit Staff
</Button>
<Button variant="danger" onClick={handleDelete}>
<Trash2 className="w-[15px] h-[15px]" />
Delete Staff
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-4 max-w-[800px]">
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5">
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-600 uppercase tracking-wider mb-3">
<User className="w-[14px] h-[14px]" />
Personal Info
</h3>
<DetailRow label="Username" value={staff.username} />
<DetailRow label="Email" value={staff.email || "—"} />
<DetailRow label="Mobile" value={staff.mobile || "—"} last />
</div>
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5">
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-600 uppercase tracking-wider mb-3">
<Shield className="w-[14px] h-[14px]" />
Roles
</h3>
<DetailRow
label="Roles"
value={staff.roles.length > 0 ? staff.roles.map((r) => r.name).join(", ") : "No roles"}
last
/>
</div>
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-5 col-span-full">
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-600 uppercase tracking-wider mb-3">
<Calendar className="w-[14px] h-[14px]" />
Employment
</h3>
<DetailRow label="Date Joined" value={fmtDate(staff.added_on)} />
<DetailRow label="Password Reset Needed" value={staff.is_password_reset_needed ? "Yes" : "No"} last />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,192 @@
import { useEffect } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { Plus } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
useCreateStaff,
useUpdateStaff,
useGetStaffById,
useListRoles,
trpc,
} from "shared-react";
import { Button, Input, BackLink, Select, buttonVariants } from "#/components/ui";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
email: z.string().nullable().optional(),
mobile: z.string().nullable().optional(),
added_on: z.string().min(1, "Date is required"),
role_ids: z.array(z.coerce.number().int()).default([]),
});
type FormValues = z.infer<typeof formSchema>;
export const Route = createFileRoute("/staff/add")({
component: AddStaffPage,
validateSearch: (search: Record<string, unknown>) => ({
id: search.id ? Number(search.id) : undefined,
}),
staticData: {
title: "Add Staff",
subtitle: "Add a new staff member to the pharmacy",
},
});
function AddStaffPage() {
const navigate = useNavigate();
const { id: editId } = Route.useSearch();
const createMutation = useCreateStaff();
const updateMutation = useUpdateStaff();
const { data: existingStaff } = useGetStaffById(editId ?? 0);
const utils = trpc.useUtils();
const { data: roleList } = useListRoles();
const isEditing = typeof editId === "number" && editId > 0;
const {
register,
handleSubmit,
reset,
watch,
setValue,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
username: "",
password: "",
email: null,
mobile: null,
added_on: new Date().toISOString().slice(0, 10),
role_ids: [],
},
});
useEffect(() => {
if (isEditing && existingStaff) {
reset({
name: existingStaff.name,
username: existingStaff.username,
password: "",
email: existingStaff.email,
mobile: existingStaff.mobile,
added_on: existingStaff.added_on,
role_ids: existingStaff.roles.map((r) => r.id),
});
}
}, [isEditing, existingStaff, reset]);
const selectedRoleIds: number[] = watch("role_ids") || [];
function toggleRoles(newIds: string[]) {
setValue("role_ids", newIds.map(Number));
}
function onSubmit(values: FormValues) {
if (isEditing) {
updateMutation.mutate(
{ id: editId!, ...values, password: values.password || undefined },
{
onSuccess: () => {
utils.staffManagement.list.invalidate();
utils.staffManagement.byId.invalidate({ id: editId! });
navigate({ to: "/staff" });
},
},
);
} else {
createMutation.mutate(values, {
onSuccess: () => {
utils.staffManagement.list.invalidate();
navigate({ to: "/staff" });
},
});
}
}
const mutation = isEditing ? updateMutation : createMutation;
return (
<div>
<BackLink to="/staff" label="Staff" />
<form onSubmit={handleSubmit(onSubmit)} className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] p-8 max-w-2xl">
<h2 className="text-xl font-semibold mb-1">{isEditing ? "Edit Staff" : "Add Staff"}</h2>
<p className="text-sm text-slate-600 mb-7">
{isEditing ? "Update staff member details." : "Add a new staff member to the pharmacy."}
</p>
<div className="grid grid-cols-2 gap-5">
<div className="col-span-full">
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Full Name <span className="text-red-600">*</span>
</label>
<Input {...register("name")} variant={errors.name ? "error" : "default"} placeholder="e.g. John Doe" />
{errors.name && <p className="text-sm text-red-600 mt-1">{errors.name.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Username <span className="text-red-600">*</span>
</label>
<Input {...register("username")} variant={errors.username ? "error" : "default"} placeholder="johndoe" />
{errors.username && <p className="text-sm text-red-600 mt-1">{errors.username.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Password <span className="text-red-600">*</span>
</label>
<Input type="password" {...register("password")} variant={errors.password ? "error" : "default"} placeholder={isEditing ? "Leave blank to keep current" : "Enter password"} />
{errors.password && <p className="text-sm text-red-600 mt-1">{errors.password.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">Email</label>
<Input {...register("email", { setValueAs: (v: string) => v || null })} placeholder="johndoe@example.com" />
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">Mobile</label>
<Input {...register("mobile", { setValueAs: (v: string) => v || null })} placeholder="9876543210" />
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Date Joined <span className="text-red-600">*</span>
</label>
<Input type="date" {...register("added_on")} variant={errors.added_on ? "error" : "default"} />
{errors.added_on && <p className="text-sm text-red-600 mt-1">{errors.added_on.message}</p>}
</div>
<div className="col-span-full">
<label className="block text-sm font-medium text-slate-900 mb-2">Roles</label>
<Select
multiple
value={selectedRoleIds.map(String)}
onChangeValue={toggleRoles}
options={(roleList ?? []).map((r) => ({ value: String(r.id), label: r.name }))}
/>
</div>
<div className="col-span-full flex justify-end gap-3 pt-5 mt-2 border-t border-slate-200">
<Link to="/staff" className={buttonVariants({ variant: "outline" })}>Cancel</Link>
<Button type="submit" disabled={mutation.isPending}>
<Plus className="w-[15px] h-[15px]" />
{mutation.isPending ? "Saving..." : isEditing ? "Update Staff" : "Add Staff"}
</Button>
</div>
</div>
{mutation.error && (
<p className="text-sm text-red-600 mt-4">Failed to {isEditing ? "update" : "create"} staff. Please try again.</p>
)}
</form>
</div>
);
}

View file

@ -0,0 +1,171 @@
import { useState, useMemo, useCallback } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { Pencil, Trash2, Users } from "lucide-react";
import { GridTable } from "#/components/GridTable";
import type { GridTableColumn } from "#/components/GridTable";
import { Button, SearchToolbar } from "#/components/ui";
import { useListStaff, useRemoveStaff, trpc } from "shared-react";
interface StaffRow {
id: number;
name: string;
username: string;
mobile: string | null;
added_on: string;
roles: { id: number; name: string }[];
}
function fmtDate(d: string): string {
const date = new Date(d + "T00:00:00");
return date.toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" });
}
function roleBadgeClass(name: string): string {
const l = name.toLowerCase();
if (l.includes("admin")) return "bg-red-50 text-red-600";
if (l.includes("pharmacist")) return "bg-blue-50 text-blue-600";
if (l.includes("cashier")) return "bg-emerald-50 text-emerald-600";
if (l.includes("manager")) return "bg-amber-50 text-amber-600";
return "bg-slate-100 text-slate-600";
}
function makeColumns(
onDelete: (row: StaffRow) => void,
): GridTableColumn<StaffRow>[] {
return [
{
id: "name",
header: "Staff Name",
cell: ({ row }) => (
<Link
to="/staff/$id"
params={{ id: row.id.toString() }}
className="font-semibold text-blue-600 hover:underline"
>
{row.name}
</Link>
),
},
{
id: "mobile",
header: "Mobile",
cell: ({ row }) => (
<span className="text-sm text-slate-600 font-mono">
{row.mobile || "—"}
</span>
),
},
{
id: "role",
header: "Role",
cell: ({ row }) => (
<div className="flex flex-wrap gap-1">
{row.roles.length > 0 ? (
row.roles.map((r) => (
<span
key={r.id}
className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${roleBadgeClass(r.name)}`}
>
{r.name}
</span>
))
) : (
<span className="text-sm text-slate-400"></span>
)}
</div>
),
},
{
id: "added_on",
header: "Date Joined",
cell: ({ row }) => (
<span className="text-sm text-slate-600 whitespace-nowrap">
{fmtDate(row.added_on)}
</span>
),
},
{
id: "actions",
header: "Actions",
size: 90,
cell: ({ row }) => (
<div className="flex items-center justify-center gap-1">
<Link to="/staff/add" search={{ id: row.id }}>
<Button variant="ghost-blue" size="icon" aria-label={`Edit ${row.name}`} type="button">
<Pencil className="w-[15px] h-[15px]" />
</Button>
</Link>
<Button variant="ghost-red" size="icon" aria-label={`Delete ${row.name}`} onClick={() => onDelete(row)}>
<Trash2 className="w-[15px] h-[15px]" />
</Button>
</div>
),
},
];
}
export const Route = createFileRoute("/staff/")({
component: StaffIndexPage,
staticData: {
title: "Staff",
subtitle: "Manage pharmacy staff members",
},
});
function StaffIndexPage() {
const [searchQuery, setSearchQuery] = useState("");
const { data: staffList, isLoading, error } = useListStaff();
const removeMutation = useRemoveStaff();
const utils = trpc.useUtils();
const handleDelete = useCallback(
(row: StaffRow) => {
if (!confirm(`Delete ${row.name}?`)) return;
removeMutation.mutate(
{ id: row.id },
{ onSuccess: () => utils.staffManagement.list.invalidate() },
);
},
[removeMutation, utils],
);
const columns = useMemo(() => makeColumns(handleDelete), [handleDelete]);
const filtered = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
if (!q) return staffList ?? [];
return (staffList ?? []).filter((s) => {
const text = `${s.name} ${s.mobile || ""} ${s.roles.map((r) => r.name).join(" ")}`;
return text.toLowerCase().includes(q);
});
}, [searchQuery, staffList]);
if (isLoading) return <div className="text-sm text-slate-600 py-8">Loading staff...</div>;
if (error) return <div className="text-sm text-red-600 py-8">Failed to load staff.</div>;
return (
<div>
<SearchToolbar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search by name, mobile, or role..."
addLink="/staff/add"
addLabel="Add Staff"
/>
<GridTable
columns={columns}
data={filtered}
emptyState={
<div className="flex flex-col items-center py-16 px-6 text-slate-600">
<Users className="w-12 h-12 mb-4 opacity-40" />
<h3 className="text-base font-semibold text-slate-900 mb-1.5">No staff found</h3>
<p className="text-sm">
{searchQuery ? "No staff match your search." : "Add your first staff member to get started."}
</p>
</div>
}
/>
</div>
);
}

View file

@ -13,7 +13,7 @@ import {
useListDistributors,
trpc,
} from "shared-react";
import { Button, Input, Checkbox, buttonVariants, Combobox } from "#/components/ui";
import { Button, Input, Checkbox, Select, buttonVariants, Combobox } from "#/components/ui";
import { CreateStockBatchInput } from "@repo/shared";
const formSchema = CreateStockBatchInput.extend({
@ -193,28 +193,20 @@ function AddStockPage() {
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">Rack</label>
<select
<Select
{...register("rack_id")}
className="w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
>
<option value=""> Select a rack </option>
{(racks ?? []).map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
options={(racks ?? []).map((r) => ({ value: String(r.id), label: r.name }))}
placeholder="— Select a rack —"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-900 mb-1.5">Distributor</label>
<select
<Select
{...register("distributor_id")}
className="w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
>
<option value=""> Select a distributor </option>
{(distributorList ?? []).map((d) => (
<option key={d.id} value={d.id}>{d.agency}</option>
))}
</select>
options={(distributorList ?? []).map((d) => ({ value: String(d.id), label: d.agency }))}
placeholder="— Select a distributor —"
/>
</div>
<div className="col-span-full">

View file

@ -0,0 +1,3 @@
CREATE UNIQUE INDEX `role_permissions_role_id_permission_id_unique` ON `role_permissions` (`role_id`,`permission_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `staff_roles_staff_id_role_id_unique` ON `staff_roles` (`staff_id`,`role_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `enterprise_staff_staff_id_enterprise_id_unique` ON `enterprise_staff` (`staff_id`,`enterprise_id`);

File diff suppressed because it is too large Load diff

View file

@ -43,6 +43,13 @@
"when": 1779546674621,
"tag": "0005_yielding_silver_fox",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1779551911536,
"tag": "0006_nappy_sir_ram",
"breakpoints": true
}
]
}

View file

@ -11,6 +11,7 @@ export type EnterpriseStaff = {
export type EnterpriseStaffRepo = {
getByStaffId: (staffId: number) => EnterpriseStaff | null
listByEnterprise: (enterpriseId: number) => EnterpriseStaff[]
create: (input: { staff_id: number; enterprise_id: number }) => EnterpriseStaff
}
@ -29,6 +30,11 @@ export function createEnterpriseStaffRepo(): { repo: EnterpriseStaffRepo } {
return row ? toEnterpriseStaff(row) : null
},
listByEnterprise(enterpriseId) {
const rows = db.select().from(enterpriseStaff).where(eq(enterpriseStaff.enterpriseId, enterpriseId)).all()
return rows.map(toEnterpriseStaff)
},
create(input) {
const created = db.insert(enterpriseStaff).values({
staffId: input.staff_id,

View file

@ -62,3 +62,18 @@ export {
type EnterpriseStaff,
type EnterpriseStaffRepo,
} from './enterpriseStaff'
export {
createRolesRepo,
type Role,
type RolesRepo,
} from './roles'
export {
createPermissionsRepo,
type Permission,
type PermissionsRepo,
} from './permissions'
export {
createStaffRolesRepo,
type StaffRole,
type StaffRolesRepo,
} from './staffRoles'

View file

@ -0,0 +1,48 @@
import { eq } from 'drizzle-orm'
import { db } from './db-instance'
import { permissions } from './schema/permissions'
import { rolePermissions } from './schema/rolePermissions'
export type Permission = {
id: number
name: string
}
export type PermissionsRepo = {
listPermissions: () => Promise<Permission[]>
getRolePermissions: (roleId: number) => Permission[]
setRolePermissions: (roleId: number, permIds: number[]) => void
}
function toPermission(row: typeof permissions.$inferSelect): Permission {
return { id: row.id, name: row.name }
}
export function createPermissionsRepo(): { repo: PermissionsRepo } {
const repo: PermissionsRepo = {
listPermissions() {
const rows = db.select().from(permissions).all()
return Promise.resolve(rows.map(toPermission))
},
getRolePermissions(roleId) {
const rows = db
.select({
id: permissions.id,
name: permissions.name,
})
.from(rolePermissions)
.innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
.where(eq(rolePermissions.roleId, roleId))
.all()
return rows.map((r) => ({ id: r.id, name: r.name }))
},
setRolePermissions(roleId, permIds) {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run()
for (const permId of permIds) {
db.insert(rolePermissions).values({ roleId, permissionId: permId }).run()
}
},
}
return { repo }
}

View file

@ -0,0 +1,50 @@
import { eq } from 'drizzle-orm'
import { db } from './db-instance'
import { roles } from './schema/roles'
export type Role = {
id: number
name: string
}
export type RolesRepo = {
listRoles: () => Promise<Role[]>
getRoleById: (id: number) => Promise<Role | null>
createRole: (input: { name: string }) => Promise<Role>
updateRole: (id: number, input: { name: string }) => Promise<Role | null>
deleteRole: (id: number) => Promise<boolean>
}
function toRole(row: typeof roles.$inferSelect): Role {
return { id: row.id, name: row.name }
}
export function createRolesRepo(): { repo: RolesRepo } {
const repo: RolesRepo = {
listRoles() {
const rows = db.select().from(roles).all()
return Promise.resolve(rows.map(toRole))
},
getRoleById(id) {
const row = db.select().from(roles).where(eq(roles.id, id)).get()
return Promise.resolve(row ? toRole(row) : null)
},
createRole(input) {
const created = db.insert(roles).values(input).returning().get()
return Promise.resolve(toRole(created))
},
updateRole(id, input) {
const existing = db.select().from(roles).where(eq(roles.id, id)).get()
if (!existing) return Promise.resolve(null)
db.update(roles).set({ name: input.name }).where(eq(roles.id, id)).run()
const updated = db.select().from(roles).where(eq(roles.id, id)).get()!
return Promise.resolve(toRole(updated))
},
deleteRole(id) {
const deleted = db.delete(roles).where(eq(roles.id, id)).returning({ id: roles.id }).get()
return Promise.resolve(Boolean(deleted))
},
}
return { repo }
}

View file

@ -1,4 +1,4 @@
import { integer, sqliteTable } from 'drizzle-orm/sqlite-core'
import { integer, sqliteTable, unique } from 'drizzle-orm/sqlite-core'
import { staff } from './staff'
import { enterprises } from './enterprises'
@ -6,4 +6,6 @@ export const enterpriseStaff = sqliteTable('enterprise_staff', {
id: integer('id').primaryKey({ autoIncrement: true }),
staffId: integer('staff_id').notNull().references(() => staff.id),
enterpriseId: integer('enterprise_id').notNull().references(() => enterprises.id),
})
}, (table) => ({
uniquePair: unique().on(table.staffId, table.enterpriseId),
}))

View file

@ -1,4 +1,4 @@
import { integer, sqliteTable } from 'drizzle-orm/sqlite-core'
import { integer, sqliteTable, unique } from 'drizzle-orm/sqlite-core'
import { roles } from './roles'
import { permissions } from './permissions'
@ -6,4 +6,6 @@ export const rolePermissions = sqliteTable('role_permissions', {
id: integer('id').primaryKey({ autoIncrement: true }),
roleId: integer('role_id').notNull().references(() => roles.id),
permissionId: integer('permission_id').notNull().references(() => permissions.id),
})
}, (table) => ({
uniquePair: unique().on(table.roleId, table.permissionId),
}))

View file

@ -1,4 +1,4 @@
import { integer, sqliteTable } from 'drizzle-orm/sqlite-core'
import { integer, sqliteTable, unique } from 'drizzle-orm/sqlite-core'
import { staff } from './staff'
import { roles } from './roles'
@ -6,4 +6,6 @@ export const staffRoles = sqliteTable('staff_roles', {
id: integer('id').primaryKey({ autoIncrement: true }),
staffId: integer('staff_id').notNull().references(() => staff.id),
roleId: integer('role_id').notNull().references(() => roles.id),
})
}, (table) => ({
uniquePair: unique().on(table.staffId, table.roleId),
}))

View file

@ -3,6 +3,9 @@ import bcrypt from 'bcryptjs'
import { db } from './db-instance'
import { staff } from './schema/staff'
import { staffRoles } from './schema/staffRoles'
import { roles } from './schema/roles'
import { enterpriseStaff } from './schema/enterpriseStaff'
export type Staff = {
id: number
@ -13,16 +16,31 @@ export type Staff = {
added_on: string
password: string
is_password_reset_needed: boolean
roles: { id: number; name: string }[]
}
export type StaffRepo = {
getStaffById: (id: number) => Promise<Staff | null>
getStaffByUsername: (username: string) => Promise<Staff | null>
createStaff: (input: { name: string; username: string; email?: string | null; mobile?: string | null; added_on: string; password: string; is_password_reset_needed?: boolean }) => Promise<Staff>
getStaffByEnterprise: (enterpriseId: number) => Promise<Staff[]>
createStaff: (input: { name: string; username: string; email?: string | null; mobile?: string | null; added_on: string; password: string; is_password_reset_needed?: boolean; role_ids?: number[] }, enterpriseId: number) => Promise<Staff>
updateStaff: (id: number, input: { name?: string; username?: string; email?: string | null; mobile?: string | null; is_password_reset_needed?: boolean; role_ids?: number[] }) => Promise<Staff | null>
updateStaffRoles: (staffId: number, roleIds: number[]) => void
deleteStaff: (id: number) => Promise<boolean>
verifyPassword: (staff: Staff, password: string) => Promise<boolean>
}
function toStaff(row: typeof staff.$inferSelect): Staff {
function getRolesForStaff(staffId: number): { id: number; name: string }[] {
const rows = db
.select({ roleId: roles.id, roleName: roles.name })
.from(staffRoles)
.innerJoin(roles, eq(staffRoles.roleId, roles.id))
.where(eq(staffRoles.staffId, staffId))
.all()
return rows.map((r) => ({ id: r.roleId, name: r.roleName }))
}
function toStaff(row: typeof staff.$inferSelect): Omit<Staff, 'roles'> {
return {
id: row.id,
name: row.name,
@ -39,15 +57,29 @@ export function createStaffRepo(): { repo: StaffRepo } {
const repo: StaffRepo = {
getStaffById(id) {
const row = db.select().from(staff).where(eq(staff.id, id)).get()
return Promise.resolve(row ? toStaff(row) : null)
if (!row) return Promise.resolve(null)
return Promise.resolve({ ...toStaff(row), roles: getRolesForStaff(id) })
},
getStaffByUsername(username) {
const row = db.select().from(staff).where(eq(staff.username, username)).get()
return Promise.resolve(row ? toStaff(row) : null)
if (!row) return Promise.resolve(null)
return Promise.resolve({ ...toStaff(row), roles: getRolesForStaff(row.id) })
},
createStaff(input) {
getStaffByEnterprise(enterpriseId) {
const rows = db
.select({ staff: staff })
.from(enterpriseStaff)
.innerJoin(staff, eq(enterpriseStaff.staffId, staff.id))
.where(eq(enterpriseStaff.enterpriseId, enterpriseId))
.all()
return Promise.resolve(
rows.map((r) => ({ ...toStaff(r.staff), roles: getRolesForStaff(r.staff.id) })),
)
},
createStaff(input, enterpriseId) {
const hashed = bcrypt.hashSync(input.password, 10)
const created = db.insert(staff).values({
name: input.name,
@ -58,7 +90,56 @@ export function createStaffRepo(): { repo: StaffRepo } {
password: hashed,
isPasswordResetNeeded: input.is_password_reset_needed ?? true,
}).returning().get()
return Promise.resolve(toStaff(created))
db.insert(enterpriseStaff).values({ staffId: created.id, enterpriseId }).run()
if (input.role_ids && input.role_ids.length > 0) {
for (const roleId of input.role_ids) {
db.insert(staffRoles).values({ staffId: created.id, roleId }).run()
}
}
return Promise.resolve({ ...toStaff(created), roles: getRolesForStaff(created.id) })
},
updateStaff(id, input) {
const existing = db.select().from(staff).where(eq(staff.id, id)).get()
if (!existing) return Promise.resolve(null)
const setData: Record<string, unknown> = {}
if (input.name !== undefined) setData.name = input.name
if (input.username !== undefined) setData.username = input.username
if (input.email !== undefined) setData.email = input.email ?? null
if (input.mobile !== undefined) setData.mobile = input.mobile ?? null
if (input.is_password_reset_needed !== undefined) setData.isPasswordResetNeeded = input.is_password_reset_needed
if (Object.keys(setData).length > 0) {
db.update(staff).set(setData).where(eq(staff.id, id)).run()
}
if (input.role_ids !== undefined) {
db.delete(staffRoles).where(eq(staffRoles.staffId, id)).run()
for (const roleId of input.role_ids) {
db.insert(staffRoles).values({ staffId: id, roleId }).run()
}
}
const updated = db.select().from(staff).where(eq(staff.id, id)).get()!
return Promise.resolve({ ...toStaff(updated), roles: getRolesForStaff(id) })
},
updateStaffRoles(staffId, roleIds) {
db.delete(staffRoles).where(eq(staffRoles.staffId, staffId)).run()
for (const roleId of roleIds) {
db.insert(staffRoles).values({ staffId, roleId }).run()
}
},
deleteStaff(id) {
db.delete(staffRoles).where(eq(staffRoles.staffId, id)).run()
db.delete(enterpriseStaff).where(eq(enterpriseStaff.staffId, id)).run()
const deleted = db.delete(staff).where(eq(staff.id, id)).returning({ id: staff.id }).get()
return Promise.resolve(Boolean(deleted))
},
async verifyPassword(s, password) {

View file

@ -0,0 +1,48 @@
import { eq } from 'drizzle-orm'
import { db } from './db-instance'
import { staffRoles } from './schema/staffRoles'
import { roles } from './schema/roles'
export type StaffRole = {
id: number
staff_id: number
role: { id: number; name: string }
}
export type StaffRolesRepo = {
getByStaffId: (staffId: number) => StaffRole[]
setStaffRoles: (staffId: number, roleIds: number[]) => void
}
export function createStaffRolesRepo(): { repo: StaffRolesRepo } {
const repo: StaffRolesRepo = {
getByStaffId(staffId) {
const rows = db
.select({
id: staffRoles.id,
staffId: staffRoles.staffId,
roleId: roles.id,
roleName: roles.name,
})
.from(staffRoles)
.innerJoin(roles, eq(staffRoles.roleId, roles.id))
.where(eq(staffRoles.staffId, staffId))
.all()
return rows.map((r) => ({
id: r.id,
staff_id: r.staffId,
role: { id: r.roleId, name: r.roleName },
}))
},
setStaffRoles(staffId, roleIds) {
db.delete(staffRoles).where(eq(staffRoles.staffId, staffId)).run()
for (const roleId of roleIds) {
db.insert(staffRoles).values({ staffId, roleId }).run()
}
},
}
return { repo }
}

View file

@ -0,0 +1,5 @@
import { trpc } from "../trpc";
export function useListRoles() {
return trpc.roles.list.useQuery();
}

View file

@ -0,0 +1,21 @@
import { trpc } from "../trpc";
export function useListStaff() {
return trpc.staffManagement.list.useQuery();
}
export function useGetStaffById(id: number) {
return trpc.staffManagement.byId.useQuery({ id });
}
export function useCreateStaff() {
return trpc.staffManagement.create.useMutation();
}
export function useUpdateStaff() {
return trpc.staffManagement.update.useMutation();
}
export function useRemoveStaff() {
return trpc.staffManagement.remove.useMutation();
}

View file

@ -7,6 +7,8 @@ export * from './hooks/products'
export * from './hooks/drugInfo'
export * from './hooks/units'
export * from './hooks/stockBatches'
export * from './hooks/staffManagement'
export * from './hooks/roles'
export { trpc } from './trpc'
export { useAuthStore, useWhoAmI, useLogin } from './auth'
export type { AuthStaff, AuthEnterprise } from './auth'