Compare commits

...

2 commits

Author SHA1 Message Date
shafi54
f733c1e36b storage tab functional 2026-05-23 13:26:25 +05:30
shafi54
17f1f8e4d2 added shell screens 2026-05-23 11:25:27 +05:30
47 changed files with 1928 additions and 349 deletions

5
agents.md Normal file
View file

@ -0,0 +1,5 @@
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.

View file

@ -1,5 +1,3 @@
import { env } from './env-exporter'
import { DataManager } from './data-manager'
import { DataManager } from "./data-manager";
// Singleton container for all backend data access.
export const dataManager = new DataManager(env.SQLITE_PATH || 'dev.db')
export const dataManager = new DataManager();

View file

@ -1,28 +1,13 @@
import {
createStorageSpacesRepo,
runMigrations,
type StorageSpacesRepo,
} from 'data-manager-sqlite'
import type { StorageSpacesService } from '../trpc/router'
} from "data-manager-sqlite";
export class DataManager {
readonly storageSpaces: StorageSpacesService
readonly close: () => void
readonly storageSpaces: StorageSpacesRepo;
constructor(sqlitePath: string) {
const { repo, sqlite, close } = createStorageSpacesRepo({ sqlitePath })
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),
}
constructor() {
const { repo } = createStorageSpacesRepo();
this.storageSpaces = repo;
}
}

View file

@ -1,4 +1,3 @@
export const env = {
PORT: process.env.PORT || '4004',
SQLITE_PATH: process.env.SQLITE_PATH,
} as const
PORT: process.env.PORT || "4004",
} as const;

View file

@ -0,0 +1,4 @@
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export { t };

View file

@ -0,0 +1,63 @@
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 };
}),
});

View file

@ -1,92 +1,8 @@
import { initTRPC } from '@trpc/server'
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 }
}),
})
import { t } from "./init";
import { storageRouter } from "./pharmanager/v1/storage";
export const appRouter = t.router({
storageSpaces: storageSpacesRouter,
})
storage: storageRouter,
});
export type AppRouter = typeof appRouter
export type AppRouter = typeof appRouter;

View file

@ -13,7 +13,8 @@
"**/index.html",
"**/vite.config.ts",
"!**/src/routeTree.gen.ts",
"!**/src/styles.css"
"!**/src/styles.css",
"!**/inspiration"
]
},
"formatter": {
@ -33,4 +34,3 @@
}
}
}

View file

@ -15,16 +15,20 @@
"check": "biome check"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "latest",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.132.0",
"lucide-react": "^0.545.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.76.1",
"shared-react": "*",
"tailwindcss": "^4.1.18"
"tailwindcss": "^4.1.18",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",

View file

@ -0,0 +1,67 @@
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>
);
}

View file

@ -0,0 +1,121 @@
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>
);
}

View file

@ -0,0 +1,48 @@
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>
);
}

View file

@ -0,0 +1,220 @@
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>
);
}

View file

@ -0,0 +1,159 @@
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>
</>
);
}

View file

@ -0,0 +1,3 @@
export function cn(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(" ");
}

View file

@ -1,27 +1,28 @@
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { TrpcProvider } from 'shared-react'
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { TrpcProvider } from "shared-react";
const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreload: "intent",
scrollRestoration: true,
})
});
declare module '@tanstack/react-router' {
declare module "@tanstack/react-router" {
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) {
const root = ReactDOM.createRoot(rootElement)
const root = ReactDOM.createRoot(rootElement);
root.render(
<TrpcProvider baseUrl="http://localhost:3001">
<RouterProvider router={router} />
</TrpcProvider>,
)
);
}

View file

@ -9,38 +9,236 @@
// 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 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 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({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} 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 {
'/': 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 {
'/': 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 {
__root__: typeof rootRouteImport
'/': 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 {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths:
| '/'
| '/billing'
| '/customers'
| '/distributors'
| '/products'
| '/profile'
| '/staff'
| '/stock'
| '/storage'
| '/storage/$id'
| '/storage/add'
| '/storage/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to:
| '/'
| '/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
}
export interface RootRouteChildren {
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' {
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: '/'
path: '/'
@ -48,11 +246,55 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
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 = {
IndexRoute: IndexRoute,
BillingRoute: BillingRoute,
CustomersRoute: CustomersRoute,
DistributorsRoute: DistributorsRoute,
ProductsRoute: ProductsRoute,
ProfileRoute: ProfileRoute,
StaffRoute: StaffRoute,
StockRoute: StockRoute,
StorageRoute: StorageRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View file

@ -1,19 +0,0 @@
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>
}
}

View file

@ -1,28 +1,31 @@
import { Outlet, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { Outlet, createRootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanStackDevtools } from "@tanstack/react-devtools";
import { AppLayout } from "#/components/AppLayout";
import '../styles.css'
import "../styles.css";
export const Route = createRootRoute({
component: RootComponent,
})
});
function RootComponent() {
return (
<>
<AppLayout>
<Outlet />
</AppLayout>
<TanStackDevtools
config={{
position: 'bottom-right',
position: "bottom-right",
}}
plugins={[
{
name: 'TanStack Router',
name: "TanStack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
</>
)
);
}

View file

@ -0,0 +1,13 @@
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>;
}

View file

@ -0,0 +1,13 @@
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>;
}

View file

@ -0,0 +1,13 @@
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>;
}

View file

@ -1,45 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import type { Person } from '@repo/shared'
import { useCounterStore, useGetStorageSpaces } from 'shared-react'
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute('/')({ component: Home })
export const Route = createFileRoute("/")({
component: HomePage,
staticData: {
title: "Dashboard",
subtitle: "Pharmacy overview & inventory at a glance",
},
});
function Home() {
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>
)
function HomePage() {
return <div>Home</div>;
}

View file

@ -0,0 +1,13 @@
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>;
}

View file

@ -0,0 +1,13 @@
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>;
}

View file

@ -0,0 +1,13 @@
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>;
}

View file

@ -0,0 +1,13 @@
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>;
}

View file

@ -0,0 +1,5 @@
import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/storage")({
component: () => <Outlet />,
});

View file

@ -0,0 +1,188 @@
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>
);
}

View file

@ -0,0 +1,305 @@
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>
);
}

View file

@ -0,0 +1,182 @@
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>
);
}

View file

@ -1,19 +1,19 @@
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import { defineConfig } from "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 tailwindcss from '@tailwindcss/vite'
import viteReact from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [
devtools(),
tailwindcss(),
tanstackRouter({ target: 'react', autoCodeSplitting: true }),
tanstackRouter({ target: "react", autoCodeSplitting: true }),
viteReact(),
],
})
});
export default config
export default config;

View file

@ -1,9 +1,11 @@
import type { Config } from 'drizzle-kit'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { env } from './src/lib/env-exporter'
// Default to the backend dev database in the monorepo.
const defaultDbPath = path.resolve(import.meta.dir, '../../apps/backend/dev.db')
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const defaultDbPath = path.resolve(__dirname, '../../apps/backend/dev.db')
const dbPath = env.SQLITE_PATH || defaultDbPath
export default {

View file

@ -2,5 +2,6 @@ CREATE TABLE `storage_spaces` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`description` text,
`aliases` text DEFAULT '[]' NOT NULL,
`image_urls` text DEFAULT '[]' NOT NULL
);

View file

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1fe82ac3-40f3-4846-8d99-eddabf4580a2",
"id": "0a690373-295e-498e-a867-29ce0949f57d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"storage_spaces": {
@ -28,6 +28,14 @@
"notNull": false,
"autoincrement": false
},
"aliases": {
"name": "aliases",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_urls": {
"name": "image_urls",
"type": "text",

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1778999618092,
"tag": "0000_clumsy_morgan_stark",
"when": 1779522854247,
"tag": "0000_normal_mephisto",
"breakpoints": true
}
]

View file

@ -0,0 +1,7 @@
import { createDb } from './db'
import { runMigrations } from './migrate'
const { db, sqlite } = createDb()
runMigrations(sqlite)
export { db, sqlite }

View file

@ -22,3 +22,5 @@ export function createDb(options: DbOptions = {}) {
return { db, sqlite, sqlitePath }
}
export type DrizzleDb = ReturnType<typeof drizzle>

View file

@ -1,8 +1,9 @@
export { createDb } from './db'
export { sqlite } from './db-instance'
export { runMigrations } from './migrate'
export {
createStorageSpacesRepo,
type StorageSpace,
type StorageSpacesRepo,
} from './storageSpaces'
export { storageSpaces } from './schema/storageSpaces'
export { storageSpaces } from './schema/storageSpacesSchema'

View file

@ -0,0 +1,12 @@
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);
}

View file

@ -1 +1 @@
export * from './storageSpaces'
export * from './storageSpacesSchema'

View file

@ -4,6 +4,7 @@ export const storageSpaces = sqliteTable('storage_spaces', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
description: text('description'),
aliases: text('aliases').notNull().default('[]'),
// JSON array string of image URLs
imageUrls: text('image_urls').notNull().default('[]'),
})

View file

@ -1,125 +1,111 @@
import { eq } from 'drizzle-orm'
import type { Database } from 'bun:sqlite'
import { createDb } from './db'
import { storageSpaces } from './schema/storageSpaces'
import { db } from './db-instance'
import { storageSpaces } from './schema/storageSpacesSchema'
import { parseStringArrJson, serializeStringArrJson } from './lib/json-utils'
export type StorageSpace = {
id: number
name: string
description: string | null
aliases: string[]
image_urls: string[]
}
function parseImageUrls(raw: string): string[] {
try {
const v = JSON.parse(raw)
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? v : []
} catch {
return []
}
export type CreateStorageSpaceInput = {
name: string
description?: StorageSpace['description']
aliases: StorageSpace['aliases']
image_urls: StorageSpace['image_urls']
}
function serializeImageUrls(urls: string[]): string {
return JSON.stringify(urls)
export type UpdateStorageSpacePatch = Partial<CreateStorageSpaceInput>
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: {
id: number
name: string
description: string | null
aliases: string
imageUrls: string
}): StorageSpace {
return {
id: row.id,
name: row.name,
description: row.description,
image_urls: parseImageUrls(row.imageUrls),
aliases: parseStringArrJson(row.aliases),
image_urls: parseStringArrJson(row.imageUrls),
}
}
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 }): {
export function createStorageSpacesRepo(): {
repo: StorageSpacesRepo
sqlite: Database
close: () => void
} {
const { db, sqlite } = createDb({ sqlitePath: opts?.sqlitePath })
const repo: StorageSpacesRepo = {
async getStorageSpaces() {
const rows = await db.select().from(storageSpaces).all()
return rows.map(toStorageSpace)
getStorageSpaces() {
const rows = db.select().from(storageSpaces).all()
return Promise.resolve(rows.map(toStorageSpace))
},
async getStorageSpaceById(id) {
const row = await db
getStorageSpaceById(id) {
const row = db
.select()
.from(storageSpaces)
.where(eq(storageSpaces.id, id))
.get()
return row ? toStorageSpace(row) : null
return Promise.resolve(row ? toStorageSpace(row) : null)
},
async createStorageSpace(input) {
const created = await db
createStorageSpace(input) {
const created = db
.insert(storageSpaces)
.values({
name: input.name,
description: input.description ?? null,
imageUrls: serializeImageUrls(input.image_urls),
aliases: serializeStringArrJson(input.aliases),
imageUrls: serializeStringArrJson(input.image_urls),
})
.returning()
.get()
return toStorageSpace(created)
return Promise.resolve(toStorageSpace(created))
},
async updateStorageSpace(id, patch) {
const updated = await db
updateStorageSpace(id, patch) {
const updated = db
.update(storageSpaces)
.set({
...(patch.name !== undefined ? { name: patch.name } : {}),
...(patch.description !== undefined ? { description: patch.description } : {}),
...(patch.aliases !== undefined
? { aliases: serializeStringArrJson(patch.aliases) }
: {}),
...(patch.image_urls !== undefined
? { imageUrls: serializeImageUrls(patch.image_urls) }
? { imageUrls: serializeStringArrJson(patch.image_urls) }
: {}),
})
.where(eq(storageSpaces.id, id))
.returning()
.get()
return updated ? toStorageSpace(updated) : null
return Promise.resolve(updated ? toStorageSpace(updated) : null)
},
async deleteStorageSpace(id) {
const deleted = await db
deleteStorageSpace(id) {
const deleted = db
.delete(storageSpaces)
.where(eq(storageSpaces.id, id))
.returning({ id: storageSpaces.id })
.get()
return Boolean(deleted)
return Promise.resolve(Boolean(deleted))
},
}
return {
repo,
sqlite,
close: () => sqlite.close(),
}
return { repo }
}

View file

@ -1,21 +1,21 @@
import { trpc } from '../trpc'
import { trpc } from "../trpc";
export function useGetStorageSpaces() {
return trpc.storageSpaces.getStorageSpaces.useQuery()
export function useListStorage() {
return trpc.storage.list.useQuery();
}
export function useGetStorageSpaceById(id: number) {
return trpc.storageSpaces.getStorageSpaceById.useQuery({ id })
export function useGetStorageById(id: number) {
return trpc.storage.byId.useQuery({ id });
}
export function useAddStorageSpace() {
return trpc.storageSpaces.addStorageSpace.useMutation()
export function useCreateStorage() {
return trpc.storage.create.useMutation();
}
export function useUpdateStorageSpace() {
return trpc.storageSpaces.updateStorageSpace.useMutation()
export function useUpdateStorage() {
return trpc.storage.update.useMutation();
}
export function useDeleteStorageSpace() {
return trpc.storageSpaces.deleteStorageSpace.useMutation()
export function useRemoveStorage() {
return trpc.storage.remove.useMutation();
}

View file

@ -2,3 +2,4 @@ export * from './query'
export * from './store'
export * from './provider'
export * from './hooks/storageSpaces'
export { trpc } from './trpc'