From f733c1e36b8d2618c7f0a4b3ce2c1d55da1cf007 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sat, 23 May 2026 13:26:25 +0530 Subject: [PATCH] storage tab functional --- agents.md | 6 +- apps/backend/dev.db | Bin 20480 -> 20480 bytes apps/backend/src/lib/data-manager-instance.ts | 6 +- apps/backend/src/lib/data-manager.ts | 31 +- apps/backend/src/lib/env-exporter.ts | 5 +- apps/backend/src/trpc/init.ts | 4 + .../src/trpc/pharmanager/v1/storage.ts | 63 ++++ apps/backend/src/trpc/router.ts | 94 +----- apps/pharmanager/package.json | 6 +- apps/pharmanager/src/components/GridTable.tsx | 121 +++++++ apps/pharmanager/src/components/Header.tsx | 12 +- .../src/components/MedicineSearch.tsx | 220 +++++++++++++ apps/pharmanager/src/routeTree.gen.ts | 80 ++++- apps/pharmanager/src/routes/storage.tsx | 12 +- apps/pharmanager/src/routes/storage/$id.tsx | 188 +++++++++++ apps/pharmanager/src/routes/storage/add.tsx | 305 ++++++++++++++++++ apps/pharmanager/src/routes/storage/index.tsx | 182 +++++++++++ .../data-manager-sqlite/drizzle.config.ts | 6 +- ...gan_stark.sql => 0000_normal_mephisto.sql} | 1 + .../drizzle/meta/0000_snapshot.json | 10 +- .../drizzle/meta/_journal.json | 4 +- .../data-manager-sqlite/src/db-instance.ts | 7 + packages/data-manager-sqlite/src/db.ts | 2 + packages/data-manager-sqlite/src/index.ts | 3 +- .../data-manager-sqlite/src/lib/json-utils.ts | 12 + .../data-manager-sqlite/src/schema/index.ts | 2 +- ...torageSpaces.ts => storageSpacesSchema.ts} | 1 + .../data-manager-sqlite/src/storageSpaces.ts | 100 +++--- .../shared-react/src/hooks/storageSpaces.ts | 22 +- packages/shared-react/src/index.ts | 1 + 30 files changed, 1286 insertions(+), 220 deletions(-) create mode 100644 apps/backend/src/trpc/init.ts create mode 100644 apps/backend/src/trpc/pharmanager/v1/storage.ts create mode 100644 apps/pharmanager/src/components/GridTable.tsx create mode 100644 apps/pharmanager/src/components/MedicineSearch.tsx create mode 100644 apps/pharmanager/src/routes/storage/$id.tsx create mode 100644 apps/pharmanager/src/routes/storage/add.tsx create mode 100644 apps/pharmanager/src/routes/storage/index.tsx rename packages/data-manager-sqlite/drizzle/{0000_clumsy_morgan_stark.sql => 0000_normal_mephisto.sql} (81%) create mode 100644 packages/data-manager-sqlite/src/db-instance.ts create mode 100644 packages/data-manager-sqlite/src/lib/json-utils.ts rename packages/data-manager-sqlite/src/schema/{storageSpaces.ts => storageSpacesSchema.ts} (87%) diff --git a/agents.md b/agents.md index cc18bb5..134448b 100644 --- a/agents.md +++ b/agents.md @@ -1 +1,5 @@ -don't try to compile or run or build any project until explicitly asked for \ No newline at end of file +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 + diff --git a/apps/backend/dev.db b/apps/backend/dev.db index c4d283aa1e3839bc6d85999a5668255f39f0996c..6326ac6d1c64292264f9eef3387fbd23daccb8f8 100644 GIT binary patch delta 285 zcmZozz}T>Wae_1>^F$eEQDz3cyj{HfKNwhew=wWD^RMUQ;N7;d(TR8RUEW~M3TAe3 zO-;sT?a7vWi{G82naixU(|QY%UnTwL88Lw!ON)T3k775w}|6#PPcd^TU_do18) zZjqK|W|@+dYHn#@Vq}(NmS$>{VrFEXXl7_+X`E!7oM@V4o|c?yU}~OfX=$2fWNd1f zl4fFMY@2)H8lA4>Z5R{ml z4QJ>mB&8N-rlcyAWTYx2=a=S{q!vXhQDS;(d~rcya%wRE2!2#e delta 164 zcmZozz}T>Wae_1>(?l6(Q6>hxyj{HfKNwhe?=tW+^RMUA=e@gGP#}|c@;2UJ&Ol~% zaZOFerr60se2X^U=DRQ8mS&NhXqsedm}s7uVq$J;Xqsx8m}qWknrdKdkz!$LlwxL) zVrFS>U}j=zn3kAonP{Gpnq+2}l4_iql$?@ez&Ouj@hhE83M>Ll3<8@46<+gC4B%(u JWdH&=7XZ;2EujDa diff --git a/apps/backend/src/lib/data-manager-instance.ts b/apps/backend/src/lib/data-manager-instance.ts index 7b600c3..95a8c91 100644 --- a/apps/backend/src/lib/data-manager-instance.ts +++ b/apps/backend/src/lib/data-manager-instance.ts @@ -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(); diff --git a/apps/backend/src/lib/data-manager.ts b/apps/backend/src/lib/data-manager.ts index 0c84abf..ea018db 100644 --- a/apps/backend/src/lib/data-manager.ts +++ b/apps/backend/src/lib/data-manager.ts @@ -1,28 +1,13 @@ import { - createStorageSpacesRepo, - runMigrations, - type StorageSpacesRepo, -} from 'data-manager-sqlite' - -import type { StorageSpacesService } from '../trpc/router' + createStorageSpacesRepo, + type StorageSpacesRepo, +} 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; + } } diff --git a/apps/backend/src/lib/env-exporter.ts b/apps/backend/src/lib/env-exporter.ts index ad34888..e008448 100644 --- a/apps/backend/src/lib/env-exporter.ts +++ b/apps/backend/src/lib/env-exporter.ts @@ -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; diff --git a/apps/backend/src/trpc/init.ts b/apps/backend/src/trpc/init.ts new file mode 100644 index 0000000..d0b7a36 --- /dev/null +++ b/apps/backend/src/trpc/init.ts @@ -0,0 +1,4 @@ +import { initTRPC } from "@trpc/server"; + +const t = initTRPC.create(); +export { t }; diff --git a/apps/backend/src/trpc/pharmanager/v1/storage.ts b/apps/backend/src/trpc/pharmanager/v1/storage.ts new file mode 100644 index 0000000..0f96292 --- /dev/null +++ b/apps/backend/src/trpc/pharmanager/v1/storage.ts @@ -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; + +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 }; + }), +}); diff --git a/apps/backend/src/trpc/router.ts b/apps/backend/src/trpc/router.ts index bc44090..9938c18 100644 --- a/apps/backend/src/trpc/router.ts +++ b/apps/backend/src/trpc/router.ts @@ -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 - -export type StorageSpacesService = { - getStorageSpaces: () => Promise - getStorageSpaceById: (id: number) => Promise - createStorageSpace: (input: { - name: string - description?: string | null - image_urls: string[] - }) => Promise - updateStorageSpace: ( - id: number, - patch: { - name?: string - description?: string | null - image_urls?: string[] - }, - ) => Promise - deleteStorageSpace: (id: number) => Promise -} - -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; diff --git a/apps/pharmanager/package.json b/apps/pharmanager/package.json index 9965a1a..83f021e 100644 --- a/apps/pharmanager/package.json +++ b/apps/pharmanager/package.json @@ -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", diff --git a/apps/pharmanager/src/components/GridTable.tsx b/apps/pharmanager/src/components/GridTable.tsx new file mode 100644 index 0000000..8ded86c --- /dev/null +++ b/apps/pharmanager/src/components/GridTable.tsx @@ -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 { + id: string; + header: string; + size?: number; + cell: (props: { row: T }) => ReactNode; +} + +interface GridTableProps { + columns: GridTableColumn[]; + data: T[]; + emptyState?: ReactNode; + onRowClick?: (row: T) => void; +} + +export function GridTable({ + columns, + data, + emptyState, + onRowClick, +}: GridTableProps) { + const tableColumns = useMemo[]>( + () => + 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 ( +
+ {emptyState} +
+ ); + } + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => { + const isLastRow = + row.index === table.getRowModel().rows.length - 1; + + return ( + onRowClick(row.original) : undefined + } + className={cn( + "hover:bg-slate-50 transition-colors", + onRowClick && "cursor-pointer", + )} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + ); + })} + +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+
+ ); +} diff --git a/apps/pharmanager/src/components/Header.tsx b/apps/pharmanager/src/components/Header.tsx index 73b5104..73217fa 100644 --- a/apps/pharmanager/src/components/Header.tsx +++ b/apps/pharmanager/src/components/Header.tsx @@ -1,4 +1,5 @@ -import { Menu, Bell, Search } from "lucide-react"; +import { Menu, Bell } from "lucide-react"; +import { MedicineSearch } from "#/components/MedicineSearch"; interface HeaderProps { pageTitle: string; @@ -29,14 +30,7 @@ export function Header({
-
- - -
+ + )) + )} +
+ )} + + ); +} diff --git a/apps/pharmanager/src/routeTree.gen.ts b/apps/pharmanager/src/routeTree.gen.ts index 1f78345..e3ff4c9 100644 --- a/apps/pharmanager/src/routeTree.gen.ts +++ b/apps/pharmanager/src/routeTree.gen.ts @@ -18,6 +18,9 @@ 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', @@ -64,6 +67,21 @@ const IndexRoute = IndexRouteImport.update({ 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 @@ -74,7 +92,10 @@ export interface FileRoutesByFullPath { '/profile': typeof ProfileRoute '/staff': typeof StaffRoute '/stock': typeof StockRoute - '/storage': typeof StorageRoute + '/storage': typeof StorageRouteWithChildren + '/storage/$id': typeof StorageIdRoute + '/storage/add': typeof StorageAddRoute + '/storage/': typeof StorageIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -85,7 +106,9 @@ export interface FileRoutesByTo { '/profile': typeof ProfileRoute '/staff': typeof StaffRoute '/stock': typeof StockRoute - '/storage': typeof StorageRoute + '/storage/$id': typeof StorageIdRoute + '/storage/add': typeof StorageAddRoute + '/storage': typeof StorageIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -97,7 +120,10 @@ export interface FileRoutesById { '/profile': typeof ProfileRoute '/staff': typeof StaffRoute '/stock': typeof StockRoute - '/storage': typeof StorageRoute + '/storage': typeof StorageRouteWithChildren + '/storage/$id': typeof StorageIdRoute + '/storage/add': typeof StorageAddRoute + '/storage/': typeof StorageIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -111,6 +137,9 @@ export interface FileRouteTypes { | '/staff' | '/stock' | '/storage' + | '/storage/$id' + | '/storage/add' + | '/storage/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -121,6 +150,8 @@ export interface FileRouteTypes { | '/profile' | '/staff' | '/stock' + | '/storage/$id' + | '/storage/add' | '/storage' id: | '__root__' @@ -133,6 +164,9 @@ export interface FileRouteTypes { | '/staff' | '/stock' | '/storage' + | '/storage/$id' + | '/storage/add' + | '/storage/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -144,7 +178,7 @@ export interface RootRouteChildren { ProfileRoute: typeof ProfileRoute StaffRoute: typeof StaffRoute StockRoute: typeof StockRoute - StorageRoute: typeof StorageRoute + StorageRoute: typeof StorageRouteWithChildren } declare module '@tanstack/react-router' { @@ -212,9 +246,45 @@ 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, @@ -224,7 +294,7 @@ const rootRouteChildren: RootRouteChildren = { ProfileRoute: ProfileRoute, StaffRoute: StaffRoute, StockRoute: StockRoute, - StorageRoute: StorageRoute, + StorageRoute: StorageRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/pharmanager/src/routes/storage.tsx b/apps/pharmanager/src/routes/storage.tsx index 1972d79..3f7956d 100644 --- a/apps/pharmanager/src/routes/storage.tsx +++ b/apps/pharmanager/src/routes/storage.tsx @@ -1,13 +1,5 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/storage")({ - component: StoragePage, - staticData: { - title: "Storage", - subtitle: "Rack & shelf management with image records", - }, + component: () => , }); - -function StoragePage() { - return
Storage
; -} diff --git a/apps/pharmanager/src/routes/storage/$id.tsx b/apps/pharmanager/src/routes/storage/$id.tsx new file mode 100644 index 0000000..446dbde --- /dev/null +++ b/apps/pharmanager/src/routes/storage/$id.tsx @@ -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 ( +
+ Loading rack details... +
+ ); + } + + if (error || !rack) { + return ( +
+ +

+ Rack not found +

+

+ The rack you're looking for doesn't exist. +

+ + Back to Storage + +
+ ); + } + + return ( +
+ + + Back to Storage + + +
+
+ +
+
+

+ {rack.name} +

+ {rack.description && ( +

+ {rack.description} +

+ )} +
+
+ + +
+
+ +
+
+

+ + Rack Info +

+
+ Rack ID + {rack.id} +
+
+ Status + + Active + +
+
+ Description + + {rack.description || "—"} + +
+
+ +
+

+ + Aliases +

+ {rack.aliases.length > 0 ? ( +
+ {rack.aliases.map((alias) => ( + + {alias} + + ))} +
+ ) : ( + No aliases + )} +
+ +
+

+ + Images{" "} + + ({rack.image_urls.length}) + +

+ {rack.image_urls.length > 0 ? ( +
+ {rack.image_urls.map((url) => ( +
+ + + {url} + +
+ ))} +
+ ) : ( + + No images uploaded + + )} +
+
+
+ ); +} diff --git a/apps/pharmanager/src/routes/storage/add.tsx b/apps/pharmanager/src/routes/storage/add.tsx new file mode 100644 index 0000000..c098b7f --- /dev/null +++ b/apps/pharmanager/src/routes/storage/add.tsx @@ -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; + +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({ + 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) { + 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) { + 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 ( +
+ + + Back to Storage + + +
+
+ + + {errors.name && ( +

+ {errors.name.message} +

+ )} +
+ +
+ +