storage tab functional
This commit is contained in:
parent
17f1f8e4d2
commit
f733c1e36b
30 changed files with 1286 additions and 220 deletions
|
|
@ -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.
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,28 +1,13 @@
|
|||
import {
|
||||
createStorageSpacesRepo,
|
||||
runMigrations,
|
||||
type StorageSpacesRepo,
|
||||
} from 'data-manager-sqlite'
|
||||
|
||||
import type { StorageSpacesService } from '../trpc/router'
|
||||
createStorageSpacesRepo,
|
||||
type StorageSpacesRepo,
|
||||
} from "data-manager-sqlite";
|
||||
|
||||
export class DataManager {
|
||||
readonly storageSpaces: StorageSpacesService
|
||||
readonly close: () => void
|
||||
readonly storageSpaces: StorageSpacesRepo;
|
||||
|
||||
constructor(sqlitePath: string) {
|
||||
const { repo, sqlite, close } = createStorageSpacesRepo({ sqlitePath })
|
||||
runMigrations(sqlite)
|
||||
|
||||
this.close = close
|
||||
|
||||
// Keep the service surface stable for the router.
|
||||
this.storageSpaces = {
|
||||
getStorageSpaces: () => repo.getStorageSpaces(),
|
||||
getStorageSpaceById: (id) => repo.getStorageSpaceById(id),
|
||||
createStorageSpace: (input) => repo.createStorageSpace(input),
|
||||
updateStorageSpace: (id, patch) => repo.updateStorageSpace(id, patch),
|
||||
deleteStorageSpace: (id) => repo.deleteStorageSpace(id),
|
||||
}
|
||||
}
|
||||
constructor() {
|
||||
const { repo } = createStorageSpacesRepo();
|
||||
this.storageSpaces = repo;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
4
apps/backend/src/trpc/init.ts
Normal file
4
apps/backend/src/trpc/init.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { initTRPC } from "@trpc/server";
|
||||
|
||||
const t = initTRPC.create();
|
||||
export { t };
|
||||
63
apps/backend/src/trpc/pharmanager/v1/storage.ts
Normal file
63
apps/backend/src/trpc/pharmanager/v1/storage.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
121
apps/pharmanager/src/components/GridTable.tsx
Normal file
121
apps/pharmanager/src/components/GridTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
220
apps/pharmanager/src/components/MedicineSearch.tsx
Normal file
220
apps/pharmanager/src/components/MedicineSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
188
apps/pharmanager/src/routes/storage/$id.tsx
Normal file
188
apps/pharmanager/src/routes/storage/$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
305
apps/pharmanager/src/routes/storage/add.tsx
Normal file
305
apps/pharmanager/src/routes/storage/add.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
apps/pharmanager/src/routes/storage/index.tsx
Normal file
182
apps/pharmanager/src/routes/storage/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1778999618092,
|
||||
"tag": "0000_clumsy_morgan_stark",
|
||||
"when": 1779522854247,
|
||||
"tag": "0000_normal_mephisto",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
7
packages/data-manager-sqlite/src/db-instance.ts
Normal file
7
packages/data-manager-sqlite/src/db-instance.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createDb } from './db'
|
||||
import { runMigrations } from './migrate'
|
||||
|
||||
const { db, sqlite } = createDb()
|
||||
runMigrations(sqlite)
|
||||
|
||||
export { db, sqlite }
|
||||
|
|
@ -22,3 +22,5 @@ export function createDb(options: DbOptions = {}) {
|
|||
|
||||
return { db, sqlite, sqlitePath }
|
||||
}
|
||||
|
||||
export type DrizzleDb = ReturnType<typeof drizzle>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
12
packages/data-manager-sqlite/src/lib/json-utils.ts
Normal file
12
packages/data-manager-sqlite/src/lib/json-utils.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
export * from './storageSpaces'
|
||||
export * from './storageSpacesSchema'
|
||||
|
|
|
|||
|
|
@ -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('[]'),
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export * from './query'
|
|||
export * from './store'
|
||||
export * from './provider'
|
||||
export * from './hooks/storageSpaces'
|
||||
export { trpc } from './trpc'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue