storage tab functional

This commit is contained in:
shafi54 2026-05-23 13:26:25 +05:30
parent 17f1f8e4d2
commit f733c1e36b
30 changed files with 1286 additions and 220 deletions

View file

@ -1 +1,5 @@
don't try to compile or run or build any project until explicitly asked for 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();
export const dataManager = new DataManager(env.SQLITE_PATH || 'dev.db')

View file

@ -1,28 +1,13 @@
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: StorageSpacesService readonly storageSpaces: StorageSpacesRepo;
readonly close: () => void
constructor(sqlitePath: string) { constructor() {
const { repo, sqlite, close } = createStorageSpacesRepo({ sqlitePath }) const { repo } = createStorageSpacesRepo();
runMigrations(sqlite) this.storageSpaces = repo;
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),
}
} }
} }

View file

@ -1,4 +1,3 @@
export const env = { export const env = {
PORT: process.env.PORT || '4004', PORT: process.env.PORT || "4004",
SQLITE_PATH: process.env.SQLITE_PATH, } as const;
} 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 { t } from "./init";
import { z } from 'zod' import { storageRouter } from "./pharmanager/v1/storage";
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({
storageSpaces: storageSpacesRouter, storage: storageRouter,
}) });
export type AppRouter = typeof appRouter export type AppRouter = typeof appRouter;

View file

@ -15,16 +15,20 @@
"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",

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

@ -1,4 +1,5 @@
import { Menu, Bell, Search } from "lucide-react"; import { Menu, Bell } from "lucide-react";
import { MedicineSearch } from "#/components/MedicineSearch";
interface HeaderProps { interface HeaderProps {
pageTitle: string; pageTitle: string;
@ -29,14 +30,7 @@ export function Header({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="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]"> <MedicineSearch />
<Search className="w-3.5 h-3.5 text-slate-600 shrink-0" />
<input
type="text"
placeholder="Search medicines..."
className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-600"
/>
</div>
<button <button
type="button" type="button"

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

@ -18,6 +18,9 @@ import { Route as DistributorsRouteImport } from './routes/distributors'
import { Route as CustomersRouteImport } from './routes/customers' import { Route as CustomersRouteImport } from './routes/customers'
import { Route as BillingRouteImport } from './routes/billing' 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({ const StorageRoute = StorageRouteImport.update({
id: '/storage', id: '/storage',
@ -64,6 +67,21 @@ const IndexRoute = IndexRouteImport.update({
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
@ -74,7 +92,10 @@ export interface FileRoutesByFullPath {
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/staff': typeof StaffRoute '/staff': typeof StaffRoute
'/stock': typeof StockRoute '/stock': typeof StockRoute
'/storage': typeof StorageRoute '/storage': typeof StorageRouteWithChildren
'/storage/$id': typeof StorageIdRoute
'/storage/add': typeof StorageAddRoute
'/storage/': typeof StorageIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@ -85,7 +106,9 @@ export interface FileRoutesByTo {
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/staff': typeof StaffRoute '/staff': typeof StaffRoute
'/stock': typeof StockRoute '/stock': typeof StockRoute
'/storage': typeof StorageRoute '/storage/$id': typeof StorageIdRoute
'/storage/add': typeof StorageAddRoute
'/storage': typeof StorageIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@ -97,7 +120,10 @@ export interface FileRoutesById {
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/staff': typeof StaffRoute '/staff': typeof StaffRoute
'/stock': typeof StockRoute '/stock': typeof StockRoute
'/storage': typeof StorageRoute '/storage': typeof StorageRouteWithChildren
'/storage/$id': typeof StorageIdRoute
'/storage/add': typeof StorageAddRoute
'/storage/': typeof StorageIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@ -111,6 +137,9 @@ export interface FileRouteTypes {
| '/staff' | '/staff'
| '/stock' | '/stock'
| '/storage' | '/storage'
| '/storage/$id'
| '/storage/add'
| '/storage/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@ -121,6 +150,8 @@ export interface FileRouteTypes {
| '/profile' | '/profile'
| '/staff' | '/staff'
| '/stock' | '/stock'
| '/storage/$id'
| '/storage/add'
| '/storage' | '/storage'
id: id:
| '__root__' | '__root__'
@ -133,6 +164,9 @@ export interface FileRouteTypes {
| '/staff' | '/staff'
| '/stock' | '/stock'
| '/storage' | '/storage'
| '/storage/$id'
| '/storage/add'
| '/storage/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@ -144,7 +178,7 @@ export interface RootRouteChildren {
ProfileRoute: typeof ProfileRoute ProfileRoute: typeof ProfileRoute
StaffRoute: typeof StaffRoute StaffRoute: typeof StaffRoute
StockRoute: typeof StockRoute StockRoute: typeof StockRoute
StorageRoute: typeof StorageRoute StorageRoute: typeof StorageRouteWithChildren
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -212,9 +246,45 @@ 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, BillingRoute: BillingRoute,
@ -224,7 +294,7 @@ const rootRouteChildren: RootRouteChildren = {
ProfileRoute: ProfileRoute, ProfileRoute: ProfileRoute,
StaffRoute: StaffRoute, StaffRoute: StaffRoute,
StockRoute: StockRoute, StockRoute: StockRoute,
StorageRoute: StorageRoute, StorageRoute: StorageRouteWithChildren,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View file

@ -1,13 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/storage")({ export const Route = createFileRoute("/storage")({
component: StoragePage, component: () => <Outlet />,
staticData: {
title: "Storage",
subtitle: "Rack & shelf management with image records",
},
}); });
function StoragePage() {
return <div>Storage</div>;
}

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,9 +1,11 @@
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'
// Default to the backend dev database in the monorepo. const __dirname = path.dirname(fileURLToPath(import.meta.url))
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 {

View file

@ -2,5 +2,6 @@ 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
); );

View file

@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "1fe82ac3-40f3-4846-8d99-eddabf4580a2", "id": "0a690373-295e-498e-a867-29ce0949f57d",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"storage_spaces": { "storage_spaces": {
@ -28,6 +28,14 @@
"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",

View file

@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1778999618092, "when": 1779522854247,
"tag": "0000_clumsy_morgan_stark", "tag": "0000_normal_mephisto",
"breakpoints": true "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 } return { db, sqlite, sqlitePath }
} }
export type DrizzleDb = ReturnType<typeof drizzle>

View file

@ -1,8 +1,9 @@
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/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 }), 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('[]'),
}) })

View file

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

View file

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

View file

@ -2,3 +2,4 @@ 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'