user and roles functional
This commit is contained in:
parent
86ff92511e
commit
302866dc58
29 changed files with 2190 additions and 78 deletions
|
|
@ -8,6 +8,7 @@ import {
|
||||||
createEnterpriseRepo,
|
createEnterpriseRepo,
|
||||||
createStaffRepo,
|
createStaffRepo,
|
||||||
createEnterpriseStaffRepo,
|
createEnterpriseStaffRepo,
|
||||||
|
createRolesRepo,
|
||||||
type StorageSpacesRepo,
|
type StorageSpacesRepo,
|
||||||
type DistributorsRepo,
|
type DistributorsRepo,
|
||||||
type ProductsRepo,
|
type ProductsRepo,
|
||||||
|
|
@ -17,6 +18,7 @@ import {
|
||||||
type EnterpriseRepo,
|
type EnterpriseRepo,
|
||||||
type StaffRepo,
|
type StaffRepo,
|
||||||
type EnterpriseStaffRepo,
|
type EnterpriseStaffRepo,
|
||||||
|
type RolesRepo,
|
||||||
} from "data-manager-sqlite";
|
} from "data-manager-sqlite";
|
||||||
|
|
||||||
export class DataManager {
|
export class DataManager {
|
||||||
|
|
@ -29,6 +31,7 @@ export class DataManager {
|
||||||
readonly enterprises: EnterpriseRepo;
|
readonly enterprises: EnterpriseRepo;
|
||||||
readonly staff: StaffRepo;
|
readonly staff: StaffRepo;
|
||||||
readonly enterpriseStaff: EnterpriseStaffRepo;
|
readonly enterpriseStaff: EnterpriseStaffRepo;
|
||||||
|
readonly roles: RolesRepo;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const { repo: storageSpacesRepo } = createStorageSpacesRepo();
|
const { repo: storageSpacesRepo } = createStorageSpacesRepo();
|
||||||
|
|
@ -40,6 +43,7 @@ export class DataManager {
|
||||||
const { repo: enterpriseRepo } = createEnterpriseRepo();
|
const { repo: enterpriseRepo } = createEnterpriseRepo();
|
||||||
const { repo: staffRepo } = createStaffRepo();
|
const { repo: staffRepo } = createStaffRepo();
|
||||||
const { repo: enterpriseStaffRepo } = createEnterpriseStaffRepo();
|
const { repo: enterpriseStaffRepo } = createEnterpriseStaffRepo();
|
||||||
|
const { repo: rolesRepo } = createRolesRepo();
|
||||||
|
|
||||||
this.storageSpaces = storageSpacesRepo;
|
this.storageSpaces = storageSpacesRepo;
|
||||||
this.distributors = distributorsRepo;
|
this.distributors = distributorsRepo;
|
||||||
|
|
@ -50,5 +54,6 @@ export class DataManager {
|
||||||
this.enterprises = enterpriseRepo;
|
this.enterprises = enterpriseRepo;
|
||||||
this.staff = staffRepo;
|
this.staff = staffRepo;
|
||||||
this.enterpriseStaff = enterpriseStaffRepo;
|
this.enterpriseStaff = enterpriseStaffRepo;
|
||||||
|
this.roles = rolesRepo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
apps/backend/src/trpc/pharmanager/v1/roles.ts
Normal file
14
apps/backend/src/trpc/pharmanager/v1/roles.ts
Normal 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()),
|
||||||
|
});
|
||||||
72
apps/backend/src/trpc/pharmanager/v1/staffManagement.ts
Normal file
72
apps/backend/src/trpc/pharmanager/v1/staffManagement.ts
Normal 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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,8 @@ import { drugInfoRouter } from "./pharmanager/v1/drugInfo";
|
||||||
import { unitsRouter } from "./pharmanager/v1/units";
|
import { unitsRouter } from "./pharmanager/v1/units";
|
||||||
import { stockRouter } from "./pharmanager/v1/stock";
|
import { stockRouter } from "./pharmanager/v1/stock";
|
||||||
import { authRouter } from "./pharmanager/v1/auth";
|
import { authRouter } from "./pharmanager/v1/auth";
|
||||||
|
import { staffManagementRouter } from "./pharmanager/v1/staffManagement";
|
||||||
|
import { rolesRouter } from "./pharmanager/v1/roles";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
storage: storageRouter,
|
storage: storageRouter,
|
||||||
|
|
@ -15,6 +17,8 @@ export const appRouter = router({
|
||||||
units: unitsRouter,
|
units: unitsRouter,
|
||||||
stock: stockRouter,
|
stock: stockRouter,
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
|
staffManagement: staffManagementRouter,
|
||||||
|
roles: rolesRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
|
||||||
157
apps/pharmanager/src/components/ui/Select.tsx
Normal file
157
apps/pharmanager/src/components/ui/Select.tsx
Normal 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";
|
||||||
|
|
@ -10,3 +10,5 @@ export { BackLink } from "./BackLink";
|
||||||
export { FormField } from "./FormField";
|
export { FormField } from "./FormField";
|
||||||
export { EmptyState } from "./EmptyState";
|
export { EmptyState } from "./EmptyState";
|
||||||
export { SearchToolbar } from "./SearchToolbar";
|
export { SearchToolbar } from "./SearchToolbar";
|
||||||
|
export { Select } from "./Select";
|
||||||
|
export type { SelectOption } from "./Select";
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ if (!rootElement) throw new Error("Root element not found");
|
||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<TrpcProvider baseUrl="http://localhost:3001">
|
<TrpcProvider>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</TrpcProvider>,
|
</TrpcProvider>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,15 @@ import { Route as BillingRouteImport } from './routes/billing'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as StorageIndexRouteImport } from './routes/storage/index'
|
import { Route as StorageIndexRouteImport } from './routes/storage/index'
|
||||||
import { Route as StockIndexRouteImport } from './routes/stock/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 ProductsIndexRouteImport } from './routes/products/index'
|
||||||
import { Route as DistributorsIndexRouteImport } from './routes/distributors/index'
|
import { Route as DistributorsIndexRouteImport } from './routes/distributors/index'
|
||||||
import { Route as StorageAddRouteImport } from './routes/storage/add'
|
import { Route as StorageAddRouteImport } from './routes/storage/add'
|
||||||
import { Route as StorageIdRouteImport } from './routes/storage/$id'
|
import { Route as StorageIdRouteImport } from './routes/storage/$id'
|
||||||
import { Route as StockAddRouteImport } from './routes/stock/add'
|
import { Route as StockAddRouteImport } from './routes/stock/add'
|
||||||
import { Route as StockIdRouteImport } from './routes/stock/$id'
|
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 ProductsAddRouteImport } from './routes/products/add'
|
||||||
import { Route as ProductsIdRouteImport } from './routes/products/$id'
|
import { Route as ProductsIdRouteImport } from './routes/products/$id'
|
||||||
import { Route as DistributorsAddRouteImport } from './routes/distributors/add'
|
import { Route as DistributorsAddRouteImport } from './routes/distributors/add'
|
||||||
|
|
@ -92,6 +95,11 @@ const StockIndexRoute = StockIndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => StockRoute,
|
getParentRoute: () => StockRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const StaffIndexRoute = StaffIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => StaffRoute,
|
||||||
|
} as any)
|
||||||
const ProductsIndexRoute = ProductsIndexRouteImport.update({
|
const ProductsIndexRoute = ProductsIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|
@ -122,6 +130,16 @@ const StockIdRoute = StockIdRouteImport.update({
|
||||||
path: '/$id',
|
path: '/$id',
|
||||||
getParentRoute: () => StockRoute,
|
getParentRoute: () => StockRoute,
|
||||||
} as any)
|
} 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({
|
const ProductsAddRoute = ProductsAddRouteImport.update({
|
||||||
id: '/add',
|
id: '/add',
|
||||||
path: '/add',
|
path: '/add',
|
||||||
|
|
@ -151,19 +169,22 @@ export interface FileRoutesByFullPath {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/products': typeof ProductsRouteWithChildren
|
'/products': typeof ProductsRouteWithChildren
|
||||||
'/profile': typeof ProfileRoute
|
'/profile': typeof ProfileRoute
|
||||||
'/staff': typeof StaffRoute
|
'/staff': typeof StaffRouteWithChildren
|
||||||
'/stock': typeof StockRouteWithChildren
|
'/stock': typeof StockRouteWithChildren
|
||||||
'/storage': typeof StorageRouteWithChildren
|
'/storage': typeof StorageRouteWithChildren
|
||||||
'/distributors/$id': typeof DistributorsIdRoute
|
'/distributors/$id': typeof DistributorsIdRoute
|
||||||
'/distributors/add': typeof DistributorsAddRoute
|
'/distributors/add': typeof DistributorsAddRoute
|
||||||
'/products/$id': typeof ProductsIdRoute
|
'/products/$id': typeof ProductsIdRoute
|
||||||
'/products/add': typeof ProductsAddRoute
|
'/products/add': typeof ProductsAddRoute
|
||||||
|
'/staff/$id': typeof StaffIdRoute
|
||||||
|
'/staff/add': typeof StaffAddRoute
|
||||||
'/stock/$id': typeof StockIdRoute
|
'/stock/$id': typeof StockIdRoute
|
||||||
'/stock/add': typeof StockAddRoute
|
'/stock/add': typeof StockAddRoute
|
||||||
'/storage/$id': typeof StorageIdRoute
|
'/storage/$id': typeof StorageIdRoute
|
||||||
'/storage/add': typeof StorageAddRoute
|
'/storage/add': typeof StorageAddRoute
|
||||||
'/distributors/': typeof DistributorsIndexRoute
|
'/distributors/': typeof DistributorsIndexRoute
|
||||||
'/products/': typeof ProductsIndexRoute
|
'/products/': typeof ProductsIndexRoute
|
||||||
|
'/staff/': typeof StaffIndexRoute
|
||||||
'/stock/': typeof StockIndexRoute
|
'/stock/': typeof StockIndexRoute
|
||||||
'/storage/': typeof StorageIndexRoute
|
'/storage/': typeof StorageIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -173,17 +194,19 @@ export interface FileRoutesByTo {
|
||||||
'/customers': typeof CustomersRoute
|
'/customers': typeof CustomersRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/profile': typeof ProfileRoute
|
'/profile': typeof ProfileRoute
|
||||||
'/staff': typeof StaffRoute
|
|
||||||
'/distributors/$id': typeof DistributorsIdRoute
|
'/distributors/$id': typeof DistributorsIdRoute
|
||||||
'/distributors/add': typeof DistributorsAddRoute
|
'/distributors/add': typeof DistributorsAddRoute
|
||||||
'/products/$id': typeof ProductsIdRoute
|
'/products/$id': typeof ProductsIdRoute
|
||||||
'/products/add': typeof ProductsAddRoute
|
'/products/add': typeof ProductsAddRoute
|
||||||
|
'/staff/$id': typeof StaffIdRoute
|
||||||
|
'/staff/add': typeof StaffAddRoute
|
||||||
'/stock/$id': typeof StockIdRoute
|
'/stock/$id': typeof StockIdRoute
|
||||||
'/stock/add': typeof StockAddRoute
|
'/stock/add': typeof StockAddRoute
|
||||||
'/storage/$id': typeof StorageIdRoute
|
'/storage/$id': typeof StorageIdRoute
|
||||||
'/storage/add': typeof StorageAddRoute
|
'/storage/add': typeof StorageAddRoute
|
||||||
'/distributors': typeof DistributorsIndexRoute
|
'/distributors': typeof DistributorsIndexRoute
|
||||||
'/products': typeof ProductsIndexRoute
|
'/products': typeof ProductsIndexRoute
|
||||||
|
'/staff': typeof StaffIndexRoute
|
||||||
'/stock': typeof StockIndexRoute
|
'/stock': typeof StockIndexRoute
|
||||||
'/storage': typeof StorageIndexRoute
|
'/storage': typeof StorageIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -196,19 +219,22 @@ export interface FileRoutesById {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/products': typeof ProductsRouteWithChildren
|
'/products': typeof ProductsRouteWithChildren
|
||||||
'/profile': typeof ProfileRoute
|
'/profile': typeof ProfileRoute
|
||||||
'/staff': typeof StaffRoute
|
'/staff': typeof StaffRouteWithChildren
|
||||||
'/stock': typeof StockRouteWithChildren
|
'/stock': typeof StockRouteWithChildren
|
||||||
'/storage': typeof StorageRouteWithChildren
|
'/storage': typeof StorageRouteWithChildren
|
||||||
'/distributors/$id': typeof DistributorsIdRoute
|
'/distributors/$id': typeof DistributorsIdRoute
|
||||||
'/distributors/add': typeof DistributorsAddRoute
|
'/distributors/add': typeof DistributorsAddRoute
|
||||||
'/products/$id': typeof ProductsIdRoute
|
'/products/$id': typeof ProductsIdRoute
|
||||||
'/products/add': typeof ProductsAddRoute
|
'/products/add': typeof ProductsAddRoute
|
||||||
|
'/staff/$id': typeof StaffIdRoute
|
||||||
|
'/staff/add': typeof StaffAddRoute
|
||||||
'/stock/$id': typeof StockIdRoute
|
'/stock/$id': typeof StockIdRoute
|
||||||
'/stock/add': typeof StockAddRoute
|
'/stock/add': typeof StockAddRoute
|
||||||
'/storage/$id': typeof StorageIdRoute
|
'/storage/$id': typeof StorageIdRoute
|
||||||
'/storage/add': typeof StorageAddRoute
|
'/storage/add': typeof StorageAddRoute
|
||||||
'/distributors/': typeof DistributorsIndexRoute
|
'/distributors/': typeof DistributorsIndexRoute
|
||||||
'/products/': typeof ProductsIndexRoute
|
'/products/': typeof ProductsIndexRoute
|
||||||
|
'/staff/': typeof StaffIndexRoute
|
||||||
'/stock/': typeof StockIndexRoute
|
'/stock/': typeof StockIndexRoute
|
||||||
'/storage/': typeof StorageIndexRoute
|
'/storage/': typeof StorageIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -229,12 +255,15 @@ export interface FileRouteTypes {
|
||||||
| '/distributors/add'
|
| '/distributors/add'
|
||||||
| '/products/$id'
|
| '/products/$id'
|
||||||
| '/products/add'
|
| '/products/add'
|
||||||
|
| '/staff/$id'
|
||||||
|
| '/staff/add'
|
||||||
| '/stock/$id'
|
| '/stock/$id'
|
||||||
| '/stock/add'
|
| '/stock/add'
|
||||||
| '/storage/$id'
|
| '/storage/$id'
|
||||||
| '/storage/add'
|
| '/storage/add'
|
||||||
| '/distributors/'
|
| '/distributors/'
|
||||||
| '/products/'
|
| '/products/'
|
||||||
|
| '/staff/'
|
||||||
| '/stock/'
|
| '/stock/'
|
||||||
| '/storage/'
|
| '/storage/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
|
@ -244,17 +273,19 @@ export interface FileRouteTypes {
|
||||||
| '/customers'
|
| '/customers'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
| '/staff'
|
|
||||||
| '/distributors/$id'
|
| '/distributors/$id'
|
||||||
| '/distributors/add'
|
| '/distributors/add'
|
||||||
| '/products/$id'
|
| '/products/$id'
|
||||||
| '/products/add'
|
| '/products/add'
|
||||||
|
| '/staff/$id'
|
||||||
|
| '/staff/add'
|
||||||
| '/stock/$id'
|
| '/stock/$id'
|
||||||
| '/stock/add'
|
| '/stock/add'
|
||||||
| '/storage/$id'
|
| '/storage/$id'
|
||||||
| '/storage/add'
|
| '/storage/add'
|
||||||
| '/distributors'
|
| '/distributors'
|
||||||
| '/products'
|
| '/products'
|
||||||
|
| '/staff'
|
||||||
| '/stock'
|
| '/stock'
|
||||||
| '/storage'
|
| '/storage'
|
||||||
id:
|
id:
|
||||||
|
|
@ -273,12 +304,15 @@ export interface FileRouteTypes {
|
||||||
| '/distributors/add'
|
| '/distributors/add'
|
||||||
| '/products/$id'
|
| '/products/$id'
|
||||||
| '/products/add'
|
| '/products/add'
|
||||||
|
| '/staff/$id'
|
||||||
|
| '/staff/add'
|
||||||
| '/stock/$id'
|
| '/stock/$id'
|
||||||
| '/stock/add'
|
| '/stock/add'
|
||||||
| '/storage/$id'
|
| '/storage/$id'
|
||||||
| '/storage/add'
|
| '/storage/add'
|
||||||
| '/distributors/'
|
| '/distributors/'
|
||||||
| '/products/'
|
| '/products/'
|
||||||
|
| '/staff/'
|
||||||
| '/stock/'
|
| '/stock/'
|
||||||
| '/storage/'
|
| '/storage/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
|
|
@ -291,7 +325,7 @@ export interface RootRouteChildren {
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
ProductsRoute: typeof ProductsRouteWithChildren
|
ProductsRoute: typeof ProductsRouteWithChildren
|
||||||
ProfileRoute: typeof ProfileRoute
|
ProfileRoute: typeof ProfileRoute
|
||||||
StaffRoute: typeof StaffRoute
|
StaffRoute: typeof StaffRouteWithChildren
|
||||||
StockRoute: typeof StockRouteWithChildren
|
StockRoute: typeof StockRouteWithChildren
|
||||||
StorageRoute: typeof StorageRouteWithChildren
|
StorageRoute: typeof StorageRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
@ -382,6 +416,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof StockIndexRouteImport
|
preLoaderRoute: typeof StockIndexRouteImport
|
||||||
parentRoute: typeof StockRoute
|
parentRoute: typeof StockRoute
|
||||||
}
|
}
|
||||||
|
'/staff/': {
|
||||||
|
id: '/staff/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/staff/'
|
||||||
|
preLoaderRoute: typeof StaffIndexRouteImport
|
||||||
|
parentRoute: typeof StaffRoute
|
||||||
|
}
|
||||||
'/products/': {
|
'/products/': {
|
||||||
id: '/products/'
|
id: '/products/'
|
||||||
path: '/'
|
path: '/'
|
||||||
|
|
@ -424,6 +465,20 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof StockIdRouteImport
|
preLoaderRoute: typeof StockIdRouteImport
|
||||||
parentRoute: typeof StockRoute
|
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': {
|
'/products/add': {
|
||||||
id: '/products/add'
|
id: '/products/add'
|
||||||
path: '/add'
|
path: '/add'
|
||||||
|
|
@ -487,6 +542,20 @@ const ProductsRouteWithChildren = ProductsRoute._addFileChildren(
|
||||||
ProductsRouteChildren,
|
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 {
|
interface StockRouteChildren {
|
||||||
StockIdRoute: typeof StockIdRoute
|
StockIdRoute: typeof StockIdRoute
|
||||||
StockAddRoute: typeof StockAddRoute
|
StockAddRoute: typeof StockAddRoute
|
||||||
|
|
@ -524,7 +593,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
ProductsRoute: ProductsRouteWithChildren,
|
ProductsRoute: ProductsRouteWithChildren,
|
||||||
ProfileRoute: ProfileRoute,
|
ProfileRoute: ProfileRoute,
|
||||||
StaffRoute: StaffRoute,
|
StaffRoute: StaffRouteWithChildren,
|
||||||
StockRoute: StockRouteWithChildren,
|
StockRoute: StockRouteWithChildren,
|
||||||
StorageRoute: StorageRouteWithChildren,
|
StorageRoute: StorageRouteWithChildren,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
useListDistributors,
|
useListDistributors,
|
||||||
trpc,
|
trpc,
|
||||||
} from "shared-react";
|
} 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";
|
import { CreateProductInput } from "@repo/shared";
|
||||||
|
|
||||||
const formSchema = CreateProductInput.extend({
|
const formSchema = CreateProductInput.extend({
|
||||||
|
|
@ -206,14 +206,10 @@ function AddProductPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">Category</label>
|
<label className="block text-sm font-medium text-slate-900 mb-1.5">Category</label>
|
||||||
<select
|
<Select
|
||||||
{...register("category")}
|
{...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"
|
options={CATEGORIES.map((c) => ({ value: c, label: c }))}
|
||||||
>
|
/>
|
||||||
{CATEGORIES.map((c) => (
|
|
||||||
<option key={c} value={c}>{c}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showUnitsPerStrip && (
|
{showUnitsPerStrip && (
|
||||||
|
|
@ -236,29 +232,21 @@ function AddProductPage() {
|
||||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">
|
<label className="block text-sm font-medium text-slate-900 mb-1.5">
|
||||||
Product Unit <span className="text-red-600">*</span>
|
Product Unit <span className="text-red-600">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select
|
||||||
{...register("unit_name")}
|
{...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"}`}
|
options={(unitList ?? []).map((u) => ({ value: u.name, label: u.name }))}
|
||||||
>
|
placeholder="Select..."
|
||||||
<option value="">Select...</option>
|
/>
|
||||||
{(unitList ?? []).map((u) => (
|
|
||||||
<option key={u.id} value={u.name}>{u.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.unit_name && <p className="text-sm text-red-600 mt-1">{errors.unit_name.message}</p>}
|
{errors.unit_name && <p className="text-sm text-red-600 mt-1">{errors.unit_name.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={showUnitsPerStrip ? "" : "col-span-full"}>
|
<div className={showUnitsPerStrip ? "" : "col-span-full"}>
|
||||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">Distributor</label>
|
<label className="block text-sm font-medium text-slate-900 mb-1.5">Distributor</label>
|
||||||
<select
|
<Select
|
||||||
{...register("distributor_id")}
|
{...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"
|
options={(distributorList ?? []).map((d) => ({ value: String(d.id), label: d.agency }))}
|
||||||
>
|
placeholder="None"
|
||||||
<option value="">None</option>
|
/>
|
||||||
{(distributorList ?? []).map((d) => (
|
|
||||||
<option key={d.id} value={d.id}>{d.agency}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Composition */}
|
{/* Composition */}
|
||||||
|
|
@ -294,15 +282,11 @@ function AddProductPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="w-28">
|
<div className="w-28">
|
||||||
<label className="block text-xs font-medium text-slate-600 mb-1">Unit</label>
|
<label className="block text-xs font-medium text-slate-600 mb-1">Unit</label>
|
||||||
<select
|
<Select
|
||||||
{...register(`compositions.${index}.unit_name`)}
|
{...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"}`}
|
options={(unitList ?? []).map((u) => ({ value: u.name, label: u.name }))}
|
||||||
>
|
placeholder="Select..."
|
||||||
<option value="">Select...</option>
|
/>
|
||||||
{(unitList ?? []).map((u) => (
|
|
||||||
<option key={u.id} value={u.name}>{u.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createFileRoute("/staff")({
|
export const Route = createFileRoute("/staff")({
|
||||||
component: StaffPage,
|
component: () => <Outlet />,
|
||||||
staticData: {
|
|
||||||
title: "Staff",
|
|
||||||
subtitle: "Staff management with roles & permissions",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function StaffPage() {
|
|
||||||
return <div>Staff</div>;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
140
apps/pharmanager/src/routes/staff/$id.tsx
Normal file
140
apps/pharmanager/src/routes/staff/$id.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
apps/pharmanager/src/routes/staff/add.tsx
Normal file
192
apps/pharmanager/src/routes/staff/add.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
apps/pharmanager/src/routes/staff/index.tsx
Normal file
171
apps/pharmanager/src/routes/staff/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
useListDistributors,
|
useListDistributors,
|
||||||
trpc,
|
trpc,
|
||||||
} from "shared-react";
|
} 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";
|
import { CreateStockBatchInput } from "@repo/shared";
|
||||||
|
|
||||||
const formSchema = CreateStockBatchInput.extend({
|
const formSchema = CreateStockBatchInput.extend({
|
||||||
|
|
@ -193,28 +193,20 @@ function AddStockPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">Rack</label>
|
<label className="block text-sm font-medium text-slate-900 mb-1.5">Rack</label>
|
||||||
<select
|
<Select
|
||||||
{...register("rack_id")}
|
{...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"
|
options={(racks ?? []).map((r) => ({ value: String(r.id), label: r.name }))}
|
||||||
>
|
placeholder="— Select a rack —"
|
||||||
<option value="">— Select a rack —</option>
|
/>
|
||||||
{(racks ?? []).map((r) => (
|
|
||||||
<option key={r.id} value={r.id}>{r.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">Distributor</label>
|
<label className="block text-sm font-medium text-slate-900 mb-1.5">Distributor</label>
|
||||||
<select
|
<Select
|
||||||
{...register("distributor_id")}
|
{...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"
|
options={(distributorList ?? []).map((d) => ({ value: String(d.id), label: d.agency }))}
|
||||||
>
|
placeholder="— Select a distributor —"
|
||||||
<option value="">— Select a distributor —</option>
|
/>
|
||||||
{(distributorList ?? []).map((d) => (
|
|
||||||
<option key={d.id} value={d.id}>{d.agency}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-full">
|
<div className="col-span-full">
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
1026
packages/data-manager-sqlite/drizzle/meta/0006_snapshot.json
Normal file
1026
packages/data-manager-sqlite/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,13 @@
|
||||||
"when": 1779546674621,
|
"when": 1779546674621,
|
||||||
"tag": "0005_yielding_silver_fox",
|
"tag": "0005_yielding_silver_fox",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1779551911536,
|
||||||
|
"tag": "0006_nappy_sir_ram",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ export type EnterpriseStaff = {
|
||||||
|
|
||||||
export type EnterpriseStaffRepo = {
|
export type EnterpriseStaffRepo = {
|
||||||
getByStaffId: (staffId: number) => EnterpriseStaff | null
|
getByStaffId: (staffId: number) => EnterpriseStaff | null
|
||||||
|
listByEnterprise: (enterpriseId: number) => EnterpriseStaff[]
|
||||||
create: (input: { staff_id: number; enterprise_id: 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
|
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) {
|
create(input) {
|
||||||
const created = db.insert(enterpriseStaff).values({
|
const created = db.insert(enterpriseStaff).values({
|
||||||
staffId: input.staff_id,
|
staffId: input.staff_id,
|
||||||
|
|
|
||||||
|
|
@ -62,3 +62,18 @@ export {
|
||||||
type EnterpriseStaff,
|
type EnterpriseStaff,
|
||||||
type EnterpriseStaffRepo,
|
type EnterpriseStaffRepo,
|
||||||
} from './enterpriseStaff'
|
} 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'
|
||||||
|
|
|
||||||
48
packages/data-manager-sqlite/src/permissions.ts
Normal file
48
packages/data-manager-sqlite/src/permissions.ts
Normal 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 }
|
||||||
|
}
|
||||||
50
packages/data-manager-sqlite/src/roles.ts
Normal file
50
packages/data-manager-sqlite/src/roles.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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 { staff } from './staff'
|
||||||
import { enterprises } from './enterprises'
|
import { enterprises } from './enterprises'
|
||||||
|
|
||||||
|
|
@ -6,4 +6,6 @@ export const enterpriseStaff = sqliteTable('enterprise_staff', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
staffId: integer('staff_id').notNull().references(() => staff.id),
|
staffId: integer('staff_id').notNull().references(() => staff.id),
|
||||||
enterpriseId: integer('enterprise_id').notNull().references(() => enterprises.id),
|
enterpriseId: integer('enterprise_id').notNull().references(() => enterprises.id),
|
||||||
})
|
}, (table) => ({
|
||||||
|
uniquePair: unique().on(table.staffId, table.enterpriseId),
|
||||||
|
}))
|
||||||
|
|
|
||||||
|
|
@ -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 { roles } from './roles'
|
||||||
import { permissions } from './permissions'
|
import { permissions } from './permissions'
|
||||||
|
|
||||||
|
|
@ -6,4 +6,6 @@ export const rolePermissions = sqliteTable('role_permissions', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
roleId: integer('role_id').notNull().references(() => roles.id),
|
roleId: integer('role_id').notNull().references(() => roles.id),
|
||||||
permissionId: integer('permission_id').notNull().references(() => permissions.id),
|
permissionId: integer('permission_id').notNull().references(() => permissions.id),
|
||||||
})
|
}, (table) => ({
|
||||||
|
uniquePair: unique().on(table.roleId, table.permissionId),
|
||||||
|
}))
|
||||||
|
|
|
||||||
|
|
@ -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 { staff } from './staff'
|
||||||
import { roles } from './roles'
|
import { roles } from './roles'
|
||||||
|
|
||||||
|
|
@ -6,4 +6,6 @@ export const staffRoles = sqliteTable('staff_roles', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
staffId: integer('staff_id').notNull().references(() => staff.id),
|
staffId: integer('staff_id').notNull().references(() => staff.id),
|
||||||
roleId: integer('role_id').notNull().references(() => roles.id),
|
roleId: integer('role_id').notNull().references(() => roles.id),
|
||||||
})
|
}, (table) => ({
|
||||||
|
uniquePair: unique().on(table.staffId, table.roleId),
|
||||||
|
}))
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
import { db } from './db-instance'
|
import { db } from './db-instance'
|
||||||
import { staff } from './schema/staff'
|
import { staff } from './schema/staff'
|
||||||
|
import { staffRoles } from './schema/staffRoles'
|
||||||
|
import { roles } from './schema/roles'
|
||||||
|
import { enterpriseStaff } from './schema/enterpriseStaff'
|
||||||
|
|
||||||
export type Staff = {
|
export type Staff = {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -13,16 +16,31 @@ export type Staff = {
|
||||||
added_on: string
|
added_on: string
|
||||||
password: string
|
password: string
|
||||||
is_password_reset_needed: boolean
|
is_password_reset_needed: boolean
|
||||||
|
roles: { id: number; name: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StaffRepo = {
|
export type StaffRepo = {
|
||||||
getStaffById: (id: number) => Promise<Staff | null>
|
getStaffById: (id: number) => Promise<Staff | null>
|
||||||
getStaffByUsername: (username: string) => 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>
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
|
@ -39,15 +57,29 @@ export function createStaffRepo(): { repo: StaffRepo } {
|
||||||
const repo: StaffRepo = {
|
const repo: StaffRepo = {
|
||||||
getStaffById(id) {
|
getStaffById(id) {
|
||||||
const row = db.select().from(staff).where(eq(staff.id, id)).get()
|
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) {
|
getStaffByUsername(username) {
|
||||||
const row = db.select().from(staff).where(eq(staff.username, username)).get()
|
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 hashed = bcrypt.hashSync(input.password, 10)
|
||||||
const created = db.insert(staff).values({
|
const created = db.insert(staff).values({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
|
@ -58,7 +90,56 @@ export function createStaffRepo(): { repo: StaffRepo } {
|
||||||
password: hashed,
|
password: hashed,
|
||||||
isPasswordResetNeeded: input.is_password_reset_needed ?? true,
|
isPasswordResetNeeded: input.is_password_reset_needed ?? true,
|
||||||
}).returning().get()
|
}).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) {
|
async verifyPassword(s, password) {
|
||||||
|
|
|
||||||
48
packages/data-manager-sqlite/src/staffRoles.ts
Normal file
48
packages/data-manager-sqlite/src/staffRoles.ts
Normal 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 }
|
||||||
|
}
|
||||||
5
packages/shared-react/src/hooks/roles.ts
Normal file
5
packages/shared-react/src/hooks/roles.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { trpc } from "../trpc";
|
||||||
|
|
||||||
|
export function useListRoles() {
|
||||||
|
return trpc.roles.list.useQuery();
|
||||||
|
}
|
||||||
21
packages/shared-react/src/hooks/staffManagement.ts
Normal file
21
packages/shared-react/src/hooks/staffManagement.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ export * from './hooks/products'
|
||||||
export * from './hooks/drugInfo'
|
export * from './hooks/drugInfo'
|
||||||
export * from './hooks/units'
|
export * from './hooks/units'
|
||||||
export * from './hooks/stockBatches'
|
export * from './hooks/stockBatches'
|
||||||
|
export * from './hooks/staffManagement'
|
||||||
|
export * from './hooks/roles'
|
||||||
export { trpc } from './trpc'
|
export { trpc } from './trpc'
|
||||||
export { useAuthStore, useWhoAmI, useLogin } from './auth'
|
export { useAuthStore, useWhoAmI, useLogin } from './auth'
|
||||||
export type { AuthStaff, AuthEnterprise } from './auth'
|
export type { AuthStaff, AuthEnterprise } from './auth'
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue