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(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

@ -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,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 {
pageTitle: string;
@ -29,14 +30,7 @@ export function Header({
</div>
<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]">
<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>
<MedicineSearch />
<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 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)

View file

@ -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: () => <Outlet />,
});
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 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'