Compare commits
No commits in common. "f733c1e36b8d2618c7f0a4b3ce2c1d55da1cf007" and "7c9fb6b060514b76404687da675bd53083c42ada" have entirely different histories.
f733c1e36b
...
7c9fb6b060
47 changed files with 349 additions and 1928 deletions
|
|
@ -1,5 +0,0 @@
|
||||||
1. don't try to compile or run or build any project until explicitly asked for
|
|
||||||
2. Don't generate or run migrations. They are exclusively handled by the user
|
|
||||||
3. The projects packages/data-manager-sqlite and the apps/backend should be edge compatible
|
|
||||||
don't use any node apis in it. They will be deployed to a serverless platform in future
|
|
||||||
|
|
||||||
Binary file not shown.
|
|
@ -1,3 +1,5 @@
|
||||||
import { DataManager } from "./data-manager";
|
import { env } from './env-exporter'
|
||||||
|
import { DataManager } from './data-manager'
|
||||||
|
|
||||||
export const dataManager = new DataManager();
|
// Singleton container for all backend data access.
|
||||||
|
export const dataManager = new DataManager(env.SQLITE_PATH || 'dev.db')
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,28 @@
|
||||||
import {
|
import {
|
||||||
createStorageSpacesRepo,
|
createStorageSpacesRepo,
|
||||||
|
runMigrations,
|
||||||
type StorageSpacesRepo,
|
type StorageSpacesRepo,
|
||||||
} from "data-manager-sqlite";
|
} from 'data-manager-sqlite'
|
||||||
|
|
||||||
|
import type { StorageSpacesService } from '../trpc/router'
|
||||||
|
|
||||||
export class DataManager {
|
export class DataManager {
|
||||||
readonly storageSpaces: StorageSpacesRepo;
|
readonly storageSpaces: StorageSpacesService
|
||||||
|
readonly close: () => void
|
||||||
|
|
||||||
constructor() {
|
constructor(sqlitePath: string) {
|
||||||
const { repo } = createStorageSpacesRepo();
|
const { repo, sqlite, close } = createStorageSpacesRepo({ sqlitePath })
|
||||||
this.storageSpaces = repo;
|
runMigrations(sqlite)
|
||||||
|
|
||||||
|
this.close = close
|
||||||
|
|
||||||
|
// Keep the service surface stable for the router.
|
||||||
|
this.storageSpaces = {
|
||||||
|
getStorageSpaces: () => repo.getStorageSpaces(),
|
||||||
|
getStorageSpaceById: (id) => repo.getStorageSpaceById(id),
|
||||||
|
createStorageSpace: (input) => repo.createStorageSpace(input),
|
||||||
|
updateStorageSpace: (id, patch) => repo.updateStorageSpace(id, patch),
|
||||||
|
deleteStorageSpace: (id) => repo.deleteStorageSpace(id),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export const env = {
|
export const env = {
|
||||||
PORT: process.env.PORT || "4004",
|
PORT: process.env.PORT || '4004',
|
||||||
} as const;
|
SQLITE_PATH: process.env.SQLITE_PATH,
|
||||||
|
} as const
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
import { initTRPC } from "@trpc/server";
|
|
||||||
|
|
||||||
const t = initTRPC.create();
|
|
||||||
export { t };
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
import {t} from '../../init'
|
|
||||||
import { dataManager } from "../../../lib/data-manager-instance";
|
|
||||||
|
|
||||||
export const StorageSpaceSchema = z.object({
|
|
||||||
id: z.number().int(),
|
|
||||||
name: z.string(),
|
|
||||||
description: z.string().nullable(),
|
|
||||||
aliases: z.array(z.string()),
|
|
||||||
image_urls: z.array(z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { shape } = StorageSpaceSchema;
|
|
||||||
|
|
||||||
export const CreateStorageInput = z.object({
|
|
||||||
name: shape.name.min(1),
|
|
||||||
description: shape.description.optional(),
|
|
||||||
aliases: shape.aliases.default([]),
|
|
||||||
image_urls: shape.image_urls.default([]),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UpdateStorageInput = z
|
|
||||||
.object({ id: z.number().int() })
|
|
||||||
.merge(CreateStorageInput.partial());
|
|
||||||
|
|
||||||
export type StorageSpace = z.infer<typeof StorageSpaceSchema>;
|
|
||||||
|
|
||||||
export const storageRouter = t.router({
|
|
||||||
list: t.procedure
|
|
||||||
.output(z.array(StorageSpaceSchema))
|
|
||||||
.query(() => dataManager.storageSpaces.getStorageSpaces()),
|
|
||||||
|
|
||||||
byId: t.procedure
|
|
||||||
.input(z.object({ id: z.number().int() }))
|
|
||||||
.output(StorageSpaceSchema.nullable())
|
|
||||||
.query(({ input }) =>
|
|
||||||
dataManager.storageSpaces.getStorageSpaceById(input.id),
|
|
||||||
),
|
|
||||||
|
|
||||||
create: t.procedure
|
|
||||||
.input(CreateStorageInput)
|
|
||||||
.output(StorageSpaceSchema)
|
|
||||||
.mutation(({ input }) =>
|
|
||||||
dataManager.storageSpaces.createStorageSpace(input),
|
|
||||||
),
|
|
||||||
|
|
||||||
update: t.procedure
|
|
||||||
.input(UpdateStorageInput)
|
|
||||||
.output(StorageSpaceSchema.nullable())
|
|
||||||
.mutation(({ input }) => {
|
|
||||||
const { id, ...patch } = input;
|
|
||||||
return dataManager.storageSpaces.updateStorageSpace(id, patch);
|
|
||||||
}),
|
|
||||||
|
|
||||||
remove: t.procedure
|
|
||||||
.input(z.object({ id: z.number().int() }))
|
|
||||||
.output(z.object({ ok: z.boolean() }))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
const ok =
|
|
||||||
await dataManager.storageSpaces.deleteStorageSpace(input.id);
|
|
||||||
return { ok };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -1,8 +1,92 @@
|
||||||
import { t } from "./init";
|
import { initTRPC } from '@trpc/server'
|
||||||
import { storageRouter } from "./pharmanager/v1/storage";
|
import { z } from 'zod'
|
||||||
|
import { dataManager } from '../lib/data-manager-instance'
|
||||||
|
|
||||||
|
export const StorageSpaceSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
image_urls: z.array(z.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type StorageSpace = z.infer<typeof StorageSpaceSchema>
|
||||||
|
|
||||||
|
export type StorageSpacesService = {
|
||||||
|
getStorageSpaces: () => Promise<StorageSpace[]>
|
||||||
|
getStorageSpaceById: (id: number) => Promise<StorageSpace | null>
|
||||||
|
createStorageSpace: (input: {
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
image_urls: string[]
|
||||||
|
}) => Promise<StorageSpace>
|
||||||
|
updateStorageSpace: (
|
||||||
|
id: number,
|
||||||
|
patch: {
|
||||||
|
name?: string
|
||||||
|
description?: string | null
|
||||||
|
image_urls?: string[]
|
||||||
|
},
|
||||||
|
) => Promise<StorageSpace | null>
|
||||||
|
deleteStorageSpace: (id: number) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = initTRPC.create()
|
||||||
|
|
||||||
|
const storageSpacesRouter = t.router({
|
||||||
|
getStorageSpaces: t.procedure.output(z.array(StorageSpaceSchema)).query(() => {
|
||||||
|
return dataManager.storageSpaces.getStorageSpaces()
|
||||||
|
}),
|
||||||
|
|
||||||
|
getStorageSpaceById: t.procedure
|
||||||
|
.input(z.object({ id: z.number().int() }))
|
||||||
|
.output(StorageSpaceSchema.nullable())
|
||||||
|
.query(({ input }) => {
|
||||||
|
return dataManager.storageSpaces.getStorageSpaceById(input.id)
|
||||||
|
}),
|
||||||
|
|
||||||
|
addStorageSpace: t.procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
image_urls: z.array(z.string()).default([]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(StorageSpaceSchema)
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
return dataManager.storageSpaces.createStorageSpace({
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
image_urls: input.image_urls,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateStorageSpace: t.procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
image_urls: z.array(z.string()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(StorageSpaceSchema.nullable())
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const { id, ...patch } = input
|
||||||
|
return dataManager.storageSpaces.updateStorageSpace(id, patch)
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteStorageSpace: t.procedure
|
||||||
|
.input(z.object({ id: z.number().int() }))
|
||||||
|
.output(z.object({ ok: z.boolean() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const ok = await dataManager.storageSpaces.deleteStorageSpace(input.id)
|
||||||
|
return { ok }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
export const appRouter = t.router({
|
export const appRouter = t.router({
|
||||||
storage: storageRouter,
|
storageSpaces: storageSpacesRouter,
|
||||||
});
|
})
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@
|
||||||
"**/index.html",
|
"**/index.html",
|
||||||
"**/vite.config.ts",
|
"**/vite.config.ts",
|
||||||
"!**/src/routeTree.gen.ts",
|
"!**/src/routeTree.gen.ts",
|
||||||
"!**/src/styles.css",
|
"!**/src/styles.css"
|
||||||
"!**/inspiration"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|
@ -34,3 +33,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,16 @@
|
||||||
"check": "biome check"
|
"check": "biome check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.4.0",
|
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-devtools": "latest",
|
"@tanstack/react-devtools": "latest",
|
||||||
"@tanstack/react-router": "latest",
|
"@tanstack/react-router": "latest",
|
||||||
"@tanstack/react-router-devtools": "latest",
|
"@tanstack/react-router-devtools": "latest",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
|
||||||
"@tanstack/router-plugin": "^1.132.0",
|
"@tanstack/router-plugin": "^1.132.0",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.76.1",
|
|
||||||
"shared-react": "*",
|
"shared-react": "*",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18"
|
||||||
"zod": "^4.4.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.5",
|
"@biomejs/biome": "2.4.5",
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { useState, useEffect, type ReactNode } from "react";
|
|
||||||
import { useMatches } from "@tanstack/react-router";
|
|
||||||
import { Sidebar } from "#/components/Sidebar";
|
|
||||||
import { Header } from "#/components/Header";
|
|
||||||
|
|
||||||
interface RouteStaticData {
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppLayoutProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppLayout({ children }: AppLayoutProps) {
|
|
||||||
const [minimized, setMinimized] = useState(false);
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
|
|
||||||
const matches = useMatches();
|
|
||||||
const currentMatch = matches[matches.length - 1];
|
|
||||||
const staticData = currentMatch?.staticData as RouteStaticData | undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleResize() {
|
|
||||||
if (window.innerWidth > 768) {
|
|
||||||
setMobileOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mobileOpen && window.innerWidth <= 768) {
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [mobileOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen">
|
|
||||||
<Sidebar
|
|
||||||
minimized={minimized}
|
|
||||||
mobileOpen={mobileOpen}
|
|
||||||
onToggleMinimized={() => setMinimized((prev) => !prev)}
|
|
||||||
onCloseMobile={() => setMobileOpen(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col min-w-0">
|
|
||||||
<Header
|
|
||||||
pageTitle={staticData?.title ?? "Dashboard"}
|
|
||||||
pageSubtitle={
|
|
||||||
staticData?.subtitle ?? "Pharmacy overview & inventory at a glance"
|
|
||||||
}
|
|
||||||
onMobileMenuToggle={() => setMobileOpen((prev) => !prev)}
|
|
||||||
/>
|
|
||||||
<main className="flex-1 overflow-y-auto p-6 bg-slate-100">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
import { useMemo, type ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
useReactTable,
|
|
||||||
getCoreRowModel,
|
|
||||||
flexRender,
|
|
||||||
type ColumnDef,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import { cn } from "#/lib/cn";
|
|
||||||
|
|
||||||
export interface GridTableColumn<T> {
|
|
||||||
id: string;
|
|
||||||
header: string;
|
|
||||||
size?: number;
|
|
||||||
cell: (props: { row: T }) => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GridTableProps<T> {
|
|
||||||
columns: GridTableColumn<T>[];
|
|
||||||
data: T[];
|
|
||||||
emptyState?: ReactNode;
|
|
||||||
onRowClick?: (row: T) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GridTable<T>({
|
|
||||||
columns,
|
|
||||||
data,
|
|
||||||
emptyState,
|
|
||||||
onRowClick,
|
|
||||||
}: GridTableProps<T>) {
|
|
||||||
const tableColumns = useMemo<ColumnDef<T>[]>(
|
|
||||||
() =>
|
|
||||||
columns.map((col) => ({
|
|
||||||
id: col.id,
|
|
||||||
header: () => col.header,
|
|
||||||
size: col.size,
|
|
||||||
cell: ({ row }) => col.cell({ row: row.original }),
|
|
||||||
})),
|
|
||||||
[columns],
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data,
|
|
||||||
columns: tableColumns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.length === 0 && emptyState) {
|
|
||||||
return (
|
|
||||||
<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)] overflow-hidden">
|
|
||||||
{emptyState}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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)] overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse text-sm">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<th
|
|
||||||
key={header.id}
|
|
||||||
style={
|
|
||||||
header.column.columnDef.size
|
|
||||||
? {
|
|
||||||
width: `${header.column.columnDef.size}px`,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className="px-4 py-3 text-left text-[11px] font-semibold text-slate-600 uppercase tracking-wider whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{table.getRowModel().rows.map((row) => {
|
|
||||||
const isLastRow =
|
|
||||||
row.index === table.getRowModel().rows.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={row.id}
|
|
||||||
onClick={
|
|
||||||
onRowClick ? () => onRowClick(row.original) : undefined
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"hover:bg-slate-50 transition-colors",
|
|
||||||
onRowClick && "cursor-pointer",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td
|
|
||||||
key={cell.id}
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-3",
|
|
||||||
!isLastRow && "border-b border-slate-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext(),
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { Menu, Bell } from "lucide-react";
|
|
||||||
import { MedicineSearch } from "#/components/MedicineSearch";
|
|
||||||
|
|
||||||
interface HeaderProps {
|
|
||||||
pageTitle: string;
|
|
||||||
pageSubtitle: string;
|
|
||||||
onMobileMenuToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header({
|
|
||||||
pageTitle,
|
|
||||||
pageSubtitle,
|
|
||||||
onMobileMenuToggle,
|
|
||||||
}: HeaderProps) {
|
|
||||||
return (
|
|
||||||
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onMobileMenuToggle}
|
|
||||||
className="md:hidden w-9 h-9 flex items-center justify-center rounded-md text-slate-600 hover:bg-slate-100"
|
|
||||||
aria-label="Open menu"
|
|
||||||
>
|
|
||||||
<Menu className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg font-semibold text-slate-900">{pageTitle}</h1>
|
|
||||||
<p className="text-[13px] text-slate-600 mt-px">{pageSubtitle}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MedicineSearch />
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative w-9 h-9 flex items-center justify-center rounded-md text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors"
|
|
||||||
aria-label="Notifications"
|
|
||||||
>
|
|
||||||
<Bell className="w-[18px] h-[18px]" />
|
|
||||||
<span className="absolute top-1 right-1 w-4 h-4 rounded-full bg-red-600 text-white text-[9px] font-bold flex items-center justify-center">
|
|
||||||
3
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
import { useState, useRef, useMemo, type KeyboardEvent } from "react";
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { cn } from "#/lib/cn";
|
|
||||||
|
|
||||||
interface Suggestion {
|
|
||||||
name: string;
|
|
||||||
brand: string;
|
|
||||||
category: string;
|
|
||||||
ingredients: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MedicineSearchProps {
|
|
||||||
onSearch?: (query: string) => void;
|
|
||||||
onSelect?: (suggestion: Suggestion) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_SUGGESTIONS: Suggestion[] = [
|
|
||||||
{
|
|
||||||
name: "Paracetamol 500mg",
|
|
||||||
brand: "Crocin",
|
|
||||||
category: "Analgesic",
|
|
||||||
ingredients: "Paracetamol",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Amoxicillin + Clavulanic Acid",
|
|
||||||
brand: "Mox",
|
|
||||||
category: "Antibiotic",
|
|
||||||
ingredients: "Amoxicillin Clavulanic Acid",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Omeprazole 20mg",
|
|
||||||
brand: "Omez",
|
|
||||||
category: "PPI",
|
|
||||||
ingredients: "Omeprazole",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Atorvastatin 10mg",
|
|
||||||
brand: "Lipitor",
|
|
||||||
category: "Statin",
|
|
||||||
ingredients: "Atorvastatin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Metformin 500mg",
|
|
||||||
brand: "Glyciphage",
|
|
||||||
category: "Antidiabetic",
|
|
||||||
ingredients: "Metformin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Cetirizine 10mg",
|
|
||||||
brand: "Zyrtec",
|
|
||||||
category: "Antihistamine",
|
|
||||||
ingredients: "Cetirizine",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Ibuprofen 400mg",
|
|
||||||
brand: "Brufen",
|
|
||||||
category: "NSAID",
|
|
||||||
ingredients: "Ibuprofen",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Azithromycin 500mg",
|
|
||||||
brand: "Azithral",
|
|
||||||
category: "Antibiotic",
|
|
||||||
ingredients: "Azithromycin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Losartan 50mg",
|
|
||||||
brand: "Losar",
|
|
||||||
category: "ARB",
|
|
||||||
ingredients: "Losartan",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Vitamin D3 1000IU",
|
|
||||||
brand: "D3",
|
|
||||||
category: "Supplement",
|
|
||||||
ingredients: "Cholecalciferol",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
|
||||||
if (!query) return <>{text}</>;
|
|
||||||
const idx = text.toLowerCase().indexOf(query);
|
|
||||||
if (idx === -1) return <>{text}</>;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{text.slice(0, idx)}
|
|
||||||
<span className="text-emerald-600 font-semibold">
|
|
||||||
{text.slice(idx, idx + query.length)}
|
|
||||||
</span>
|
|
||||||
{text.slice(idx + query.length)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MedicineSearch({ onSearch, onSelect }: MedicineSearchProps) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const filteredSuggestions = useMemo(() => {
|
|
||||||
if (!query.trim()) return [];
|
|
||||||
const q = query.toLowerCase();
|
|
||||||
return MOCK_SUGGESTIONS.filter((s) => {
|
|
||||||
const text = `${s.name} ${s.category} ${s.ingredients} ${s.brand}`;
|
|
||||||
return text.toLowerCase().includes(q);
|
|
||||||
}).slice(0, 8);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
function handleInputChange(value: string) {
|
|
||||||
setQuery(value);
|
|
||||||
setIsOpen(value.trim().length > 0);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
onSearch?.(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelect(suggestion: Suggestion) {
|
|
||||||
setQuery(suggestion.name);
|
|
||||||
setIsOpen(false);
|
|
||||||
onSelect?.(suggestion);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
setTimeout(() => setIsOpen(false), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocus() {
|
|
||||||
if (query.trim()) setIsOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
|
||||||
if (!isOpen || filteredSuggestions.length === 0) return;
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case "ArrowDown": {
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightedIndex((prev) =>
|
|
||||||
prev < filteredSuggestions.length - 1 ? prev + 1 : prev,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "ArrowUp": {
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "Enter": {
|
|
||||||
e.preventDefault();
|
|
||||||
if (highlightedIndex >= 0) {
|
|
||||||
handleSelect(filteredSuggestions[highlightedIndex]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "Escape": {
|
|
||||||
setIsOpen(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex items-center gap-2 px-3 py-2 bg-slate-100 rounded-md border border-transparent transition-all duration-200 focus-within:border-blue-600 focus-within:bg-white focus-within:w-[280px] w-[220px]">
|
|
||||||
<Search className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
placeholder="Search medicines..."
|
|
||||||
onChange={(e) => handleInputChange(e.target.value)}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-600"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
ref={listRef}
|
|
||||||
className="absolute top-full left-0 right-0 mt-1 z-50 bg-white border border-slate-200 rounded-lg shadow-lg max-h-72 overflow-y-auto"
|
|
||||||
>
|
|
||||||
{filteredSuggestions.length === 0 ? (
|
|
||||||
<div className="px-4 py-3.5 text-center text-sm text-slate-600">
|
|
||||||
No medicines found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredSuggestions.map((s, i) => (
|
|
||||||
<button
|
|
||||||
key={s.name}
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(s);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2.5 w-full text-left px-4 py-2.5 text-sm border-b border-slate-100 last:border-b-0 transition-colors",
|
|
||||||
i === highlightedIndex ? "bg-slate-50" : "hover:bg-slate-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium text-slate-900">
|
|
||||||
{query.trim() ? (
|
|
||||||
<HighlightedText
|
|
||||||
text={s.name}
|
|
||||||
query={query.toLowerCase().trim()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
s.name
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-600 ml-auto">
|
|
||||||
{s.brand} · {s.category}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
import { Link, useLocation } from "@tanstack/react-router";
|
|
||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
ReceiptText,
|
|
||||||
Package,
|
|
||||||
Truck,
|
|
||||||
Warehouse,
|
|
||||||
Users,
|
|
||||||
Pill,
|
|
||||||
UserRoundCog,
|
|
||||||
ChevronLeft,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "#/lib/cn";
|
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{ to: "/", label: "Home", icon: LayoutDashboard },
|
|
||||||
{ to: "/billing", label: "Billing", icon: ReceiptText },
|
|
||||||
{ to: "/stock", label: "Stock", icon: Package },
|
|
||||||
{ to: "/distributors", label: "Distributors", icon: Truck },
|
|
||||||
{ to: "/storage", label: "Storage", icon: Warehouse },
|
|
||||||
{ to: "/customers", label: "Customers", icon: Users },
|
|
||||||
{ to: "/products", label: "Products", icon: Pill },
|
|
||||||
{ to: "/staff", label: "Staff", icon: UserRoundCog },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
minimized: boolean;
|
|
||||||
mobileOpen: boolean;
|
|
||||||
onToggleMinimized: () => void;
|
|
||||||
onCloseMobile: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Sidebar({
|
|
||||||
minimized,
|
|
||||||
mobileOpen,
|
|
||||||
onToggleMinimized,
|
|
||||||
onCloseMobile,
|
|
||||||
}: SidebarProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const showLabels = !minimized || mobileOpen;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-40 bg-black/40 md:hidden transition-opacity border-0 p-0 w-full h-full cursor-default",
|
|
||||||
mobileOpen ? "opacity-100" : "opacity-0 pointer-events-none",
|
|
||||||
)}
|
|
||||||
onClick={onCloseMobile}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape" || e.key === "Enter") onCloseMobile();
|
|
||||||
}}
|
|
||||||
aria-label="Close sidebar"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<aside
|
|
||||||
className={cn(
|
|
||||||
"z-50 flex flex-col bg-slate-900 overflow-hidden transition-[width,transform] duration-300",
|
|
||||||
"fixed inset-y-0 left-0 md:static",
|
|
||||||
"w-60 min-w-60",
|
|
||||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
|
||||||
"md:translate-x-0",
|
|
||||||
minimized && "md:w-16 md:min-w-16",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between h-16 px-4 shrink-0">
|
|
||||||
<div className="flex items-center gap-2.5 overflow-hidden">
|
|
||||||
<div className="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center shrink-0">
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 28 28"
|
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M9 14l3.5 3.5L19 11"
|
|
||||||
stroke="#fff"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{showLabels && (
|
|
||||||
<span className="text-white font-semibold text-lg whitespace-nowrap">
|
|
||||||
Pharmanager
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggleMinimized}
|
|
||||||
className="hidden md:flex w-8 h-8 items-center justify-center rounded-md text-slate-400 hover:bg-slate-800 hover:text-white transition-colors shrink-0"
|
|
||||||
aria-label="Toggle sidebar"
|
|
||||||
>
|
|
||||||
<ChevronLeft
|
|
||||||
className={cn(
|
|
||||||
"w-[18px] h-[18px] transition-transform",
|
|
||||||
minimized && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-2 flex flex-col gap-0.5">
|
|
||||||
{NAV_ITEMS.map((item) => {
|
|
||||||
const isActive = location.pathname === item.to;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
onClick={onCloseMobile}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium whitespace-nowrap overflow-hidden transition-colors",
|
|
||||||
minimized &&
|
|
||||||
!(item.to === "/") &&
|
|
||||||
"md:justify-center md:px-0",
|
|
||||||
isActive
|
|
||||||
? "bg-blue-600 text-white"
|
|
||||||
: "text-slate-400 hover:bg-slate-800 hover:text-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className="w-5 h-5 shrink-0" />
|
|
||||||
{showLabels && <span>{item.label}</span>}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 border-t border-white/[0.06]",
|
|
||||||
minimized && !mobileOpen ? "p-3" : "p-4",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to="/profile"
|
|
||||||
className="flex items-center gap-2.5 overflow-hidden text-slate-400 text-sm"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold text-[13px] shrink-0">
|
|
||||||
MS
|
|
||||||
</div>
|
|
||||||
{showLabels && (
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
<div className="text-white font-medium whitespace-nowrap text-[13px]">
|
|
||||||
Dr. Mohammed
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap text-[11px]">Pharmacist</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export function cn(...classes: (string | false | null | undefined)[]): string {
|
|
||||||
return classes.filter(Boolean).join(" ");
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +1,27 @@
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from 'react-dom/client'
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from './routeTree.gen'
|
||||||
import { TrpcProvider } from "shared-react";
|
import { TrpcProvider } from 'shared-react'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
defaultPreload: "intent",
|
defaultPreload: 'intent',
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
});
|
})
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module '@tanstack/react-router' {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: typeof router;
|
router: typeof router
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootElement = document.getElementById("app");
|
const rootElement = document.getElementById('app')!
|
||||||
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 baseUrl="http://localhost:3001">
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</TrpcProvider>,
|
</TrpcProvider>,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,236 +9,38 @@
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as StorageRouteImport } from './routes/storage'
|
|
||||||
import { Route as StockRouteImport } from './routes/stock'
|
|
||||||
import { Route as StaffRouteImport } from './routes/staff'
|
|
||||||
import { Route as ProfileRouteImport } from './routes/profile'
|
|
||||||
import { Route as ProductsRouteImport } from './routes/products'
|
|
||||||
import { Route as DistributorsRouteImport } from './routes/distributors'
|
|
||||||
import { Route as CustomersRouteImport } from './routes/customers'
|
|
||||||
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 StorageAddRouteImport } from './routes/storage/add'
|
|
||||||
import { Route as StorageIdRouteImport } from './routes/storage/$id'
|
|
||||||
|
|
||||||
const StorageRoute = StorageRouteImport.update({
|
|
||||||
id: '/storage',
|
|
||||||
path: '/storage',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const StockRoute = StockRouteImport.update({
|
|
||||||
id: '/stock',
|
|
||||||
path: '/stock',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const StaffRoute = StaffRouteImport.update({
|
|
||||||
id: '/staff',
|
|
||||||
path: '/staff',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ProfileRoute = ProfileRouteImport.update({
|
|
||||||
id: '/profile',
|
|
||||||
path: '/profile',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ProductsRoute = ProductsRouteImport.update({
|
|
||||||
id: '/products',
|
|
||||||
path: '/products',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const DistributorsRoute = DistributorsRouteImport.update({
|
|
||||||
id: '/distributors',
|
|
||||||
path: '/distributors',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const CustomersRoute = CustomersRouteImport.update({
|
|
||||||
id: '/customers',
|
|
||||||
path: '/customers',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const BillingRoute = BillingRouteImport.update({
|
|
||||||
id: '/billing',
|
|
||||||
path: '/billing',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const StorageIndexRoute = StorageIndexRouteImport.update({
|
|
||||||
id: '/',
|
|
||||||
path: '/',
|
|
||||||
getParentRoute: () => StorageRoute,
|
|
||||||
} as any)
|
|
||||||
const StorageAddRoute = StorageAddRouteImport.update({
|
|
||||||
id: '/add',
|
|
||||||
path: '/add',
|
|
||||||
getParentRoute: () => StorageRoute,
|
|
||||||
} as any)
|
|
||||||
const StorageIdRoute = StorageIdRouteImport.update({
|
|
||||||
id: '/$id',
|
|
||||||
path: '/$id',
|
|
||||||
getParentRoute: () => StorageRoute,
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/billing': typeof BillingRoute
|
|
||||||
'/customers': typeof CustomersRoute
|
|
||||||
'/distributors': typeof DistributorsRoute
|
|
||||||
'/products': typeof ProductsRoute
|
|
||||||
'/profile': typeof ProfileRoute
|
|
||||||
'/staff': typeof StaffRoute
|
|
||||||
'/stock': typeof StockRoute
|
|
||||||
'/storage': typeof StorageRouteWithChildren
|
|
||||||
'/storage/$id': typeof StorageIdRoute
|
|
||||||
'/storage/add': typeof StorageAddRoute
|
|
||||||
'/storage/': typeof StorageIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/billing': typeof BillingRoute
|
|
||||||
'/customers': typeof CustomersRoute
|
|
||||||
'/distributors': typeof DistributorsRoute
|
|
||||||
'/products': typeof ProductsRoute
|
|
||||||
'/profile': typeof ProfileRoute
|
|
||||||
'/staff': typeof StaffRoute
|
|
||||||
'/stock': typeof StockRoute
|
|
||||||
'/storage/$id': typeof StorageIdRoute
|
|
||||||
'/storage/add': typeof StorageAddRoute
|
|
||||||
'/storage': typeof StorageIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/billing': typeof BillingRoute
|
|
||||||
'/customers': typeof CustomersRoute
|
|
||||||
'/distributors': typeof DistributorsRoute
|
|
||||||
'/products': typeof ProductsRoute
|
|
||||||
'/profile': typeof ProfileRoute
|
|
||||||
'/staff': typeof StaffRoute
|
|
||||||
'/stock': typeof StockRoute
|
|
||||||
'/storage': typeof StorageRouteWithChildren
|
|
||||||
'/storage/$id': typeof StorageIdRoute
|
|
||||||
'/storage/add': typeof StorageAddRoute
|
|
||||||
'/storage/': typeof StorageIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths: '/'
|
||||||
| '/'
|
|
||||||
| '/billing'
|
|
||||||
| '/customers'
|
|
||||||
| '/distributors'
|
|
||||||
| '/products'
|
|
||||||
| '/profile'
|
|
||||||
| '/staff'
|
|
||||||
| '/stock'
|
|
||||||
| '/storage'
|
|
||||||
| '/storage/$id'
|
|
||||||
| '/storage/add'
|
|
||||||
| '/storage/'
|
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to: '/'
|
||||||
| '/'
|
id: '__root__' | '/'
|
||||||
| '/billing'
|
|
||||||
| '/customers'
|
|
||||||
| '/distributors'
|
|
||||||
| '/products'
|
|
||||||
| '/profile'
|
|
||||||
| '/staff'
|
|
||||||
| '/stock'
|
|
||||||
| '/storage/$id'
|
|
||||||
| '/storage/add'
|
|
||||||
| '/storage'
|
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/'
|
|
||||||
| '/billing'
|
|
||||||
| '/customers'
|
|
||||||
| '/distributors'
|
|
||||||
| '/products'
|
|
||||||
| '/profile'
|
|
||||||
| '/staff'
|
|
||||||
| '/stock'
|
|
||||||
| '/storage'
|
|
||||||
| '/storage/$id'
|
|
||||||
| '/storage/add'
|
|
||||||
| '/storage/'
|
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
BillingRoute: typeof BillingRoute
|
|
||||||
CustomersRoute: typeof CustomersRoute
|
|
||||||
DistributorsRoute: typeof DistributorsRoute
|
|
||||||
ProductsRoute: typeof ProductsRoute
|
|
||||||
ProfileRoute: typeof ProfileRoute
|
|
||||||
StaffRoute: typeof StaffRoute
|
|
||||||
StockRoute: typeof StockRoute
|
|
||||||
StorageRoute: typeof StorageRouteWithChildren
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/storage': {
|
|
||||||
id: '/storage'
|
|
||||||
path: '/storage'
|
|
||||||
fullPath: '/storage'
|
|
||||||
preLoaderRoute: typeof StorageRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/stock': {
|
|
||||||
id: '/stock'
|
|
||||||
path: '/stock'
|
|
||||||
fullPath: '/stock'
|
|
||||||
preLoaderRoute: typeof StockRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/staff': {
|
|
||||||
id: '/staff'
|
|
||||||
path: '/staff'
|
|
||||||
fullPath: '/staff'
|
|
||||||
preLoaderRoute: typeof StaffRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/profile': {
|
|
||||||
id: '/profile'
|
|
||||||
path: '/profile'
|
|
||||||
fullPath: '/profile'
|
|
||||||
preLoaderRoute: typeof ProfileRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/products': {
|
|
||||||
id: '/products'
|
|
||||||
path: '/products'
|
|
||||||
fullPath: '/products'
|
|
||||||
preLoaderRoute: typeof ProductsRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/distributors': {
|
|
||||||
id: '/distributors'
|
|
||||||
path: '/distributors'
|
|
||||||
fullPath: '/distributors'
|
|
||||||
preLoaderRoute: typeof DistributorsRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/customers': {
|
|
||||||
id: '/customers'
|
|
||||||
path: '/customers'
|
|
||||||
fullPath: '/customers'
|
|
||||||
preLoaderRoute: typeof CustomersRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/billing': {
|
|
||||||
id: '/billing'
|
|
||||||
path: '/billing'
|
|
||||||
fullPath: '/billing'
|
|
||||||
preLoaderRoute: typeof BillingRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
|
|
@ -246,55 +48,11 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/storage/': {
|
|
||||||
id: '/storage/'
|
|
||||||
path: '/'
|
|
||||||
fullPath: '/storage/'
|
|
||||||
preLoaderRoute: typeof StorageIndexRouteImport
|
|
||||||
parentRoute: typeof StorageRoute
|
|
||||||
}
|
|
||||||
'/storage/add': {
|
|
||||||
id: '/storage/add'
|
|
||||||
path: '/add'
|
|
||||||
fullPath: '/storage/add'
|
|
||||||
preLoaderRoute: typeof StorageAddRouteImport
|
|
||||||
parentRoute: typeof StorageRoute
|
|
||||||
}
|
|
||||||
'/storage/$id': {
|
|
||||||
id: '/storage/$id'
|
|
||||||
path: '/$id'
|
|
||||||
fullPath: '/storage/$id'
|
|
||||||
preLoaderRoute: typeof StorageIdRouteImport
|
|
||||||
parentRoute: typeof StorageRoute
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StorageRouteChildren {
|
|
||||||
StorageIdRoute: typeof StorageIdRoute
|
|
||||||
StorageAddRoute: typeof StorageAddRoute
|
|
||||||
StorageIndexRoute: typeof StorageIndexRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const StorageRouteChildren: StorageRouteChildren = {
|
|
||||||
StorageIdRoute: StorageIdRoute,
|
|
||||||
StorageAddRoute: StorageAddRoute,
|
|
||||||
StorageIndexRoute: StorageIndexRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const StorageRouteWithChildren =
|
|
||||||
StorageRoute._addFileChildren(StorageRouteChildren)
|
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
BillingRoute: BillingRoute,
|
|
||||||
CustomersRoute: CustomersRoute,
|
|
||||||
DistributorsRoute: DistributorsRoute,
|
|
||||||
ProductsRoute: ProductsRoute,
|
|
||||||
ProfileRoute: ProfileRoute,
|
|
||||||
StaffRoute: StaffRoute,
|
|
||||||
StockRoute: StockRoute,
|
|
||||||
StorageRoute: StorageRouteWithChildren,
|
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
19
apps/pharmanager/src/router.tsx
Normal file
19
apps/pharmanager/src/router.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
export function getRouter() {
|
||||||
|
const router = createTanStackRouter({
|
||||||
|
routeTree,
|
||||||
|
scrollRestoration: true,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: ReturnType<typeof getRouter>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,28 @@
|
||||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
import { Outlet, createRootRoute } from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||||
import { AppLayout } from "#/components/AppLayout";
|
|
||||||
|
|
||||||
import "../styles.css";
|
import '../styles.css'
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
});
|
})
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppLayout>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppLayout>
|
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
config={{
|
config={{
|
||||||
position: "bottom-right",
|
position: 'bottom-right',
|
||||||
}}
|
}}
|
||||||
plugins={[
|
plugins={[
|
||||||
{
|
{
|
||||||
name: "TanStack Router",
|
name: 'TanStack Router',
|
||||||
render: <TanStackRouterDevtoolsPanel />,
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/billing")({
|
|
||||||
component: BillingPage,
|
|
||||||
staticData: {
|
|
||||||
title: "Billing",
|
|
||||||
subtitle: "Invoices, payments, revenue tracking",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function BillingPage() {
|
|
||||||
return <div>Billing</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/customers")({
|
|
||||||
component: CustomersPage,
|
|
||||||
staticData: {
|
|
||||||
title: "Customers",
|
|
||||||
subtitle: "Customer management with purchase history",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function CustomersPage() {
|
|
||||||
return <div>Customers</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/distributors")({
|
|
||||||
component: DistributorsPage,
|
|
||||||
staticData: {
|
|
||||||
title: "Distributors",
|
|
||||||
subtitle: "Agency management with contact details",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function DistributorsPage() {
|
|
||||||
return <div>Distributors</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,45 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import type { Person } from '@repo/shared'
|
||||||
|
import { useCounterStore, useGetStorageSpaces } from 'shared-react'
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute('/')({ component: Home })
|
||||||
component: HomePage,
|
|
||||||
staticData: {
|
|
||||||
title: "Dashboard",
|
|
||||||
subtitle: "Pharmacy overview & inventory at a glance",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function HomePage() {
|
function Home() {
|
||||||
return <div>Home</div>;
|
const shafi:Person = {age: 32, name: 'Shafi'}
|
||||||
|
const count = useCounterStore((s) => s.count)
|
||||||
|
const inc = useCounterStore((s) => s.inc)
|
||||||
|
const spaces = useGetStorageSpaces()
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-4xl font-bold">Welcome to TanStack Start</h1>
|
||||||
|
<p className="mt-4 text-lg">
|
||||||
|
{shafi.name} is {shafi.age} years old.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-6 rounded bg-black px-4 py-2 text-white"
|
||||||
|
onClick={inc}
|
||||||
|
>
|
||||||
|
Count: {count}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-2xl font-semibold">Storage Spaces</h2>
|
||||||
|
{spaces.isLoading ? (
|
||||||
|
<p className="mt-2">Loading…</p>
|
||||||
|
) : spaces.error ? (
|
||||||
|
<p className="mt-2 text-red-600">Failed to load storage spaces</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-2 list-disc pl-5">
|
||||||
|
{spaces.data?.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
{s.name}
|
||||||
|
{s.description ? `: ${s.description}` : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/products")({
|
|
||||||
component: ProductsPage,
|
|
||||||
staticData: {
|
|
||||||
title: "Products",
|
|
||||||
subtitle: "Medicine catalog with search, add, edit, delete",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function ProductsPage() {
|
|
||||||
return <div>Products</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/profile")({
|
|
||||||
component: ProfilePage,
|
|
||||||
staticData: {
|
|
||||||
title: "Profile",
|
|
||||||
subtitle: "Your profile information",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function ProfilePage() {
|
|
||||||
return <div>Profile</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/staff")({
|
|
||||||
component: StaffPage,
|
|
||||||
staticData: {
|
|
||||||
title: "Staff",
|
|
||||||
subtitle: "Staff management with roles & permissions",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function StaffPage() {
|
|
||||||
return <div>Staff</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/stock")({
|
|
||||||
component: StockPage,
|
|
||||||
staticData: {
|
|
||||||
title: "Stock",
|
|
||||||
subtitle: "Inventory levels, low-stock alerts, purchase orders",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function StockPage() {
|
|
||||||
return <div>Stock</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/storage")({
|
|
||||||
component: () => <Outlet />,
|
|
||||||
});
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Warehouse,
|
|
||||||
Layers,
|
|
||||||
ImageIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useGetStorageById, useRemoveStorage, trpc } from "shared-react";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/storage/$id")({
|
|
||||||
component: StorageDetailsPage,
|
|
||||||
staticData: {
|
|
||||||
title: "Rack Details",
|
|
||||||
subtitle: "Storage rack information",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function StorageDetailsPage() {
|
|
||||||
const { id } = Route.useParams();
|
|
||||||
const rackId = Number(id);
|
|
||||||
const { data: rack, isLoading, error } = useGetStorageById(rackId);
|
|
||||||
const removeMutation = useRemoveStorage();
|
|
||||||
const utils = trpc.useUtils();
|
|
||||||
|
|
||||||
function handleDelete() {
|
|
||||||
if (!rack) return;
|
|
||||||
if (!confirm(`Delete ${rack.name}? This cannot be undone.`)) return;
|
|
||||||
removeMutation.mutate(
|
|
||||||
{ id: rack.id },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.storage.list.invalidate();
|
|
||||||
window.location.href = "/storage";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-slate-600 py-8">
|
|
||||||
Loading rack details...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !rack) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center py-16 px-6 text-slate-600">
|
|
||||||
<Warehouse className="w-12 h-12 mb-4 opacity-40" />
|
|
||||||
<h3 className="text-base font-semibold text-slate-900 mb-1.5">
|
|
||||||
Rack not found
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm mb-4">
|
|
||||||
The rack you're looking for doesn't exist.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
to="/storage"
|
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Back to Storage
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
to="/storage"
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:underline mb-5"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
Back to Storage
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<div className="w-14 h-14 rounded-xl bg-blue-50 flex items-center justify-center shrink-0">
|
|
||||||
<Warehouse className="w-7 h-7 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h2 className="text-[22px] font-semibold text-slate-900">
|
|
||||||
{rack.name}
|
|
||||||
</h2>
|
|
||||||
{rack.description && (
|
|
||||||
<p className="text-sm text-slate-600 mt-1">
|
|
||||||
{rack.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Pencil className="w-[15px] h-[15px]" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-[15px] h-[15px]" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4 mb-5">
|
|
||||||
<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-sm font-semibold text-slate-600 uppercase tracking-wider mb-3">
|
|
||||||
<Warehouse className="w-[15px] h-[15px]" />
|
|
||||||
Rack Info
|
|
||||||
</h3>
|
|
||||||
<div className="flex justify-between py-2 border-b border-slate-200 text-sm">
|
|
||||||
<span className="text-slate-600">Rack ID</span>
|
|
||||||
<span className="font-medium">{rack.id}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between py-2 border-b border-slate-200 text-sm">
|
|
||||||
<span className="text-slate-600">Status</span>
|
|
||||||
<span className="inline-block px-2.5 py-px rounded-full text-xs font-medium bg-emerald-50 text-emerald-600">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between py-2 text-sm">
|
|
||||||
<span className="text-slate-600">Description</span>
|
|
||||||
<span className="font-normal text-slate-600 text-right max-w-60">
|
|
||||||
{rack.description || "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</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-sm font-semibold text-slate-600 uppercase tracking-wider mb-3">
|
|
||||||
<Layers className="w-[15px] h-[15px]" />
|
|
||||||
Aliases
|
|
||||||
</h3>
|
|
||||||
{rack.aliases.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{rack.aliases.map((alias) => (
|
|
||||||
<span
|
|
||||||
key={alias}
|
|
||||||
className="inline-block px-3 py-1 bg-slate-100 rounded-full text-sm text-slate-700"
|
|
||||||
>
|
|
||||||
{alias}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-slate-400">No aliases</span>
|
|
||||||
)}
|
|
||||||
</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-sm font-semibold text-slate-600 uppercase tracking-wider mb-3">
|
|
||||||
<ImageIcon className="w-[15px] h-[15px]" />
|
|
||||||
Images{" "}
|
|
||||||
<span className="font-normal normal-case tracking-normal text-slate-500">
|
|
||||||
({rack.image_urls.length})
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
{rack.image_urls.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
|
|
||||||
{rack.image_urls.map((url) => (
|
|
||||||
<div
|
|
||||||
key={url}
|
|
||||||
className="aspect-square rounded-lg border border-slate-200 bg-slate-50 flex flex-col items-center justify-center gap-2 p-3 text-center"
|
|
||||||
>
|
|
||||||
<ImageIcon className="w-8 h-8 text-slate-300" />
|
|
||||||
<span className="text-xs text-slate-500 break-all">
|
|
||||||
{url}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-slate-400">
|
|
||||||
No images uploaded
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { ArrowLeft, Plus, X, ImageIcon } from "lucide-react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { useCreateStorage, trpc } from "shared-react";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().min(1, "Rack name is required"),
|
|
||||||
description: z.string().nullable().optional(),
|
|
||||||
aliases: z.array(z.string()),
|
|
||||||
image_urls: z.array(z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/storage/add")({
|
|
||||||
component: AddStoragePage,
|
|
||||||
staticData: {
|
|
||||||
title: "Add Rack",
|
|
||||||
subtitle: "Create a new storage rack or shelf",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function AddStoragePage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const createMutation = useCreateStorage();
|
|
||||||
const utils = trpc.useUtils();
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
setValue,
|
|
||||||
getValues,
|
|
||||||
} = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
description: null,
|
|
||||||
aliases: [],
|
|
||||||
image_urls: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [aliasInput, setAliasInput] = useState("");
|
|
||||||
const [imageInput, setImageInput] = useState("");
|
|
||||||
|
|
||||||
function handleAddAlias() {
|
|
||||||
const trimmed = aliasInput.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
const current = getValues("aliases");
|
|
||||||
if (!current.includes(trimmed)) {
|
|
||||||
setValue("aliases", [...current, trimmed]);
|
|
||||||
}
|
|
||||||
setAliasInput("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAliasKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
||||||
if (e.key === "Enter" || e.key === ",") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAddAlias();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemoveAlias(index: number) {
|
|
||||||
const current = getValues("aliases");
|
|
||||||
setValue(
|
|
||||||
"aliases",
|
|
||||||
current.filter((_, i) => i !== index),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddImage() {
|
|
||||||
const trimmed = imageInput.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
const current = getValues("image_urls");
|
|
||||||
if (!current.includes(trimmed)) {
|
|
||||||
setValue("image_urls", [...current, trimmed]);
|
|
||||||
}
|
|
||||||
setImageInput("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImageKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAddImage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemoveImage(index: number) {
|
|
||||||
const current = getValues("image_urls");
|
|
||||||
setValue(
|
|
||||||
"image_urls",
|
|
||||||
current.filter((_, i) => i !== index),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSubmit(values: FormValues) {
|
|
||||||
createMutation.mutate(
|
|
||||||
{
|
|
||||||
name: values.name,
|
|
||||||
description: values.description ?? null,
|
|
||||||
aliases: values.aliases,
|
|
||||||
image_urls: values.image_urls,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.storage.list.invalidate();
|
|
||||||
navigate({ to: "/storage" });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
to="/storage"
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:underline mb-5"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
Back to Storage
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<div className="mb-5">
|
|
||||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">
|
|
||||||
Rack Name <span className="text-red-600 ml-0.5">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
{...register("name")}
|
|
||||||
placeholder="e.g. Rack A-01, Cold Storage 1"
|
|
||||||
className={`w-full px-3.5 py-2.5 border rounded-md text-sm text-slate-900 bg-white transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 ${errors.name ? "border-red-600" : "border-slate-200 focus:border-blue-600"}`}
|
|
||||||
/>
|
|
||||||
{errors.name && (
|
|
||||||
<p className="text-sm text-red-600 mt-1">
|
|
||||||
{errors.name.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-5">
|
|
||||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
{...register("description", {
|
|
||||||
setValueAs: (v: string) => v || null,
|
|
||||||
})}
|
|
||||||
placeholder="Describe what this rack stores, its location, special conditions..."
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white resize-y min-h-[80px] transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-5">
|
|
||||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">
|
|
||||||
Aliases
|
|
||||||
</label>
|
|
||||||
<AliasList
|
|
||||||
aliases={getValues("aliases")}
|
|
||||||
onRemove={handleRemoveAlias}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={aliasInput}
|
|
||||||
onChange={(e) => setAliasInput(e.target.value)}
|
|
||||||
onKeyDown={handleAliasKeyDown}
|
|
||||||
placeholder="Type an alias and press Enter or comma"
|
|
||||||
className="flex-1 px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddAlias}
|
|
||||||
className="px-3 py-2.5 border border-slate-200 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 transition-colors"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
|
||||||
Common names or abbreviations for this rack
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-5">
|
|
||||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">
|
|
||||||
Images
|
|
||||||
</label>
|
|
||||||
<ImageList
|
|
||||||
images={getValues("image_urls")}
|
|
||||||
onRemove={handleRemoveImage}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={imageInput}
|
|
||||||
onChange={(e) => setImageInput(e.target.value)}
|
|
||||||
onKeyDown={handleImageKeyDown}
|
|
||||||
placeholder="Image filename (e.g. rack-a01-photo.jpg)"
|
|
||||||
className="flex-1 px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-slate-900 bg-white transition-colors focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddImage}
|
|
||||||
className="px-3 py-2.5 border border-slate-200 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 transition-colors"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
|
||||||
Add filenames or URLs for rack photos
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-5 border-t border-slate-200">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
className="inline-flex items-center gap-1.5 px-5 py-2.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-[15px] h-[15px]" />
|
|
||||||
{createMutation.isPending ? "Saving..." : "Save Rack"}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
to="/storage"
|
|
||||||
className="inline-flex items-center px-5 py-2.5 border border-slate-200 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createMutation.error && (
|
|
||||||
<p className="text-sm text-red-600 mt-4">
|
|
||||||
Failed to create rack. Please try again.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AliasList({
|
|
||||||
aliases,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
aliases: string[];
|
|
||||||
onRemove: (index: number) => void;
|
|
||||||
}) {
|
|
||||||
if (aliases.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
||||||
{aliases.map((alias, i) => (
|
|
||||||
<span
|
|
||||||
key={`${alias}-${i}`}
|
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-100 rounded-full text-sm"
|
|
||||||
>
|
|
||||||
{alias}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRemove(i)}
|
|
||||||
className="w-4 h-4 flex items-center justify-center rounded-full hover:bg-red-600 hover:text-white transition-colors text-slate-500"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageList({
|
|
||||||
images,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
images: string[];
|
|
||||||
onRemove: (index: number) => void;
|
|
||||||
}) {
|
|
||||||
if (images.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
|
||||||
{images.map((img, i) => (
|
|
||||||
<div
|
|
||||||
key={`${img}-${i}`}
|
|
||||||
className="relative w-20 h-20 rounded-md border border-slate-200 bg-slate-50 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<ImageIcon className="w-8 h-8 text-slate-300" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRemove(i)}
|
|
||||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-red-600 text-white flex items-center justify-center text-xs hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
import { useState, useMemo, useCallback } from "react";
|
|
||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
||||||
import { Search, Plus, Pencil, Trash2, Warehouse } from "lucide-react";
|
|
||||||
import { GridTable } from "#/components/GridTable";
|
|
||||||
import type { GridTableColumn } from "#/components/GridTable";
|
|
||||||
import { useListStorage, useRemoveStorage, trpc } from "shared-react";
|
|
||||||
|
|
||||||
interface Rack {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
aliases: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeColumns(
|
|
||||||
onDelete: (rack: Rack) => void,
|
|
||||||
): GridTableColumn<Rack>[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
header: "Rack Name",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link
|
|
||||||
to="/storage/$id"
|
|
||||||
params={{ id: row.id.toString() }}
|
|
||||||
className="font-medium text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
{row.name}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "description",
|
|
||||||
header: "Description",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm text-slate-600">{row.description}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "aliases",
|
|
||||||
header: "Aliases",
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.aliases.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{row.aliases.map((alias) => (
|
|
||||||
<span
|
|
||||||
key={alias}
|
|
||||||
className="inline-block bg-slate-100 rounded px-2 py-0.5 text-xs text-slate-500"
|
|
||||||
>
|
|
||||||
{alias}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-slate-400">—</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: "Actions",
|
|
||||||
size: 80,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-md text-slate-500 hover:text-blue-600 hover:bg-blue-50 transition-colors"
|
|
||||||
aria-label={`Edit ${row.name}`}
|
|
||||||
>
|
|
||||||
<Pencil className="w-[15px] h-[15px]" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 transition-colors"
|
|
||||||
aria-label={`Delete ${row.name}`}
|
|
||||||
onClick={() => onDelete(row)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-[15px] h-[15px]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/storage/")({
|
|
||||||
component: StorageIndexPage,
|
|
||||||
staticData: {
|
|
||||||
title: "Storage",
|
|
||||||
subtitle: "Manage pharmacy racks & shelves",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function StorageIndexPage() {
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const { data: racks, isLoading, error } = useListStorage();
|
|
||||||
const removeMutation = useRemoveStorage();
|
|
||||||
const utils = trpc.useUtils();
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
(rack: Rack) => {
|
|
||||||
if (!confirm(`Delete ${rack.name}? This cannot be undone.`)) return;
|
|
||||||
removeMutation.mutate(
|
|
||||||
{ id: rack.id },
|
|
||||||
{ onSuccess: () => utils.storage.list.invalidate() },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[removeMutation, utils],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = useMemo(() => makeColumns(handleDelete), [handleDelete]);
|
|
||||||
|
|
||||||
const filteredRacks = useMemo(() => {
|
|
||||||
const q = searchQuery.toLowerCase().trim();
|
|
||||||
if (!q) return racks ?? [];
|
|
||||||
return (racks ?? []).filter((r) => {
|
|
||||||
const nameMatch = r.name.toLowerCase().includes(q);
|
|
||||||
const descMatch = r.description.toLowerCase().includes(q);
|
|
||||||
const aliasMatch = r.aliases.some((a) =>
|
|
||||||
a.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
return nameMatch || descMatch || aliasMatch;
|
|
||||||
});
|
|
||||||
}, [searchQuery, racks]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-slate-600 py-8">
|
|
||||||
Loading storage spaces...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-red-600 py-8">
|
|
||||||
Failed to load storage spaces.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-5 flex-wrap">
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-[400px] px-3.5 py-2 bg-white rounded-md border border-slate-200 transition-all duration-200 focus-within:border-blue-600 focus-within:shadow-[0_0_0_3px_rgba(37,99,235,0.1)]">
|
|
||||||
<Search className="w-4 h-4 text-slate-600 shrink-0" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search by name, alias, or description..."
|
|
||||||
className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/storage/add"
|
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-[15px] h-[15px]" />
|
|
||||||
Add Rack
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GridTable
|
|
||||||
columns={columns}
|
|
||||||
data={filteredRacks}
|
|
||||||
emptyState={
|
|
||||||
<div className="flex flex-col items-center py-16 px-6 text-slate-600">
|
|
||||||
<Warehouse className="w-12 h-12 mb-4 opacity-40" />
|
|
||||||
<h3 className="text-base font-semibold text-slate-900 mb-1.5">
|
|
||||||
No racks found
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm">
|
|
||||||
{searchQuery
|
|
||||||
? "No racks match your search. Try a different term."
|
|
||||||
: "Add your first rack to organize storage locations."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from 'vite'
|
||||||
import { devtools } from "@tanstack/devtools-vite";
|
import { devtools } from '@tanstack/devtools-vite'
|
||||||
|
|
||||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
||||||
|
|
||||||
import viteReact from "@vitejs/plugin-react";
|
import viteReact from '@vitejs/plugin-react'
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
const config = defineConfig({
|
const config = defineConfig({
|
||||||
resolve: { tsconfigPaths: true },
|
resolve: { tsconfigPaths: true },
|
||||||
plugins: [
|
plugins: [
|
||||||
devtools(),
|
devtools(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
tanstackRouter({ target: "react", autoCodeSplitting: true }),
|
tanstackRouter({ target: 'react', autoCodeSplitting: true }),
|
||||||
viteReact(),
|
viteReact(),
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
|
|
||||||
export default config;
|
export default config
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import type { Config } from 'drizzle-kit'
|
import type { Config } from 'drizzle-kit'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { env } from './src/lib/env-exporter'
|
import { env } from './src/lib/env-exporter'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
// Default to the backend dev database in the monorepo.
|
||||||
|
const defaultDbPath = path.resolve(import.meta.dir, '../../apps/backend/dev.db')
|
||||||
const defaultDbPath = path.resolve(__dirname, '../../apps/backend/dev.db')
|
|
||||||
const dbPath = env.SQLITE_PATH || defaultDbPath
|
const dbPath = env.SQLITE_PATH || defaultDbPath
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,5 @@ CREATE TABLE `storage_spaces` (
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
`name` text NOT NULL,
|
`name` text NOT NULL,
|
||||||
`description` text,
|
`description` text,
|
||||||
`aliases` text DEFAULT '[]' NOT NULL,
|
|
||||||
`image_urls` text DEFAULT '[]' NOT NULL
|
`image_urls` text DEFAULT '[]' NOT NULL
|
||||||
);
|
);
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "0a690373-295e-498e-a867-29ce0949f57d",
|
"id": "1fe82ac3-40f3-4846-8d99-eddabf4580a2",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"storage_spaces": {
|
"storage_spaces": {
|
||||||
|
|
@ -28,14 +28,6 @@
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"aliases": {
|
|
||||||
"name": "aliases",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": "'[]'"
|
|
||||||
},
|
|
||||||
"image_urls": {
|
"image_urls": {
|
||||||
"name": "image_urls",
|
"name": "image_urls",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1779522854247,
|
"when": 1778999618092,
|
||||||
"tag": "0000_normal_mephisto",
|
"tag": "0000_clumsy_morgan_stark",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { createDb } from './db'
|
|
||||||
import { runMigrations } from './migrate'
|
|
||||||
|
|
||||||
const { db, sqlite } = createDb()
|
|
||||||
runMigrations(sqlite)
|
|
||||||
|
|
||||||
export { db, sqlite }
|
|
||||||
|
|
@ -22,5 +22,3 @@ export function createDb(options: DbOptions = {}) {
|
||||||
|
|
||||||
return { db, sqlite, sqlitePath }
|
return { db, sqlite, sqlitePath }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DrizzleDb = ReturnType<typeof drizzle>
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
export { createDb } from './db'
|
export { createDb } from './db'
|
||||||
export { sqlite } from './db-instance'
|
|
||||||
export { runMigrations } from './migrate'
|
export { runMigrations } from './migrate'
|
||||||
export {
|
export {
|
||||||
createStorageSpacesRepo,
|
createStorageSpacesRepo,
|
||||||
type StorageSpace,
|
type StorageSpace,
|
||||||
type StorageSpacesRepo,
|
type StorageSpacesRepo,
|
||||||
} from './storageSpaces'
|
} from './storageSpaces'
|
||||||
export { storageSpaces } from './schema/storageSpacesSchema'
|
export { storageSpaces } from './schema/storageSpaces'
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
export function parseStringArrJson(raw: string): string[] {
|
|
||||||
try {
|
|
||||||
const v = JSON.parse(raw);
|
|
||||||
return Array.isArray(v) && v.every((x) => typeof x === "string") ? v : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeStringArrJson(arr: string[]): string {
|
|
||||||
return JSON.stringify(arr);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export * from './storageSpacesSchema'
|
export * from './storageSpaces'
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ export const storageSpaces = sqliteTable('storage_spaces', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
aliases: text('aliases').notNull().default('[]'),
|
|
||||||
// JSON array string of image URLs
|
// JSON array string of image URLs
|
||||||
imageUrls: text('image_urls').notNull().default('[]'),
|
imageUrls: text('image_urls').notNull().default('[]'),
|
||||||
})
|
})
|
||||||
|
|
@ -1,111 +1,125 @@
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
|
import type { Database } from 'bun:sqlite'
|
||||||
|
|
||||||
import { db } from './db-instance'
|
import { createDb } from './db'
|
||||||
import { storageSpaces } from './schema/storageSpacesSchema'
|
import { storageSpaces } from './schema/storageSpaces'
|
||||||
import { parseStringArrJson, serializeStringArrJson } from './lib/json-utils'
|
|
||||||
|
|
||||||
export type StorageSpace = {
|
export type StorageSpace = {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
aliases: string[]
|
|
||||||
image_urls: string[]
|
image_urls: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateStorageSpaceInput = {
|
function parseImageUrls(raw: string): string[] {
|
||||||
name: string
|
try {
|
||||||
description?: StorageSpace['description']
|
const v = JSON.parse(raw)
|
||||||
aliases: StorageSpace['aliases']
|
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? v : []
|
||||||
image_urls: StorageSpace['image_urls']
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateStorageSpacePatch = Partial<CreateStorageSpaceInput>
|
function serializeImageUrls(urls: string[]): string {
|
||||||
|
return JSON.stringify(urls)
|
||||||
export type StorageSpacesRepo = {
|
|
||||||
getStorageSpaces: () => Promise<StorageSpace[]>
|
|
||||||
getStorageSpaceById: (id: number) => Promise<StorageSpace | null>
|
|
||||||
createStorageSpace: (input: CreateStorageSpaceInput) => Promise<StorageSpace>
|
|
||||||
updateStorageSpace: (id: number, patch: UpdateStorageSpacePatch) => Promise<StorageSpace | null>
|
|
||||||
deleteStorageSpace: (id: number) => Promise<boolean>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toStorageSpace(row: {
|
function toStorageSpace(row: {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
aliases: string
|
|
||||||
imageUrls: string
|
imageUrls: string
|
||||||
}): StorageSpace {
|
}): StorageSpace {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
aliases: parseStringArrJson(row.aliases),
|
image_urls: parseImageUrls(row.imageUrls),
|
||||||
image_urls: parseStringArrJson(row.imageUrls),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStorageSpacesRepo(): {
|
export type StorageSpacesRepo = {
|
||||||
|
getStorageSpaces: () => Promise<StorageSpace[]>
|
||||||
|
getStorageSpaceById: (id: number) => Promise<StorageSpace | null>
|
||||||
|
createStorageSpace: (input: {
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
image_urls: string[]
|
||||||
|
}) => Promise<StorageSpace>
|
||||||
|
updateStorageSpace: (id: number, patch: {
|
||||||
|
name?: string
|
||||||
|
description?: string | null
|
||||||
|
image_urls?: string[]
|
||||||
|
}) => Promise<StorageSpace | null>
|
||||||
|
deleteStorageSpace: (id: number) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStorageSpacesRepo(opts?: { sqlitePath?: string }): {
|
||||||
repo: StorageSpacesRepo
|
repo: StorageSpacesRepo
|
||||||
|
sqlite: Database
|
||||||
|
close: () => void
|
||||||
} {
|
} {
|
||||||
|
const { db, sqlite } = createDb({ sqlitePath: opts?.sqlitePath })
|
||||||
|
|
||||||
const repo: StorageSpacesRepo = {
|
const repo: StorageSpacesRepo = {
|
||||||
getStorageSpaces() {
|
async getStorageSpaces() {
|
||||||
const rows = db.select().from(storageSpaces).all()
|
const rows = await db.select().from(storageSpaces).all()
|
||||||
return Promise.resolve(rows.map(toStorageSpace))
|
return rows.map(toStorageSpace)
|
||||||
},
|
},
|
||||||
|
|
||||||
getStorageSpaceById(id) {
|
async getStorageSpaceById(id) {
|
||||||
const row = db
|
const row = await db
|
||||||
.select()
|
.select()
|
||||||
.from(storageSpaces)
|
.from(storageSpaces)
|
||||||
.where(eq(storageSpaces.id, id))
|
.where(eq(storageSpaces.id, id))
|
||||||
.get()
|
.get()
|
||||||
return Promise.resolve(row ? toStorageSpace(row) : null)
|
return row ? toStorageSpace(row) : null
|
||||||
},
|
},
|
||||||
|
|
||||||
createStorageSpace(input) {
|
async createStorageSpace(input) {
|
||||||
const created = db
|
const created = await db
|
||||||
.insert(storageSpaces)
|
.insert(storageSpaces)
|
||||||
.values({
|
.values({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
aliases: serializeStringArrJson(input.aliases),
|
imageUrls: serializeImageUrls(input.image_urls),
|
||||||
imageUrls: serializeStringArrJson(input.image_urls),
|
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
return Promise.resolve(toStorageSpace(created))
|
|
||||||
|
return toStorageSpace(created)
|
||||||
},
|
},
|
||||||
|
|
||||||
updateStorageSpace(id, patch) {
|
async updateStorageSpace(id, patch) {
|
||||||
const updated = db
|
const updated = await db
|
||||||
.update(storageSpaces)
|
.update(storageSpaces)
|
||||||
.set({
|
.set({
|
||||||
...(patch.name !== undefined ? { name: patch.name } : {}),
|
...(patch.name !== undefined ? { name: patch.name } : {}),
|
||||||
...(patch.description !== undefined ? { description: patch.description } : {}),
|
...(patch.description !== undefined ? { description: patch.description } : {}),
|
||||||
...(patch.aliases !== undefined
|
|
||||||
? { aliases: serializeStringArrJson(patch.aliases) }
|
|
||||||
: {}),
|
|
||||||
...(patch.image_urls !== undefined
|
...(patch.image_urls !== undefined
|
||||||
? { imageUrls: serializeStringArrJson(patch.image_urls) }
|
? { imageUrls: serializeImageUrls(patch.image_urls) }
|
||||||
: {}),
|
: {}),
|
||||||
})
|
})
|
||||||
.where(eq(storageSpaces.id, id))
|
.where(eq(storageSpaces.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
return Promise.resolve(updated ? toStorageSpace(updated) : null)
|
|
||||||
|
return updated ? toStorageSpace(updated) : null
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteStorageSpace(id) {
|
async deleteStorageSpace(id) {
|
||||||
const deleted = db
|
const deleted = await db
|
||||||
.delete(storageSpaces)
|
.delete(storageSpaces)
|
||||||
.where(eq(storageSpaces.id, id))
|
.where(eq(storageSpaces.id, id))
|
||||||
.returning({ id: storageSpaces.id })
|
.returning({ id: storageSpaces.id })
|
||||||
.get()
|
.get()
|
||||||
return Promise.resolve(Boolean(deleted))
|
return Boolean(deleted)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return { repo }
|
return {
|
||||||
|
repo,
|
||||||
|
sqlite,
|
||||||
|
close: () => sqlite.close(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import { trpc } from "../trpc";
|
import { trpc } from '../trpc'
|
||||||
|
|
||||||
export function useListStorage() {
|
export function useGetStorageSpaces() {
|
||||||
return trpc.storage.list.useQuery();
|
return trpc.storageSpaces.getStorageSpaces.useQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGetStorageById(id: number) {
|
export function useGetStorageSpaceById(id: number) {
|
||||||
return trpc.storage.byId.useQuery({ id });
|
return trpc.storageSpaces.getStorageSpaceById.useQuery({ id })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateStorage() {
|
export function useAddStorageSpace() {
|
||||||
return trpc.storage.create.useMutation();
|
return trpc.storageSpaces.addStorageSpace.useMutation()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateStorage() {
|
export function useUpdateStorageSpace() {
|
||||||
return trpc.storage.update.useMutation();
|
return trpc.storageSpaces.updateStorageSpace.useMutation()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRemoveStorage() {
|
export function useDeleteStorageSpace() {
|
||||||
return trpc.storage.remove.useMutation();
|
return trpc.storageSpaces.deleteStorageSpace.useMutation()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,3 @@ export * from './query'
|
||||||
export * from './store'
|
export * from './store'
|
||||||
export * from './provider'
|
export * from './provider'
|
||||||
export * from './hooks/storageSpaces'
|
export * from './hooks/storageSpaces'
|
||||||
export { trpc } from './trpc'
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue