Phase 5 cleanup
This commit is contained in:
parent
5536acd67d
commit
76a516a417
136 changed files with 6322 additions and 1985 deletions
9
.github/copilot-instructions.md
vendored
9
.github/copilot-instructions.md
vendored
|
|
@ -36,6 +36,14 @@ The `docs/instructions/` directory contains best practices and conventions that
|
||||||
- **`docs/instructions/keycloak.md`** — Auth integration, JWT claims, guards, token refresh
|
- **`docs/instructions/keycloak.md`** — Auth integration, JWT claims, guards, token refresh
|
||||||
- **`docs/instructions/testing.md`** — Vitest unit/integration/E2E test patterns, coverage targets
|
- **`docs/instructions/testing.md`** — Vitest unit/integration/E2E test patterns, coverage targets
|
||||||
|
|
||||||
|
## Tool Usage Rules
|
||||||
|
|
||||||
|
- **Always use `read_file` to read file contents** — never use terminal commands like `Get-Content`, `cat`, `head`, or `tail` to read files. These will be denied.
|
||||||
|
- **Always use `grep_search` or `file_search` to find files and patterns** — never use `Select-String`, `grep`, `find`, or `rg` in terminal commands.
|
||||||
|
- **Run scripts only via `npm run <script>`** — never use `npx`, and never add flags like `2>&1`, pipes (`|`), or `Select-Object` to `npm run` commands.
|
||||||
|
- **Use `execution_subagent` for build/test/lint commands** — it runs `npm run build`, `npm run test`, and `npm run lint` and returns relevant output without piping.
|
||||||
|
- **Every implementation task must end with**: `npm run build`, `npm run test`, and `npm run lint` all passing.
|
||||||
|
|
||||||
## Key Rules
|
## Key Rules
|
||||||
|
|
||||||
1. **All domain types and Zod schemas live in `packages/shared`** — never duplicate types across packages.
|
1. **All domain types and Zod schemas live in `packages/shared`** — never duplicate types across packages.
|
||||||
|
|
@ -47,3 +55,4 @@ The `docs/instructions/` directory contains best practices and conventions that
|
||||||
7. **Cursor-based pagination** — never use `skip()` for large collections.
|
7. **Cursor-based pagination** — never use `skip()` for large collections.
|
||||||
8. **ESM everywhere** — `"type": "module"`, `.js` extensions on imports, `import type` for type-only imports.
|
8. **ESM everywhere** — `"type": "module"`, `.js` extensions on imports, `import type` for type-only imports.
|
||||||
9. **Zod v4** — import from `'zod/v4'`, use `z.enum()` for enums, `z.email()` / `z.url()` as top-level.
|
9. **Zod v4** — import from `'zod/v4'`, use `z.enum()` for enums, `z.email()` / `z.url()` as top-level.
|
||||||
|
10. **No `.js` extensions on `@/` imports in `packages/web`** — Next.js resolves TypeScript files directly; `.js` extensions on `@/` path-alias imports break Turbopack and webpack. Only use `.js` extensions in `packages/api` and `packages/shared` (Node ESM).
|
||||||
|
|
|
||||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"files.eol": "\n",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": "explicit"
|
"source.fixAll.eslint": "explicit"
|
||||||
},
|
},
|
||||||
|
|
@ -9,6 +10,13 @@
|
||||||
"eslint.workingDirectories": ["packages/api", "packages/shared", "packages/web"],
|
"eslint.workingDirectories": ["packages/api", "packages/shared", "packages/web"],
|
||||||
"chat.tools.terminal.enableAutoApprove": true,
|
"chat.tools.terminal.enableAutoApprove": true,
|
||||||
"chat.tools.terminal.autoApprove": {
|
"chat.tools.terminal.autoApprove": {
|
||||||
"/^npm run \\w+$/": true
|
"/^npm run \\w+$/": {
|
||||||
|
"approve": true,
|
||||||
|
"matchCommandLine": true
|
||||||
|
},
|
||||||
|
"Get-Content": false,
|
||||||
|
"Select-String": false,
|
||||||
|
"Select-Object": false,
|
||||||
|
"echo": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -9,6 +9,7 @@ MeshiTrack is a self-hosted medicine & nutrition management platform. It is a Ty
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Root (all packages via Turborepo)
|
### Root (all packages via Turborepo)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start all services in dev mode
|
npm run dev # Start all services in dev mode
|
||||||
npm run build # Build all packages (shared → api/web)
|
npm run build # Build all packages (shared → api/web)
|
||||||
|
|
@ -22,6 +23,7 @@ npm run seed # Seed the database (delegates to packages/api)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Single package
|
### Single package
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -w packages/api # Run API tests
|
npm run test -w packages/api # Run API tests
|
||||||
npm run test:cov -w packages/api # API tests with coverage
|
npm run test:cov -w packages/api # API tests with coverage
|
||||||
|
|
@ -30,6 +32,7 @@ npm run dev -w packages/api # API dev server only
|
||||||
```
|
```
|
||||||
|
|
||||||
### API package (packages/api)
|
### API package (packages/api)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # tsx watch src/main.ts
|
npm run dev # tsx watch src/main.ts
|
||||||
npm run build # tsc
|
npm run build # tsc
|
||||||
|
|
@ -37,6 +40,7 @@ npm run seed # tsx src/scripts/seed.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker/docker-compose.yml up -d # Start all services
|
docker compose -f docker/docker-compose.yml up -d # Start all services
|
||||||
docker compose -f docker/docker-compose.yml down # Stop all services
|
docker compose -f docker/docker-compose.yml down # Stop all services
|
||||||
|
|
@ -45,11 +49,13 @@ docker compose -f docker/docker-compose.yml down # Stop all services
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
|
|
||||||
- **`packages/shared`** — Single source of truth for all domain types, enums, and Zod v4 schemas. Consumed by both `api` and `web`. Must be pure TypeScript with no Node.js, browser, or framework dependencies.
|
- **`packages/shared`** — Single source of truth for all domain types, enums, and Zod v4 schemas. Consumed by both `api` and `web`. Must be pure TypeScript with no Node.js, browser, or framework dependencies.
|
||||||
- **`packages/api`** — Fastify 5 backend. ESM-only, TypeScript strict. Uses Awilix for DI, Mongoose 9 for MongoDB, jose 6 for JWT verification.
|
- **`packages/api`** — Fastify 5 backend. ESM-only, TypeScript strict. Uses Awilix for DI, Mongoose 9 for MongoDB, jose 6 for JWT verification.
|
||||||
- **`packages/web`** — Next.js 16 (React 19) frontend. App Router, Tailwind CSS 4.
|
- **`packages/web`** — Next.js 16 (React 19) frontend. App Router, Tailwind CSS 4.
|
||||||
|
|
||||||
### API Layer Architecture (Fastify + Awilix)
|
### API Layer Architecture (Fastify + Awilix)
|
||||||
|
|
||||||
The API follows a **routes → services → repositories** pattern with Awilix constructor-injection DI:
|
The API follows a **routes → services → repositories** pattern with Awilix constructor-injection DI:
|
||||||
|
|
||||||
- Each domain feature lives in `src/modules/<feature>/` with files: `*.routes.ts`, `*.service.ts`, `*.repository.ts`
|
- Each domain feature lives in `src/modules/<feature>/` with files: `*.routes.ts`, `*.service.ts`, `*.repository.ts`
|
||||||
|
|
@ -61,19 +67,23 @@ The API follows a **routes → services → repositories** pattern with Awilix c
|
||||||
Plugin registration order in `main.ts`: security → compression → swagger → DI container → database → auth → household guard → route modules.
|
Plugin registration order in `main.ts`: security → compression → swagger → DI container → database → auth → household guard → route modules.
|
||||||
|
|
||||||
### Domain Modules
|
### Domain Modules
|
||||||
|
|
||||||
**Medicine domain** (Phases 1-4): `medicines/`, `medicine-products/`, `cabinet/`, `regimens/`, `organizer/`, `medicine-prices/`, `purchases/`, `refills/`
|
**Medicine domain** (Phases 1-4): `medicines/`, `medicine-products/`, `cabinet/`, `regimens/`, `organizer/`, `medicine-prices/`, `purchases/`, `refills/`
|
||||||
**Food domain** (Phases 5-9): `products/`, `recipes/`, `pantry/`, `meal-plans/`, `grocery/`
|
**Food domain** (Phases 5-9): `products/`, `recipes/`, `pantry/`, `meal-plans/`, `grocery/`
|
||||||
**Shared**: `health/`, `users/`, `households/`, `stores/`, `llm/`
|
**Shared**: `health/`, `users/`, `households/`, `stores/`, `llm/`
|
||||||
|
|
||||||
### Multi-tenancy
|
### Multi-tenancy
|
||||||
|
|
||||||
Every domain document is scoped to a `householdId`. A Fastify `preHandler` hook validates the `householdId` from the URI against the user's `householdIds[]` JWT claim. **Every data query must filter by `householdId`.**
|
Every domain document is scoped to a `householdId`. A Fastify `preHandler` hook validates the `householdId` from the URI against the user's `householdIds[]` JWT claim. **Every data query must filter by `householdId`.**
|
||||||
|
|
||||||
Routes can opt out with `config: { public: true }` (skips auth) or `config: { skipHousehold: true }` (skips household validation).
|
Routes can opt out with `config: { public: true }` (skips auth) or `config: { skipHousehold: true }` (skips household validation).
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
|
|
||||||
Keycloak is the OIDC provider. The API verifies JWTs via `jose`. The custom Keycloak protocol mapper injects `householdIds[]` into the JWT claims.
|
Keycloak is the OIDC provider. The API verifies JWTs via `jose`. The custom Keycloak protocol mapper injects `householdIds[]` into the JWT claims.
|
||||||
|
|
||||||
### Shared Package Rules
|
### Shared Package Rules
|
||||||
|
|
||||||
- All domain types and Zod schemas live here — never duplicate types across packages
|
- All domain types and Zod schemas live here — never duplicate types across packages
|
||||||
- Import from `'zod/v4'` (not `'zod'`)
|
- Import from `'zod/v4'` (not `'zod'`)
|
||||||
- Use `z.enum()` for enums, `z.email()` / `z.url()` as top-level calls
|
- Use `z.enum()` for enums, `z.email()` / `z.url()` as top-level calls
|
||||||
|
|
@ -81,12 +91,15 @@ Keycloak is the OIDC provider. The API verifies JWTs via `jose`. The custom Keyc
|
||||||
- Use `import type` for type-only imports
|
- Use `import type` for type-only imports
|
||||||
|
|
||||||
### Pagination
|
### Pagination
|
||||||
|
|
||||||
All list endpoints use cursor-based pagination. **Never use `skip()`** on MongoDB queries. Response shape:
|
All list endpoints use cursor-based pagination. **Never use `skip()`** on MongoDB queries. Response shape:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{ data: T[], pagination: { cursor: string | null, hasMore: boolean, total?: number } }
|
{ data: T[], pagination: { cursor: string | null, hasMore: boolean, total?: number } }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
Services throw `AppError` subclasses (`NotFoundError`, `ConflictError`, `ForbiddenError`, etc.). The global Fastify error handler maps them to the standard `ApiError` response shape (`statusCode`, `error`, `message`, `timestamp`, `path`).
|
Services throw `AppError` subclasses (`NotFoundError`, `ConflictError`, `ForbiddenError`, etc.). The global Fastify error handler maps them to the standard `ApiError` response shape (`statusCode`, `error`, `message`, `timestamp`, `path`).
|
||||||
|
|
||||||
## Key Rules
|
## Key Rules
|
||||||
|
|
@ -99,6 +112,10 @@ Services throw `AppError` subclasses (`NotFoundError`, `ConflictError`, `Forbidd
|
||||||
6. **`householdId` filter** on every domain query — this is the multi-tenancy boundary
|
6. **`householdId` filter** on every domain query — this is the multi-tenancy boundary
|
||||||
7. **No emojis** — never use emoji characters in source code, UI text, console output, or documentation
|
7. **No emojis** — never use emoji characters in source code, UI text, console output, or documentation
|
||||||
8. **`npx` is banned** — never run `npx` for any reason. Use `npm run <script>` for all test, lint, build, and tool invocations. No exceptions.
|
8. **`npx` is banned** — never run `npx` for any reason. Use `npm run <script>` for all test, lint, build, and tool invocations. No exceptions.
|
||||||
|
9. **Never pipe or redirect `npm run` commands** — run `npm run <script>` exactly as written; never append `2>&1`, `|`, `Select-Object`, `Select-String`, or any other shell constructs to it.
|
||||||
|
10. **Never use shell commands to read files** — always use `read_file` tool. Commands like `Get-Content`, `cat`, `head`, `tail` will be denied.
|
||||||
|
11. **Never use shell commands to search** — always use `grep_search` or `file_search`. Commands like `Select-String`, `grep`, `rg`, `find` will be denied.
|
||||||
|
12. **Every implementation task must end with `npm run build`, `npm run test`, and `npm run lint` all passing.**
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|
@ -113,6 +130,7 @@ Services throw `AppError` subclasses (`NotFoundError`, `ConflictError`, `Forbidd
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Before writing code, consult the relevant docs:
|
Before writing code, consult the relevant docs:
|
||||||
|
|
||||||
- `docs/instructions/` — coding conventions, Fastify patterns, Next.js patterns, MongoDB, Zod/TypeScript, testing, Docker, Keycloak, Turborepo
|
- `docs/instructions/` — coding conventions, Fastify patterns, Next.js patterns, MongoDB, Zod/TypeScript, testing, Docker, Keycloak, Turborepo
|
||||||
- `docs/phases/` — per-phase specs with schemas, endpoints, and business logic
|
- `docs/phases/` — per-phase specs with schemas, endpoints, and business logic
|
||||||
- `docs/architecture.md` — ADRs explaining key technology choices
|
- `docs/architecture.md` — ADRs explaining key technology choices
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ services:
|
||||||
KC_HOSTNAME_URL: http://localhost:8080
|
KC_HOSTNAME_URL: http://localhost:8080
|
||||||
command: start-dev --import-realm
|
command: start-dev --import-realm
|
||||||
volumes:
|
volumes:
|
||||||
|
- keycloak-data:/opt/keycloak/data
|
||||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
|
|
@ -137,3 +138,4 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongo-data:
|
mongo-data:
|
||||||
|
keycloak-data:
|
||||||
|
|
|
||||||
247
docs/frontend-redesign-plan.md
Normal file
247
docs/frontend-redesign-plan.md
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
# Frontend Redesign Plan — Aligning with `docs/design` Mockups
|
||||||
|
|
||||||
|
This plan describes how to evolve `packages/web` from the current minimal Tailwind UI to match the editorial, warm-neutral design defined in `docs/design/MeshiTrack.html` and the screenshots under `docs/design/screenshots/`.
|
||||||
|
|
||||||
|
The mockups are a React (UMD + Babel) prototype using a CSS-token system. We will port the **visual language and information architecture** into the existing **Next.js App Router + Tailwind v4** project, without dragging the prototype's runtime (React UMD, global `window.*` modules, inline data) into production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals & Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
- Match the visual language of the mockups: typography (Fraunces / Inter Tight / JetBrains Mono), warm-neutral palette, sage brand, generous whitespace, soft shadows, rounded cards.
|
||||||
|
- Reproduce the **app shell**: persistent left sidebar with grouped navigation + sticky topbar with title/subtitle, search, theme toggle, notifications.
|
||||||
|
- Reproduce the **page archetypes** shown in the mockups: editorial "hero" header, multi-card grid with consistent `mt-card` style, status pills, supply bars, mini SVG charts.
|
||||||
|
- Support light/dark themes and an accent switcher (sage / cobalt / terracotta / graphite) via CSS custom properties.
|
||||||
|
- Keep accessibility, responsive layout, and SSR-friendliness intact.
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
- Do not import the prototype's `data.jsx` mock data into production.
|
||||||
|
- Do not adopt the prototype's `window.*` global module pattern or Babel-in-browser runtime.
|
||||||
|
- Do not redesign data flows or API contracts; only the presentation layer changes.
|
||||||
|
- Do not block on building every page in the mockup; food-domain pages (Phases 5-9) will reuse the system once the medicine pages are migrated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gap Analysis
|
||||||
|
|
||||||
|
| Area | Current state (`packages/web`) | Target (mockups) |
|
||||||
|
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
|
||||||
|
| Theme tokens | Tailwind v4 `@theme` with a green ramp only | Full token set: warm neutrals, brand sage, status, viz palette, radii, shadows, fonts; light + dark |
|
||||||
|
| Typography | System sans only | Fraunces (display), Inter Tight (UI), JetBrains Mono (numerics/keys), tabular-nums utility |
|
||||||
|
| Sidebar | Flat list, 3 links, no groups, no badges | Grouped (`Medicines` section), icons, active state, badges, footer with avatar/role |
|
||||||
|
| TopBar | Household chip + avatar | Breadcrumbs, title + subtitle, search with `⌘K`, notifications, theme toggle |
|
||||||
|
| Cards | Ad-hoc Tailwind cards | Reusable `Card`, `CardHeader`, `Pill`, `Button` (`primary`/`ghost`), supply bar, ring chart, sparkbar |
|
||||||
|
| Dashboard | 2 link cards | Hero greeting + ring, Today's schedule, Running low, Spending bars, Days-of-supply, Pending orders, Recent activity |
|
||||||
|
| Cabinet / Schedule / Regimens / Organizer / Library / Refills / Purchases / Prices / Stores / Activity | Mostly placeholder grids/tables | Editorial layouts with status chips, swatches, supply bars (see screenshots) |
|
||||||
|
| Theming | None | `data-theme="light | dark"`on`<html>`, accent CSS vars |
|
||||||
|
| Icons | None defined centrally | Inline SVG icon set (`Icon.jsx` in mockup) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Target Architecture
|
||||||
|
|
||||||
|
### 3.1 Design tokens (Tailwind v4 `@theme` + CSS vars)
|
||||||
|
|
||||||
|
- Replace the current `@theme` block in [packages/web/src/styles/globals.css](packages/web/src/styles/globals.css) with the full token set from [docs/design/styles/tokens.css](docs/design/styles/tokens.css):
|
||||||
|
- Color tokens exposed both as CSS vars (`--bg`, `--ink`, `--brand`, ...) **and** as Tailwind theme colors (`--color-bg`, `--color-ink`, ...) so utilities like `bg-bg`, `text-ink`, `border-border` work.
|
||||||
|
- Radii (`--r-xs`..`--r-xl`) and shadows (`--shadow-sm/md/lg`).
|
||||||
|
- Font family vars + load Fraunces / Inter Tight / JetBrains Mono via `next/font/google` in [packages/web/src/app/layout.tsx](packages/web/src/app/layout.tsx) and bind them to the `--font-*` vars.
|
||||||
|
- Add `[data-theme='dark']` overrides in `globals.css` (copy from tokens.css).
|
||||||
|
- Add small global utility classes used by the mockup: `.mono`, `.serif`, `.num` (tabular-nums) — keep names so mockup snippets can be lifted with minimal edits.
|
||||||
|
|
||||||
|
### 3.2 Theme + accent provider
|
||||||
|
|
||||||
|
- New `ThemeProvider` (client component) that:
|
||||||
|
- Persists `theme` (`light|dark`) and `accent` (`sage|cobalt|terracotta|graphite`) in `localStorage`.
|
||||||
|
- Sets `data-theme` on `document.documentElement` and writes the accent's `--brand`, `--brand-deep`, `--brand-soft`, `--brand-soft-ink`, `--viz-1` CSS vars (logic ported from `docs/design/MeshiTrack.html` `useEffect`).
|
||||||
|
- Avoids FOUC by emitting a tiny inline script in `app/layout.tsx` that reads `localStorage` and applies `data-theme` before hydration.
|
||||||
|
- Expose a `useTheme()` hook for the topbar toggle.
|
||||||
|
|
||||||
|
### 3.3 Component library (`packages/web/src/components/ui/`)
|
||||||
|
|
||||||
|
Port the mockup's reusable primitives as typed React components:
|
||||||
|
|
||||||
|
- `Icon` — single component over an inline SVG map (`dashboard`, `cabinet`, `clock`, `list`, `calendar`, `pill`, `refresh`, `truck`, `tag`, `store`, `trend`, `settings`, `search`, `bell`, `sun`, `moon`, `chev`, `arrow`, `check`, `injection`, `capsule`).
|
||||||
|
- `Card`, `CardHeader` (title + sub + right-side action slot).
|
||||||
|
- `Button` with `variant: 'primary' | 'ghost' | 'danger'` and optional `size`.
|
||||||
|
- `Pill` (status chip) with `tone: 'ok' | 'warn' | 'danger' | 'info' | 'neutral'`.
|
||||||
|
- `SupplyBar`, `Ring` (SVG progress ring), `SparkBars` (monthly bars), `Sparkline`.
|
||||||
|
- `Kbd`, `IconButton`, `Avatar`, `SearchInput`, `Breadcrumbs`.
|
||||||
|
|
||||||
|
These live in `packages/web/src/components/ui/` and are pure presentational components (no data fetching, no `'use client'` unless they need state — most do not).
|
||||||
|
|
||||||
|
### 3.4 App shell
|
||||||
|
|
||||||
|
Rewrite the layout primitives:
|
||||||
|
|
||||||
|
- [packages/web/src/components/layout/Sidebar.tsx](packages/web/src/components/layout/Sidebar.tsx):
|
||||||
|
- Brand block (logo + wordmark + household sub-line — `household.name` instead of mock "Red Panda Den").
|
||||||
|
- Grouped nav from a `NAV` array mirroring the mockup's structure but driven by `next/navigation`'s `usePathname` for active state.
|
||||||
|
- Footer with `Avatar` + `displayName` + role from `useApi()`.
|
||||||
|
- Badges (e.g. low-supply count, pending refills) wired to real SWR fetchers (Phase 2 — initially render the slot but optional).
|
||||||
|
- [packages/web/src/components/layout/TopBar.tsx](packages/web/src/components/layout/TopBar.tsx):
|
||||||
|
- Accept `title`, `subtitle`, `crumbs`, `actions` props. Provided either via a `<PageHeader>` component each route renders, **or** via a Zustand/Context "page header" store updated from each page (recommended: a server-component `PageHeader` slot rendered above the page body — simpler, no client state).
|
||||||
|
- Search input (visual only at first; `⌘K` palette is a follow-up).
|
||||||
|
- Theme toggle wired to `useTheme()`.
|
||||||
|
- Notifications bell (visual only initially).
|
||||||
|
- [packages/web/src/app/(dashboard)/layout.tsx](<packages/web/src/app/(dashboard)/layout.tsx>):
|
||||||
|
- Switch root container to the mockup's grid: `grid-template-columns: 248px 1fr; min-height: 100vh; background: var(--bg)`.
|
||||||
|
- Inner `<main>` becomes `mt-page` with the mockup's padding (`32px` desktop, responsive down).
|
||||||
|
|
||||||
|
### 3.5 Page layout pattern
|
||||||
|
|
||||||
|
Each route renders:
|
||||||
|
|
||||||
|
```
|
||||||
|
<PageHeader title="..." subtitle="..." crumbs={[...]} actions={...} />
|
||||||
|
<PageContent>...cards / grid...</PageContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
`PageHeader` is a server component that renders the topbar's middle area (title block + breadcrumbs). The sticky outer `TopBar` reads `children` via React's `slots` pattern: simplest is to make `TopBar` itself accept `title/subtitle/crumbs` and have pages set them via a thin `PageHeaderContext` (client provider) or via Next's `template.tsx` + parallel routes. Choose the **Context approach** for minimal churn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Page-by-Page Mapping
|
||||||
|
|
||||||
|
For each route, port the layout from the mockup's JSX while replacing mock data with the existing service hooks.
|
||||||
|
|
||||||
|
| Route | Current file | Mockup source | Notes |
|
||||||
|
| ---------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `/dashboard` | [dashboard/page.tsx](<packages/web/src/app/(dashboard)/dashboard/page.tsx>) | [Dashboard.jsx](docs/design/src/Dashboard.jsx) | Hero + Ring, Today's schedule, Running low, Spending, Supply, Pending orders, Activity. Many cards depend on data we don't yet expose; render skeletons / "Coming soon" states for missing endpoints. |
|
||||||
|
| `/medicines/cabinet` | `medicines/cabinet/` | [Cabinet.jsx](docs/design/src/Cabinet.jsx) | Grid of medicine cards with swatch, supply bar, status pill. |
|
||||||
|
| `/medicines/regimens` | `medicines/regimens/` | `RegimensPage` in [OtherPages.jsx](docs/design/src/OtherPages.jsx) | Per-regimen rows with schedule chips. |
|
||||||
|
| `/medicines/organizer` | `medicines/organizer/` | [Schedule.jsx](docs/design/src/Schedule.jsx) `OrganizerPage` | 7-day x slots grid. |
|
||||||
|
| `/medicines/library` | `medicines/library/` | `LibraryPage` | Filterable medicine table. |
|
||||||
|
| `/medicines/activity` | `medicines/activity/` | `ActivityPage` | Activity feed + spend summary. |
|
||||||
|
| `/refills` (new) | — | `RefillsPage` | Add route under `(dashboard)/refills/`. |
|
||||||
|
| `/purchases` | `purchases/` | `PurchasesPage` | List with status pills. |
|
||||||
|
| `/medicine-prices` | `medicine-prices/` | `PricesPage` | Per-store price comparison. |
|
||||||
|
| `/stores` | `stores/` | `StoresPage` | Store cards. |
|
||||||
|
| `/settings` | `settings/` | `SettingsPage` | Household + account sections. |
|
||||||
|
|
||||||
|
Also add a **Schedule & Log** page (`/schedule` or `/medicines/schedule`) per mockup — currently missing.
|
||||||
|
|
||||||
|
Update [medicines/page.tsx](<packages/web/src/app/(dashboard)/medicines/page.tsx>) (and [dashboard/page.tsx](<packages/web/src/app/(dashboard)/dashboard/page.tsx>) if kept) to use `Card`/`CardHeader` instead of ad-hoc Tailwind classes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phased Rollout
|
||||||
|
|
||||||
|
Treat this as 5 PR-sized increments, each independently shippable.
|
||||||
|
|
||||||
|
### Phase A — Foundations (no visible page changes yet)
|
||||||
|
|
||||||
|
1. Port tokens.css into `globals.css` (CSS vars + Tailwind v4 `@theme` mappings + dark overrides).
|
||||||
|
2. Wire fonts via `next/font/google` in `app/layout.tsx`; bind to `--font-*` vars.
|
||||||
|
3. Add `ThemeProvider` + FOUC-safe inline script.
|
||||||
|
4. Add `Icon` component and the icon set.
|
||||||
|
5. Add UI primitives: `Card`, `CardHeader`, `Button`, `Pill`, `IconButton`, `Avatar`, `Kbd`, `SearchInput`, `Breadcrumbs`, `Ring`, `SupplyBar`, `SparkBars`.
|
||||||
|
6. Vitest unit tests + RTL smoke tests for each primitive.
|
||||||
|
|
||||||
|
### Phase B — App shell
|
||||||
|
|
||||||
|
1. Rewrite `Sidebar` with grouped nav, icons, active-route detection via `usePathname`, footer.
|
||||||
|
2. Rewrite `TopBar` with title/subtitle/crumbs slots, search, theme toggle, notifications.
|
||||||
|
3. Add `PageHeaderContext` (or a header slot mechanism) so pages can set the topbar content.
|
||||||
|
4. Update `(dashboard)/layout.tsx` grid + `<main>` styling.
|
||||||
|
5. Snapshot/RTL tests for shell.
|
||||||
|
|
||||||
|
### Phase C — Dashboard
|
||||||
|
|
||||||
|
1. Re-implement `/dashboard` with hero + ring + cards using real data where available, skeletons elsewhere.
|
||||||
|
2. Wire "Running low" to existing cabinet endpoint; "Pending orders" to purchases; "Activity" to activity endpoint.
|
||||||
|
3. Tests: render with seeded SWR cache.
|
||||||
|
|
||||||
|
### Phase D — Medicine pages
|
||||||
|
|
||||||
|
1. Port Cabinet, Library, Regimens, Organizer, Activity, Schedule & Log one at a time.
|
||||||
|
2. Each PR: lift JSX from the mockup, replace mock data with `services/*` SWR hooks, add tests.
|
||||||
|
|
||||||
|
### Phase E — Commerce + settings
|
||||||
|
|
||||||
|
1. Port Refills (new route), Purchases, Prices, Stores, Settings.
|
||||||
|
2. Add accent picker UI in Settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Tailwind v4 Token Mapping (concrete snippet)
|
||||||
|
|
||||||
|
In [packages/web/src/styles/globals.css](packages/web/src/styles/globals.css):
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Inter Tight', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-display: 'Fraunces', ui-serif, Georgia, serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||||
|
|
||||||
|
--color-bg: var(--bg);
|
||||||
|
--color-bg-elev: var(--bg-elev);
|
||||||
|
--color-bg-inset: var(--bg-inset);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-border-strong: var(--border-strong);
|
||||||
|
--color-ink: var(--ink);
|
||||||
|
--color-ink-strong: var(--ink-strong);
|
||||||
|
--color-ink-muted: var(--ink-muted);
|
||||||
|
--color-ink-faint: var(--ink-faint);
|
||||||
|
--color-brand: var(--brand);
|
||||||
|
--color-brand-deep: var(--brand-deep);
|
||||||
|
--color-brand-soft: var(--brand-soft);
|
||||||
|
--color-danger: var(--danger);
|
||||||
|
--color-warn: var(--warn);
|
||||||
|
--color-ok: var(--ok);
|
||||||
|
--color-info: var(--info);
|
||||||
|
|
||||||
|
--radius-xs: var(--r-xs);
|
||||||
|
--radius-sm: var(--r-sm);
|
||||||
|
--radius-md: var(--r-md);
|
||||||
|
--radius-lg: var(--r-lg);
|
||||||
|
--radius-xl: var(--r-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* paste the :root block from docs/design/styles/tokens.css */
|
||||||
|
}
|
||||||
|
[data-theme='dark'] {
|
||||||
|
/* paste the dark block */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets us write `bg-bg-elev text-ink-strong border-border rounded-lg shadow-md` while still being able to use the raw `var(--brand)` inside inline SVGs (matching the mockup's pattern).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| FOUC on theme switch / SSR mismatch | Inline script in `<head>` reads `localStorage` and sets `data-theme` before React hydrates. Mark theme-dependent UI as client-only when needed. |
|
||||||
|
| Mockup uses inline `style={{...var(--viz-X)...}}` heavily | Keep that pattern in components; tokens are CSS vars so this works fine. |
|
||||||
|
| Real data shape differs from mock data | For each card, define a small typed view-model and a transformer from API DTO; render skeletons when data is unavailable. |
|
||||||
|
| Tailwind v4 `@theme` + custom CSS vars interplay | Verified above: declare CSS vars in `:root`/`[data-theme=dark]`, then map them to `--color-*` inside `@theme` so utilities pick them up. |
|
||||||
|
| Test snapshots churn | Co-locate component tests, prefer RTL queries by role/text over snapshots; use snapshots only for the icon set. |
|
||||||
|
| Scope creep | Strictly follow the phased rollout; each phase is independently mergeable. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out-of-Scope Follow-ups
|
||||||
|
|
||||||
|
- `⌘K` command palette (search input is visual-only initially).
|
||||||
|
- Real notification feed.
|
||||||
|
- Per-user accent persistence in DB (initially `localStorage` only).
|
||||||
|
- Mobile drawer for the sidebar (mockup is desktop-first; add a `< 900px` breakpoint that collapses to a top hamburger).
|
||||||
|
- Animations beyond the existing CSS transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Definition of Done (per phase)
|
||||||
|
|
||||||
|
- All new components have unit tests; coverage for `packages/web` does not regress.
|
||||||
|
- `npm run lint` and `npm run typecheck` pass at the workspace root.
|
||||||
|
- Visual parity verified against the relevant screenshot in `docs/design/screenshots/`.
|
||||||
|
- No usage of `any`; all view-models typed; all client-only files marked `'use client'`.
|
||||||
|
- No regressions in existing E2E tests.
|
||||||
|
|
@ -16,6 +16,12 @@
|
||||||
6. LLM provider interface (`ILlmProvider`) with no-op implementation
|
6. LLM provider interface (`ILlmProvider`) with no-op implementation
|
||||||
7. "Smart Add" endpoint placeholder
|
7. "Smart Add" endpoint placeholder
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- **Metric-only storage**: Products store nutrition relative to a metric serving (`g`, `ml`) or a discrete unit (`piece`, `slice`). Imperial/volume cooking units (oz, cup, tbsp, tsp) are a recipe-input concern and are normalized to metric in Phase 6 before persistence. This keeps nutrition math density-free at the product level.
|
||||||
|
- **Per-household catalog**: Every product is owned by exactly one household. There is no cross-household sharing in this phase; a global/public catalog can be added later via an explicit seed dataset.
|
||||||
|
- **Soft delete**: Deletes set `deletedAt` rather than removing rows, so historical recipes/pantry/grocery references stay resolvable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
@ -29,18 +35,19 @@ export interface Product {
|
||||||
householdId: string;
|
householdId: string;
|
||||||
name: string;
|
name: string;
|
||||||
brand?: string;
|
brand?: string;
|
||||||
barcode?: string;
|
barcode?: string; // EAN-13 / UPC-A, digits only
|
||||||
category: ProductCategory;
|
category: ProductCategory;
|
||||||
servingSize: number;
|
servingSize: number; // quantity of one serving in `servingUnit`
|
||||||
servingUnit: ServingUnit;
|
servingUnit: ServingUnit; // metric or discrete only
|
||||||
nutrition: NutritionInfo;
|
densityGPerMl?: number; // optional, used by Phase 6 to convert volume cooking units
|
||||||
|
nutrition: NutritionInfo; // values are PER serving (size = servingSize servingUnit)
|
||||||
tags: string[];
|
tags: string[];
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
isPublic: boolean; // Visible to all households (for shared catalog)
|
source: ProductSource;
|
||||||
source: ProductSource; // 'manual' | 'barcode_lookup' | 'llm' | 'import'
|
|
||||||
createdBy: string; // userId
|
createdBy: string; // userId
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date; // soft delete
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NutritionInfo {
|
export interface NutritionInfo {
|
||||||
|
|
@ -81,14 +88,14 @@ export enum ProductCategory {
|
||||||
export enum ServingUnit {
|
export enum ServingUnit {
|
||||||
GRAMS = 'g',
|
GRAMS = 'g',
|
||||||
MILLILITERS = 'ml',
|
MILLILITERS = 'ml',
|
||||||
OUNCES = 'oz',
|
|
||||||
CUPS = 'cup',
|
|
||||||
TABLESPOONS = 'tbsp',
|
|
||||||
TEASPOONS = 'tsp',
|
|
||||||
PIECES = 'piece',
|
PIECES = 'piece',
|
||||||
SLICES = 'slice',
|
SLICES = 'slice',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: imperial/volume cooking units (oz, cup, tbsp, tsp) are intentionally
|
||||||
|
// excluded. Recipes may receive them as input in Phase 6 and convert to metric
|
||||||
|
// before persisting. See `phase-6-recipes.md` for the conversion rules.
|
||||||
|
|
||||||
export enum ProductSource {
|
export enum ProductSource {
|
||||||
MANUAL = 'manual',
|
MANUAL = 'manual',
|
||||||
BARCODE_LOOKUP = 'barcode_lookup',
|
BARCODE_LOOKUP = 'barcode_lookup',
|
||||||
|
|
@ -104,11 +111,13 @@ export enum ProductSource {
|
||||||
{ name: 'text', brand: 'text', tags: 'text' }
|
{ name: 'text', brand: 'text', tags: 'text' }
|
||||||
|
|
||||||
// Compound indexes
|
// Compound indexes
|
||||||
{ householdId: 1, category: 1 }
|
{ householdId: 1, deletedAt: 1, category: 1 }
|
||||||
{ householdId: 1, barcode: 1 } // unique within household
|
{ householdId: 1, barcode: 1 } // partial index where barcode exists & deletedAt is null; unique within household
|
||||||
{ householdId: 1, name: 1, brand: 1 } // near-unique for dedup
|
{ householdId: 1, name: 1, brand: 1 } // near-unique for dedup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All list/search queries filter `deletedAt: { $exists: false }` (or `null`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
@ -126,15 +135,22 @@ export enum ProductSource {
|
||||||
| POST | `/products/import` | Bulk import from CSV/JSON | admin |
|
| POST | `/products/import` | Bulk import from CSV/JSON | admin |
|
||||||
| POST | `/products/smart-add` | LLM-powered add from text/image | member |
|
| POST | `/products/smart-add` | LLM-powered add from text/image | member |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `DELETE /products/:id` is a soft delete; the product is hidden from listings but remains resolvable by id for historical references in recipes, pantry, and grocery.
|
||||||
|
- `POST /products` rejects payloads whose `barcode` collides with an existing non-deleted product in the household (409 `ConflictError`).
|
||||||
|
|
||||||
### Query Parameters for GET `/products`
|
### Query Parameters for GET `/products`
|
||||||
|
|
||||||
```
|
```
|
||||||
?q=chicken # Full-text search
|
?q=chicken # Full-text search (name, brand, tags)
|
||||||
&category=meat # Filter by category
|
&category=meat # Filter by category
|
||||||
&tags=organic,fresh # Filter by tags (AND)
|
&tags=organic,fresh # Filter by tags (AND)
|
||||||
&cursor=abc123 # Cursor-based pagination
|
&barcode=0123456789012 # Exact barcode match
|
||||||
|
&includeDeleted=false # Default false; admins can pass true
|
||||||
|
&cursor=abc123 # Cursor-based pagination (opaque)
|
||||||
&limit=20 # Page size (max 100)
|
&limit=20 # Page size (max 100)
|
||||||
&sort=name|-updatedAt # Sort field, prefix - for desc
|
&sort=name|-updatedAt # Sort field, prefix - for desc; default -updatedAt
|
||||||
```
|
```
|
||||||
|
|
||||||
### Response Shape
|
### Response Shape
|
||||||
|
|
@ -157,30 +173,40 @@ interface PaginatedResponse<T> {
|
||||||
### 5.1 — Shared Types & Validation
|
### 5.1 — Shared Types & Validation
|
||||||
|
|
||||||
- Add all types above to `packages/shared/src/types/product.ts`
|
- Add all types above to `packages/shared/src/types/product.ts`
|
||||||
- Add enums to `packages/shared/src/enums/`
|
- Add enums to `packages/shared/src/enums/product.enums.ts` (`ProductCategory`, `ServingUnit`, `ProductSource`)
|
||||||
- Create Zod schemas:
|
- Create Zod schemas in `packages/shared/src/validation/product.validation.ts`:
|
||||||
- `CreateProductSchema` — validates create payload
|
- `CreateProductSchema` — validates create payload; `servingSize > 0`; `nutrition` macros `>= 0`; `barcode` matches `/^\d{8,14}$/`
|
||||||
- `UpdateProductSchema` — partial, validates update payload
|
- `UpdateProductSchema` — `CreateProductSchema.partial()`
|
||||||
- `ProductQuerySchema` — validates query params
|
- `ProductQuerySchema` — validates query params; `limit` clamped to `[1, 100]`, default 20
|
||||||
|
- `ImportProductsSchema` — array of `CreateProductSchema` (for JSON import)
|
||||||
|
- All schemas imported from `'zod/v4'`; enums use `z.enum(Object.values(...))`.
|
||||||
|
|
||||||
### 5.2 — Mongoose Schema & Repository
|
### 5.2 — Mongoose Schema & Repository
|
||||||
|
|
||||||
- `packages/api/src/modules/products/schemas/product.schema.ts`
|
- `packages/api/src/modules/products/schemas/product.schema.ts` — Mongoose schema with `timestamps: true`, `deletedAt` index, partial unique index on `(householdId, barcode)`.
|
||||||
- `ProductRepository` with:
|
- `ProductRepository` with:
|
||||||
- `findByHousehold(householdId, query)` — supports text search, filters, cursor pagination
|
- `findByHousehold(householdId, query)` — text search, filters, cursor pagination, excludes soft-deleted
|
||||||
- `findByBarcode(householdId, barcode)`
|
- `findById(id, householdId)` — also returns soft-deleted (for historical resolution)
|
||||||
|
- `findByBarcode(householdId, barcode)` — excludes soft-deleted
|
||||||
|
- `findByIds(householdId, ids[])` — batch fetch for recipe/pantry resolution
|
||||||
- `create(data)`
|
- `create(data)`
|
||||||
- `update(id, householdId, data)`
|
- `update(id, householdId, data)`
|
||||||
- `softDelete(id, householdId)`
|
- `softDelete(id, householdId)` — sets `deletedAt`
|
||||||
- `bulkCreate(items[])`
|
- `bulkCreate(householdId, items[])` — uses `insertMany` with `ordered: false`
|
||||||
|
- All read queries use `.lean().exec()`.
|
||||||
|
|
||||||
### 5.3 — Barcode Lookup Service
|
### 5.3 — Barcode Lookup Service
|
||||||
|
|
||||||
- `BarcodeService`:
|
- `BarcodeService`:
|
||||||
- First check local DB for matching barcode
|
- First check local DB for matching barcode (per household)
|
||||||
- If not found, query Open Food Facts API (`https://world.openfoodfacts.org/api/v2/product/{barcode}`)
|
- If not found, call Open Food Facts API: `https://world.openfoodfacts.org/api/v2/product/{barcode}`
|
||||||
- Map OFF response to `Product` shape
|
- Map OFF response to `Product` shape:
|
||||||
- Cache results in local DB with `source: 'barcode_lookup'`
|
- `product_name` → `name`; `brands` (first) → `brand`; `categories_tags` → derived `ProductCategory`
|
||||||
|
- Nutrition normalized to per-serving (`g` or `ml`) using `serving_size` / `serving_quantity` from OFF; fall back to per-100g if absent
|
||||||
|
- Drop fields with no usable value (do not fabricate zeroes)
|
||||||
|
- Cache result in local DB with `source: 'barcode_lookup'`, owned by the requesting household
|
||||||
|
- Failures (network, 404, malformed): return `{ found: false }`; do not throw
|
||||||
|
- Outbound HTTP via `undici` with a 5s timeout and a configurable User-Agent (`MeshiTrack/<version> (+self-hosted)`)
|
||||||
|
|
||||||
### 5.4 — LLM Provider Interface
|
### 5.4 — LLM Provider Interface
|
||||||
|
|
||||||
|
|
@ -214,38 +240,46 @@ export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
|
||||||
|
|
||||||
### 5.6 — Import Endpoint
|
### 5.6 — Import Endpoint
|
||||||
|
|
||||||
- `POST /products/import` accepts multipart CSV or JSON file
|
- `POST /products/import` accepts multipart CSV or JSON file (max 5 MB, 5000 rows)
|
||||||
- Validate each row against `CreateProductSchema`
|
- Validate each row against `CreateProductSchema`; reject rows with imperial `servingUnit` values with a clear error message
|
||||||
- Return summary: `{ imported: N, skipped: M, errors: [...] }`
|
- De-dup by `(householdId, barcode)` and `(householdId, name, brand)`; existing matches are reported as `skipped`
|
||||||
- CSV column mapping: `name, brand, barcode, category, servingSize, servingUnit, calories, protein, carbs, fat, ...`
|
- Return summary: `{ imported: N, skipped: M, errors: [{ row, message }] }`
|
||||||
|
- CSV column mapping: `name, brand, barcode, category, servingSize, servingUnit, densityGPerMl, calories, protein, carbs, fat, fiber, sugar, sodium, saturatedFat, cholesterol, tags`
|
||||||
|
- `tags` is a `;`-separated list
|
||||||
|
- `servingUnit` ∈ `{g, ml, piece, slice}`
|
||||||
|
|
||||||
### 5.7 — Web UI: Product Library
|
### 5.7 — Web UI: Product Library
|
||||||
|
|
||||||
- `/products` page:
|
- `/products` page (Server Component for initial fetch; client island for filters):
|
||||||
- Search bar with debounced full-text search
|
- Search bar with debounced full-text search (300ms)
|
||||||
- Category filter dropdown
|
- Category filter dropdown
|
||||||
- Tag filter chips
|
- Tag filter chips
|
||||||
- Product grid/list view (toggle)
|
- Product grid/list view (toggle, persisted in `localStorage`)
|
||||||
- Each product card shows: name, brand, category icon, calories/serving
|
- Each product card shows: name, brand, category icon, calories per serving, serving (`100 g`, `250 ml`, `1 piece`)
|
||||||
- Add/Edit product modal:
|
- Add/Edit product modal:
|
||||||
- Form fields for all product properties
|
- Form fields for all product properties; `servingUnit` select limited to `g | ml | piece | slice`
|
||||||
- Nutrition input section with per-serving values
|
- Nutrition input section with per-serving values
|
||||||
- Barcode field with "Lookup" button
|
- Optional `densityGPerMl` field (only relevant for liquids/pastes)
|
||||||
|
- Barcode field with "Lookup" button (calls `/products/barcode/:code`)
|
||||||
- "Smart Add" tab (text input or image upload)
|
- "Smart Add" tab (text input or image upload)
|
||||||
- Import dialog: file upload with preview and error display
|
- Import dialog: file upload with preview, row count, and error display
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Can create, read, update, delete products via API
|
- [ ] Can create, read, update, soft-delete products via API
|
||||||
- [ ] Full-text search returns relevant results
|
- [ ] Soft-deleted products remain resolvable by id but excluded from listings
|
||||||
- [ ] Barcode lookup fetches from Open Food Facts when not in local DB
|
- [ ] `ServingUnit` is restricted to `g | ml | piece | slice`; imperial values are rejected at validation
|
||||||
- [ ] Bulk import processes a CSV with 100+ products
|
- [ ] Full-text search returns relevant results across name, brand, tags
|
||||||
|
- [ ] Barcode lookup fetches from Open Food Facts when not in local DB and caches the result
|
||||||
|
- [ ] Barcode collisions within a household return 409
|
||||||
|
- [ ] Bulk import processes a CSV with 100+ products and reports per-row errors
|
||||||
- [ ] Web UI allows searching, filtering, adding, and editing products
|
- [ ] Web UI allows searching, filtering, adding, and editing products
|
||||||
- [ ] `ILlmProvider` interface is defined and injectable
|
- [ ] `ILlmProvider` interface is defined and injectable
|
||||||
- [ ] Smart Add endpoint returns graceful "not available" with NoOp provider
|
- [ ] Smart Add endpoint returns graceful `{ available: false }` with the NoOp provider
|
||||||
- [ ] All product queries are scoped to `householdId`
|
- [ ] All product queries are scoped to `householdId`
|
||||||
|
- [ ] Unit + integration tests meet coverage targets (100% lines/functions/statements, 90% branches)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,21 @@ export interface Recipe {
|
||||||
export interface RecipeIngredient {
|
export interface RecipeIngredient {
|
||||||
productId: string; // Reference to Product
|
productId: string; // Reference to Product
|
||||||
productName: string; // Denormalized for display
|
productName: string; // Denormalized for display
|
||||||
quantity: number;
|
quantity: number; // stored in metric (g | ml) or as a discrete count
|
||||||
unit: ServingUnit;
|
unit: RecipeUnit; // metric/discrete only after normalization
|
||||||
|
originalQuantity?: number; // optional, preserved from import (e.g. 1)
|
||||||
|
originalUnit?: ImperialUnit | RecipeUnit; // optional, preserved from import (e.g. 'cup')
|
||||||
preparation?: string; // e.g., 'diced', 'minced', 'melted'
|
preparation?: string; // e.g., 'diced', 'minced', 'melted'
|
||||||
isOptional: boolean;
|
isOptional: boolean;
|
||||||
nutritionContribution: NutritionInfo; // Per-ingredient computed nutrition
|
nutritionContribution: NutritionInfo; // Per-ingredient computed nutrition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Storage units for recipe ingredients — same set as `ServingUnit` in Phase 5.
|
||||||
|
export type RecipeUnit = 'g' | 'ml' | 'piece' | 'slice';
|
||||||
|
|
||||||
|
// Accepted at input/import only; converted to `RecipeUnit` before persistence.
|
||||||
|
export type ImperialUnit = 'oz' | 'lb' | 'cup' | 'tbsp' | 'tsp' | 'fl_oz';
|
||||||
|
|
||||||
export interface RecipeStep {
|
export interface RecipeStep {
|
||||||
order: number;
|
order: number;
|
||||||
instruction: string;
|
instruction: string;
|
||||||
|
|
@ -143,7 +151,7 @@ class NutritionCalculatorService {
|
||||||
/**
|
/**
|
||||||
* For each ingredient:
|
* For each ingredient:
|
||||||
* 1. Lookup the product by productId
|
* 1. Lookup the product by productId
|
||||||
* 2. Convert ingredient quantity/unit to product's servingUnit
|
* 2. Convert ingredient quantity/unit to product's servingUnit (already metric)
|
||||||
* 3. Calculate nutrition proportionally: (ingredient_qty / serving_size) * nutrition_per_serving
|
* 3. Calculate nutrition proportionally: (ingredient_qty / serving_size) * nutrition_per_serving
|
||||||
* 4. Sum across all ingredients → totalNutrition
|
* 4. Sum across all ingredients → totalNutrition
|
||||||
* 5. Divide by servings → perServingNutrition
|
* 5. Divide by servings → perServingNutrition
|
||||||
|
|
@ -160,17 +168,26 @@ class NutritionCalculatorService {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Unit conversion helper: handle common conversions (g ↔ oz, ml ↔ cups, etc.)
|
- Because products are stored in metric (`g | ml | piece | slice`), the calculator only needs to bridge metric ↔ metric and discrete ↔ metric (via `Product.servingSize`).
|
||||||
- Not all conversions are possible (density-dependent) — log warning, use best approximation
|
- Imperial input handling lives in `UnitConversionService` (see 6.2a) and runs **before** the calculator at create/update/import time.
|
||||||
- This is explicitly **informative, not clinical-grade accurate**
|
|
||||||
|
### 6.2a — Unit Conversion Service
|
||||||
|
|
||||||
|
- `UnitConversionService.toMetric(quantity, unit, product)` returns `{ quantity, unit: RecipeUnit }`.
|
||||||
|
- Mass conversions (exact): `oz → g` (× 28.3495), `lb → g` (× 453.592).
|
||||||
|
- Volume conversions (exact, US customary): `tsp → ml` (× 4.92892), `tbsp → ml` (× 14.7868), `fl_oz → ml` (× 29.5735), `cup → ml` (× 236.588).
|
||||||
|
- Mass ↔ volume conversions require `product.densityGPerMl`. If absent, the service returns an error tagged `MISSING_DENSITY` and the route returns 422 with the offending ingredient so the user can either supply a density on the product or restate the quantity in the product's native unit.
|
||||||
|
- Discrete units (`piece`, `slice`) cannot be converted from imperial — reject at validation.
|
||||||
|
- This service is **input-side only**: ingredients persisted on a recipe are always already in `RecipeUnit`.
|
||||||
|
|
||||||
### 6.3 — Recipe CRUD with Auto-Calculation
|
### 6.3 — Recipe CRUD with Auto-Calculation
|
||||||
|
|
||||||
- On `POST /recipes` and `PATCH /recipes/:id`:
|
- On `POST /recipes` and `PATCH /recipes/:id`:
|
||||||
1. Validate ingredients exist in product library
|
1. Validate ingredients exist in product library (including soft-deleted)
|
||||||
2. Call `NutritionCalculatorService.calculateRecipeNutrition()`
|
2. Run each ingredient through `UnitConversionService.toMetric()` so persisted `unit` is always `RecipeUnit`; preserve the user's original input as `originalQuantity`/`originalUnit` for display
|
||||||
3. Call `NutritionCalculatorService.generateWarnings()`
|
3. Call `NutritionCalculatorService.calculateRecipeNutrition()`
|
||||||
4. Store computed `totalNutrition`, `perServingNutrition`, `warnings` on document
|
4. Call `NutritionCalculatorService.generateWarnings()`
|
||||||
|
5. Store computed `totalNutrition`, `perServingNutrition`, `warnings` on document
|
||||||
- On product nutrition update (Phase 5 edit), trigger background recalculation:
|
- On product nutrition update (Phase 5 edit), trigger background recalculation:
|
||||||
- Find all recipes where `ingredients[].productId == updatedProductId`
|
- Find all recipes where `ingredients[].productId == updatedProductId`
|
||||||
- Recalculate each recipe's nutrition
|
- Recalculate each recipe's nutrition
|
||||||
|
|
@ -186,9 +203,9 @@ class NutritionCalculatorService {
|
||||||
- `POST /recipes/import-text`:
|
- `POST /recipes/import-text`:
|
||||||
- Accepts `{ text: string }` (pasted recipe)
|
- Accepts `{ text: string }` (pasted recipe)
|
||||||
- Calls `ILlmProvider.parseRecipe(text)`
|
- Calls `ILlmProvider.parseRecipe(text)`
|
||||||
- LLM returns structured: `{ name, servings, ingredients[]: { name, quantity, unit }, steps[] }`
|
- LLM returns structured: `{ name, servings, ingredients[]: { name, quantity, unit }, steps[] }` where `unit` may be imperial
|
||||||
- Service attempts to match ingredient names to existing products (fuzzy match by name)
|
- Service runs each ingredient through `UnitConversionService.toMetric()` and matches names to existing products (fuzzy match by name)
|
||||||
- Returns structured recipe for user review — unmatched ingredients flagged for manual product creation
|
- Returns the structured recipe for user review — unmatched ingredients and `MISSING_DENSITY` failures are flagged for manual resolution; nothing is persisted yet
|
||||||
- `POST /recipes/import-url`:
|
- `POST /recipes/import-url`:
|
||||||
- Calls `ILlmProvider.parseRecipeFromUrl(url)`
|
- Calls `ILlmProvider.parseRecipeFromUrl(url)`
|
||||||
- Same flow as text import
|
- Same flow as text import
|
||||||
|
|
|
||||||
|
|
@ -235,9 +235,7 @@ describe(CabinetEventsRepository.name, () => {
|
||||||
];
|
];
|
||||||
const byPeriod = [{ _id: '2024-01', totalSpent: 100 }];
|
const byPeriod = [{ _id: '2024-01', totalSpent: 100 }];
|
||||||
|
|
||||||
mockAggregate
|
mockAggregate.mockResolvedValueOnce(byMedicine).mockResolvedValueOnce(byPeriod);
|
||||||
.mockResolvedValueOnce(byMedicine)
|
|
||||||
.mockResolvedValueOnce(byPeriod);
|
|
||||||
|
|
||||||
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
||||||
|
|
||||||
|
|
@ -256,9 +254,7 @@ describe(CabinetEventsRepository.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null currency when no medicine data', async () => {
|
it('returns null currency when no medicine data', async () => {
|
||||||
mockAggregate
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
||||||
|
|
||||||
|
|
@ -269,9 +265,7 @@ describe(CabinetEventsRepository.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters by medicineId', async () => {
|
it('filters by medicineId', async () => {
|
||||||
mockAggregate
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await repo.getSpendingSummary('hh1', { period: 'month', medicineId: 'med-1' });
|
await repo.getSpendingSummary('hh1', { period: 'month', medicineId: 'med-1' });
|
||||||
|
|
||||||
|
|
@ -279,9 +273,7 @@ describe(CabinetEventsRepository.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters by startDate only', async () => {
|
it('filters by startDate only', async () => {
|
||||||
mockAggregate
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await repo.getSpendingSummary('hh1', {
|
await repo.getSpendingSummary('hh1', {
|
||||||
period: 'month',
|
period: 'month',
|
||||||
|
|
@ -292,9 +284,7 @@ describe(CabinetEventsRepository.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters by endDate only', async () => {
|
it('filters by endDate only', async () => {
|
||||||
mockAggregate
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await repo.getSpendingSummary('hh1', {
|
await repo.getSpendingSummary('hh1', {
|
||||||
period: 'month',
|
period: 'month',
|
||||||
|
|
@ -305,9 +295,7 @@ describe(CabinetEventsRepository.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters by both startDate and endDate', async () => {
|
it('filters by both startDate and endDate', async () => {
|
||||||
mockAggregate
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await repo.getSpendingSummary('hh1', {
|
await repo.getSpendingSummary('hh1', {
|
||||||
period: 'month',
|
period: 'month',
|
||||||
|
|
@ -319,9 +307,7 @@ describe(CabinetEventsRepository.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses quarter date format', async () => {
|
it('uses quarter date format', async () => {
|
||||||
mockAggregate
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await repo.getSpendingSummary('hh1', { period: 'quarter' });
|
await repo.getSpendingSummary('hh1', { period: 'quarter' });
|
||||||
|
|
||||||
|
|
@ -329,9 +315,7 @@ describe(CabinetEventsRepository.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses year date format', async () => {
|
it('uses year date format', async () => {
|
||||||
mockAggregate
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await repo.getSpendingSummary('hh1', { period: 'year' });
|
await repo.getSpendingSummary('hh1', { period: 'year' });
|
||||||
|
|
||||||
|
|
@ -350,9 +334,7 @@ describe(CabinetEventsRepository.name, () => {
|
||||||
currency: null,
|
currency: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
mockAggregate
|
mockAggregate.mockResolvedValueOnce(byMedicine).mockResolvedValueOnce([]);
|
||||||
.mockResolvedValueOnce(byMedicine)
|
|
||||||
.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,11 @@ export class CabinetEventsRepository {
|
||||||
{
|
{
|
||||||
$addFields: {
|
$addFields: {
|
||||||
avgUnitPrice: {
|
avgUnitPrice: {
|
||||||
$cond: [{ $gt: ['$totalQuantity', 0] }, { $divide: ['$totalSpent', '$totalQuantity'] }, 0],
|
$cond: [
|
||||||
|
{ $gt: ['$totalQuantity', 0] },
|
||||||
|
{ $divide: ['$totalSpent', '$totalQuantity'] },
|
||||||
|
0,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -153,7 +157,7 @@ export class CabinetEventsRepository {
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const currency =
|
const currency =
|
||||||
byMedicine.length > 0 ? (byMedicine[0].currency as string | null) ?? null : null;
|
byMedicine.length > 0 ? ((byMedicine[0].currency as string | null) ?? null) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalSpent,
|
totalSpent,
|
||||||
|
|
@ -174,7 +178,8 @@ export class CabinetEventsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAvgUnitPriceByMedicine(householdId: string, medicineIds: string[]) {
|
public async getAvgUnitPriceByMedicine(householdId: string, medicineIds: string[]) {
|
||||||
if (medicineIds.length === 0) return new Map<string, { avgUnitPrice: number; currency: string | null }>();
|
if (medicineIds.length === 0)
|
||||||
|
return new Map<string, { avgUnitPrice: number; currency: string | null }>();
|
||||||
|
|
||||||
const results = await CabinetEventModel.aggregate([
|
const results = await CabinetEventModel.aggregate([
|
||||||
{
|
{
|
||||||
|
|
@ -196,7 +201,11 @@ export class CabinetEventsRepository {
|
||||||
{
|
{
|
||||||
$addFields: {
|
$addFields: {
|
||||||
avgUnitPrice: {
|
avgUnitPrice: {
|
||||||
$cond: [{ $gt: ['$totalQuantity', 0] }, { $divide: ['$totalSpent', '$totalQuantity'] }, 0],
|
$cond: [
|
||||||
|
{ $gt: ['$totalQuantity', 0] },
|
||||||
|
{ $divide: ['$totalSpent', '$totalQuantity'] },
|
||||||
|
0,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -234,11 +234,14 @@ describe('cabinet-events.routes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(mockListEvents).toHaveBeenCalledWith('hh1', expect.objectContaining({
|
expect(mockListEvents).toHaveBeenCalledWith(
|
||||||
medicineId: 'med-1',
|
'hh1',
|
||||||
eventType: 'purchased',
|
expect.objectContaining({
|
||||||
limit: 10,
|
medicineId: 'med-1',
|
||||||
}));
|
eventType: 'purchased',
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -276,10 +279,14 @@ describe('cabinet-events.routes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(mockGetEventsByItem).toHaveBeenCalledWith('hh1', 'ci-1', expect.objectContaining({
|
expect(mockGetEventsByItem).toHaveBeenCalledWith(
|
||||||
limit: 5,
|
'hh1',
|
||||||
cursor: 'abc',
|
'ci-1',
|
||||||
}));
|
expect.objectContaining({
|
||||||
|
limit: 5,
|
||||||
|
cursor: 'abc',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles ObjectId and Date objects in by-item response', async () => {
|
it('handles ObjectId and Date objects in by-item response', async () => {
|
||||||
|
|
@ -356,10 +363,13 @@ describe('cabinet-events.routes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.objectContaining({
|
expect(mockGetSpendingSummary).toHaveBeenCalledWith(
|
||||||
period: 'quarter',
|
'hh1',
|
||||||
medicineId: 'med-1',
|
expect.objectContaining({
|
||||||
}));
|
period: 'quarter',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty summary with null currency', async () => {
|
it('returns empty summary with null currency', async () => {
|
||||||
|
|
|
||||||
|
|
@ -142,10 +142,7 @@ export default fp(
|
||||||
},
|
},
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
const service = fastify.diContainer.resolve('cabinetEventsService');
|
const service = fastify.diContainer.resolve('cabinetEventsService');
|
||||||
const summary = await service.getSpendingSummary(
|
const summary = await service.getSpendingSummary(request.params.householdId, request.query);
|
||||||
request.params.householdId,
|
|
||||||
request.query,
|
|
||||||
);
|
|
||||||
return reply.send(summary);
|
return reply.send(summary);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,9 @@ describe(CabinetEventsService.name, () => {
|
||||||
const result = await service.getAvgUnitPrices('hh1', ['med-1']);
|
const result = await service.getAvgUnitPrices('hh1', ['med-1']);
|
||||||
|
|
||||||
expect(result).toEqual(expected);
|
expect(result).toEqual(expected);
|
||||||
expect(mockCabinetEventsRepo.getAvgUnitPriceByMedicine).toHaveBeenCalledWith('hh1', ['med-1']);
|
expect(mockCabinetEventsRepo.getAvgUnitPriceByMedicine).toHaveBeenCalledWith('hh1', [
|
||||||
|
'med-1',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import type { CabinetEventsRepository, CreateCabinetEventData } from './cabinet-events.repository.js';
|
import type {
|
||||||
|
CabinetEventsRepository,
|
||||||
|
CreateCabinetEventData,
|
||||||
|
} from './cabinet-events.repository.js';
|
||||||
import type { CabinetEventQueryInput, SpendingSummaryQueryInput } from '@meshitrack/shared';
|
import type { CabinetEventQueryInput, SpendingSummaryQueryInput } from '@meshitrack/shared';
|
||||||
|
|
||||||
interface Deps {
|
interface Deps {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { CabinetItemModel } from '../../schemas/cabinet-item.schema.js';
|
import { CabinetItemModel } from '../../schemas/cabinet-item.schema.js';
|
||||||
import type { CabinetItemStatus, CreateCabinetItemInput, UpdateCabinetItemInput } from '@meshitrack/shared';
|
import type {
|
||||||
|
CabinetItemStatus,
|
||||||
|
CreateCabinetItemInput,
|
||||||
|
UpdateCabinetItemInput,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
|
||||||
interface FindByHouseholdQuery {
|
interface FindByHouseholdQuery {
|
||||||
medicineId?: string;
|
medicineId?: string;
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,11 @@ export default fp(
|
||||||
},
|
},
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
const service = fastify.diContainer.resolve('cabinetService');
|
const service = fastify.diContainer.resolve('cabinetService');
|
||||||
await service.delete(request.params.id, request.params.householdId, request.user.keycloakId);
|
await service.delete(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
return reply.status(204).send();
|
return reply.status(204).send();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,12 @@ describe(CabinetService.name, () => {
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
it('updates and returns item', async () => {
|
it('updates and returns item', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 30,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
const updated = { _id: 'ci-1', quantity: 25 };
|
const updated = { _id: 'ci-1', quantity: 25 };
|
||||||
mockCabinetRepo.update.mockResolvedValue(updated);
|
mockCabinetRepo.update.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
|
@ -249,7 +254,12 @@ describe(CabinetService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs ADJUSTED event when quantity changes', async () => {
|
it('logs ADJUSTED event when quantity changes', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 30,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 25 });
|
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 25 });
|
||||||
|
|
||||||
await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
|
await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
|
||||||
|
|
@ -265,7 +275,12 @@ describe(CabinetService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not log event when quantity unchanged', async () => {
|
it('does not log event when quantity unchanged', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 30,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
|
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
|
||||||
|
|
||||||
await service.update('ci-1', 'hh1', { notes: 'updated' }, 'user-1');
|
await service.update('ci-1', 'hh1', { notes: 'updated' }, 'user-1');
|
||||||
|
|
@ -282,7 +297,12 @@ describe(CabinetService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError when update returns null', async () => {
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 30,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
mockCabinetRepo.update.mockResolvedValue(null);
|
mockCabinetRepo.update.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
|
await expect(service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
|
||||||
|
|
@ -293,7 +313,12 @@ describe(CabinetService.name, () => {
|
||||||
|
|
||||||
describe('adjustQuantity', () => {
|
describe('adjustQuantity', () => {
|
||||||
it('adjusts quantity and returns item', async () => {
|
it('adjusts quantity and returns item', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 30,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
const updated = { _id: 'ci-1', quantity: 27 };
|
const updated = { _id: 'ci-1', quantity: 27 };
|
||||||
mockCabinetRepo.adjustQuantity.mockResolvedValue(updated);
|
mockCabinetRepo.adjustQuantity.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
|
@ -303,7 +328,12 @@ describe(CabinetService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs ADJUSTED event', async () => {
|
it('logs ADJUSTED event', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 30,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 27 });
|
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 27 });
|
||||||
|
|
||||||
await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1', 'took some');
|
await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1', 'took some');
|
||||||
|
|
@ -334,7 +364,12 @@ describe(CabinetService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError when adjust returns null', async () => {
|
it('throws NotFoundError when adjust returns null', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 30,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
|
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.adjustQuantity('ci-1', 'hh1', 5, 'user-1')).rejects.toThrow(
|
await expect(service.adjustQuantity('ci-1', 'hh1', 5, 'user-1')).rejects.toThrow(
|
||||||
|
|
@ -357,7 +392,12 @@ describe(CabinetService.name, () => {
|
||||||
|
|
||||||
describe('delete', () => {
|
describe('delete', () => {
|
||||||
it('soft deletes item and logs DELETED event', async () => {
|
it('soft deletes item and logs DELETED event', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 10, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 10,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
mockCabinetRepo.softDelete.mockResolvedValue({ _id: 'ci-1', isDeleted: true });
|
mockCabinetRepo.softDelete.mockResolvedValue({ _id: 'ci-1', isDeleted: true });
|
||||||
|
|
||||||
const result = await service.delete('ci-1', 'hh1', 'user-1');
|
const result = await service.delete('ci-1', 'hh1', 'user-1');
|
||||||
|
|
@ -376,20 +416,34 @@ describe(CabinetService.name, () => {
|
||||||
it('throws NotFoundError when item does not exist', async () => {
|
it('throws NotFoundError when item does not exist', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue(null);
|
mockCabinetRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.delete('ci-missing', 'hh1', 'user-1')).rejects.toThrow('Cabinet item not found');
|
await expect(service.delete('ci-missing', 'hh1', 'user-1')).rejects.toThrow(
|
||||||
|
'Cabinet item not found',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError when softDelete returns null', async () => {
|
it('throws NotFoundError when softDelete returns null', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 5, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 5,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
mockCabinetRepo.softDelete.mockResolvedValue(null);
|
mockCabinetRepo.softDelete.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.delete('ci-1', 'hh1', 'user-1')).rejects.toThrow('Cabinet item not found');
|
await expect(service.delete('ci-1', 'hh1', 'user-1')).rejects.toThrow(
|
||||||
|
'Cabinet item not found',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('discard', () => {
|
describe('discard', () => {
|
||||||
it('discards item and logs DISCARDED event', async () => {
|
it('discards item and logs DISCARDED event', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 20, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 20,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
mockCabinetRepo.discard.mockResolvedValue({ _id: 'ci-1', quantity: 0, isDeleted: true });
|
mockCabinetRepo.discard.mockResolvedValue({ _id: 'ci-1', quantity: 0, isDeleted: true });
|
||||||
|
|
||||||
const result = await service.discard('ci-1', 'hh1', 'user-1', 'expired', 'smelled off');
|
const result = await service.discard('ci-1', 'hh1', 'user-1', 'expired', 'smelled off');
|
||||||
|
|
@ -409,7 +463,12 @@ describe(CabinetService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws BadRequestError when quantity is zero', async () => {
|
it('throws BadRequestError when quantity is zero', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 0, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 0,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
|
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
|
||||||
'Cannot discard an item with zero quantity',
|
'Cannot discard an item with zero quantity',
|
||||||
|
|
@ -425,7 +484,12 @@ describe(CabinetService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError when discard returns null', async () => {
|
it('throws NotFoundError when discard returns null', async () => {
|
||||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 10, medicineId: 'med-1', medicineName: 'Test' });
|
mockCabinetRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'ci-1',
|
||||||
|
quantity: 10,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Test',
|
||||||
|
});
|
||||||
mockCabinetRepo.discard.mockResolvedValue(null);
|
mockCabinetRepo.discard.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
|
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@ import type { CabinetRepository } from './cabinet.repository.js';
|
||||||
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
|
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
|
||||||
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||||
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
|
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
|
||||||
import {
|
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
|
||||||
CabinetEventType,
|
|
||||||
CabinetEventSourceType,
|
|
||||||
} from '@meshitrack/shared';
|
|
||||||
import type {
|
import type {
|
||||||
CreateCabinetItemInput,
|
CreateCabinetItemInput,
|
||||||
UpdateCabinetItemInput,
|
UpdateCabinetItemInput,
|
||||||
|
|
@ -123,7 +120,12 @@ export class CabinetService {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async update(id: string, householdId: string, data: UpdateCabinetItemInput, userId: string) {
|
public async update(
|
||||||
|
id: string,
|
||||||
|
householdId: string,
|
||||||
|
data: UpdateCabinetItemInput,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
const existing = await this.getById(id, householdId);
|
const existing = await this.getById(id, householdId);
|
||||||
const updated = await this.cabinetRepository.update(id, householdId, data);
|
const updated = await this.cabinetRepository.update(id, householdId, data);
|
||||||
if (!updated) throw new NotFoundError('Cabinet item not found');
|
if (!updated) throw new NotFoundError('Cabinet item not found');
|
||||||
|
|
@ -203,7 +205,13 @@ export class CabinetService {
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async discard(id: string, householdId: string, userId: string, reason: string, notes?: string) {
|
public async discard(
|
||||||
|
id: string,
|
||||||
|
householdId: string,
|
||||||
|
userId: string,
|
||||||
|
reason: string,
|
||||||
|
notes?: string,
|
||||||
|
) {
|
||||||
const existing = await this.getById(id, householdId);
|
const existing = await this.getById(id, householdId);
|
||||||
if (existing.quantity === 0) {
|
if (existing.quantity === 0) {
|
||||||
throw new BadRequestError('Cannot discard an item with zero quantity');
|
throw new BadRequestError('Cannot discard an item with zero quantity');
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,13 @@ vi.mock('../../schemas/medicine-price.schema.js', () => {
|
||||||
|
|
||||||
class FakeModel {
|
class FakeModel {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
constructor(data: unknown) { this.data = data; }
|
constructor(data: unknown) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
save = mockSave;
|
save = mockSave;
|
||||||
toObject() { return this.data; }
|
toObject() {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
static find = vi.fn(() => chain());
|
static find = vi.fn(() => chain());
|
||||||
static findOne = vi.fn(() => findOneChain());
|
static findOne = vi.fn(() => findOneChain());
|
||||||
static aggregate = vi.fn(() => aggregateChain());
|
static aggregate = vi.fn(() => aggregateChain());
|
||||||
|
|
@ -231,8 +235,17 @@ describe(MedicinePricesRepository.name, () => {
|
||||||
it('handles non-empty analytics results', async () => {
|
it('handles non-empty analytics results', async () => {
|
||||||
mockAggregate
|
mockAggregate
|
||||||
.mockResolvedValueOnce([{ period: '2026-01', total: 50 }])
|
.mockResolvedValueOnce([{ period: '2026-01', total: 50 }])
|
||||||
.mockResolvedValueOnce([{ medicineId: 'med-1', medicineName: 'Acetaminophen', totalSpent: 50, avgPricePerUnit: 0.1 }])
|
.mockResolvedValueOnce([
|
||||||
.mockResolvedValueOnce([{ storeId: 'st-1', storeName: 'Walgreens', totalSpent: 50, purchaseCount: 5 }])
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Acetaminophen',
|
||||||
|
totalSpent: 50,
|
||||||
|
avgPricePerUnit: 0.1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ storeId: 'st-1', storeName: 'Walgreens', totalSpent: 50, purchaseCount: 5 },
|
||||||
|
])
|
||||||
.mockResolvedValueOnce([]);
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
const result = await repo.getAnalytics('hh1', { period: 'month' });
|
const result = await repo.getAnalytics('hh1', { period: 'month' });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { MedicinePriceModel } from '../../schemas/medicine-price.schema.js';
|
import { MedicinePriceModel } from '../../schemas/medicine-price.schema.js';
|
||||||
import type { MedicinePriceHistoryQueryInput, MedicinePriceAnalyticsQueryInput } from '@meshitrack/shared';
|
import type {
|
||||||
|
MedicinePriceHistoryQueryInput,
|
||||||
|
MedicinePriceAnalyticsQueryInput,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
|
||||||
export interface CreateMedicinePriceData {
|
export interface CreateMedicinePriceData {
|
||||||
householdId: string;
|
householdId: string;
|
||||||
|
|
@ -93,11 +96,7 @@ export class MedicinePricesRepository {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLatestForMedicine(
|
public async getLatestForMedicine(householdId: string, medicineId: string, storeId?: string) {
|
||||||
householdId: string,
|
|
||||||
medicineId: string,
|
|
||||||
storeId?: string,
|
|
||||||
) {
|
|
||||||
const filter: Record<string, unknown> = { householdId, medicineId };
|
const filter: Record<string, unknown> = { householdId, medicineId };
|
||||||
if (storeId) filter['storeId'] = storeId;
|
if (storeId) filter['storeId'] = storeId;
|
||||||
return MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec();
|
return MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec();
|
||||||
|
|
@ -132,7 +131,15 @@ export class MedicinePricesRepository {
|
||||||
},
|
},
|
||||||
{ $sort: { totalSpent: -1 } },
|
{ $sort: { totalSpent: -1 } },
|
||||||
{ $limit: 10 },
|
{ $limit: 10 },
|
||||||
{ $project: { _id: 0, medicineId: '$_id', medicineName: 1, totalSpent: 1, avgPricePerUnit: 1 } },
|
{
|
||||||
|
$project: {
|
||||||
|
_id: 0,
|
||||||
|
medicineId: '$_id',
|
||||||
|
medicineName: 1,
|
||||||
|
totalSpent: 1,
|
||||||
|
avgPricePerUnit: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
]).exec(),
|
]).exec(),
|
||||||
|
|
||||||
MedicinePriceModel.aggregate([
|
MedicinePriceModel.aggregate([
|
||||||
|
|
@ -195,9 +202,26 @@ export class MedicinePricesRepository {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
spendingOverTime: spendingOverTime as { period: string; total: number }[],
|
spendingOverTime: spendingOverTime as { period: string; total: number }[],
|
||||||
topBySpending: topBySpending as { medicineId: string; medicineName: string; totalSpent: number; avgPricePerUnit: number }[],
|
topBySpending: topBySpending as {
|
||||||
spendingByStore: spendingByStore as { storeId: string; storeName: string; totalSpent: number; purchaseCount: number }[],
|
medicineId: string;
|
||||||
priceAlerts: priceAlerts as { medicineId: string; medicineName: string; storeName: string; previousPrice: number; currentPrice: number; changePercent: number }[],
|
medicineName: string;
|
||||||
|
totalSpent: number;
|
||||||
|
avgPricePerUnit: number;
|
||||||
|
}[],
|
||||||
|
spendingByStore: spendingByStore as {
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
totalSpent: number;
|
||||||
|
purchaseCount: number;
|
||||||
|
}[],
|
||||||
|
priceAlerts: priceAlerts as {
|
||||||
|
medicineId: string;
|
||||||
|
medicineName: string;
|
||||||
|
storeName: string;
|
||||||
|
previousPrice: number;
|
||||||
|
currentPrice: number;
|
||||||
|
changePercent: number;
|
||||||
|
}[],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,14 @@ vi.mock('jose', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const { mockRecordPrice, mockGetPriceHistory, mockCompareStores, mockGetAnalytics } = vi.hoisted(
|
||||||
mockRecordPrice,
|
() => ({
|
||||||
mockGetPriceHistory,
|
mockRecordPrice: vi.fn(),
|
||||||
mockCompareStores,
|
mockGetPriceHistory: vi.fn(),
|
||||||
mockGetAnalytics,
|
mockCompareStores: vi.fn(),
|
||||||
} = vi.hoisted(() => ({
|
mockGetAnalytics: vi.fn(),
|
||||||
mockRecordPrice: vi.fn(),
|
}),
|
||||||
mockGetPriceHistory: vi.fn(),
|
);
|
||||||
mockCompareStores: vi.fn(),
|
|
||||||
mockGetAnalytics: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./medicine-prices.repository.js', () => ({
|
vi.mock('./medicine-prices.repository.js', () => ({
|
||||||
MedicinePricesRepository: class {
|
MedicinePricesRepository: class {
|
||||||
|
|
@ -198,11 +195,13 @@ describe('medicine-prices.routes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles Date objects in response', async () => {
|
it('handles Date objects in response', async () => {
|
||||||
mockRecordPrice.mockResolvedValue(makeFakePriceRecord({
|
mockRecordPrice.mockResolvedValue(
|
||||||
_id: { toString: () => 'pr-obj' },
|
makeFakePriceRecord({
|
||||||
date: new Date('2026-01-15T00:00:00.000Z'),
|
_id: { toString: () => 'pr-obj' },
|
||||||
createdAt: new Date('2026-01-15T00:00:00.000Z'),
|
date: new Date('2026-01-15T00:00:00.000Z'),
|
||||||
}));
|
createdAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -238,7 +237,10 @@ describe('medicine-prices.routes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes query params to service', async () => {
|
it('passes query params to service', async () => {
|
||||||
mockGetPriceHistory.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
mockGetPriceHistory.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import {
|
||||||
} from '@meshitrack/shared';
|
} from '@meshitrack/shared';
|
||||||
import { MedicinePricesRepository } from './medicine-prices.repository.js';
|
import { MedicinePricesRepository } from './medicine-prices.repository.js';
|
||||||
import { MedicinePricesService } from './medicine-prices.service.js';
|
import { MedicinePricesService } from './medicine-prices.service.js';
|
||||||
import { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
|
||||||
import { StoresRepository } from '../stores/stores.repository.js';
|
|
||||||
|
|
||||||
type AnyPriceDoc = {
|
type AnyPriceDoc = {
|
||||||
_id: string | { toString: () => string };
|
_id: string | { toString: () => string };
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,10 @@ describe(MedicinePricesService.name, () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
it('creates price record with computed pricePerUnit', async () => {
|
it('creates price record with computed pricePerUnit', async () => {
|
||||||
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
|
mockProductsRepo.findById.mockResolvedValue({
|
||||||
|
medicineName: 'Acetaminophen',
|
||||||
|
brand: 'Tylenol',
|
||||||
|
});
|
||||||
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
|
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
|
||||||
const record = { _id: 'pr-1', pricePerUnit: 0.1 };
|
const record = { _id: 'pr-1', pricePerUnit: 0.1 };
|
||||||
mockPricesRepo.create.mockResolvedValue(record);
|
mockPricesRepo.create.mockResolvedValue(record);
|
||||||
|
|
@ -49,7 +52,11 @@ describe(MedicinePricesService.name, () => {
|
||||||
|
|
||||||
expect(result).toEqual(record);
|
expect(result).toEqual(record);
|
||||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ pricePerUnit: 0.1, medicineName: 'Acetaminophen', storeName: 'Walgreens' }),
|
expect.objectContaining({
|
||||||
|
pricePerUnit: 0.1,
|
||||||
|
medicineName: 'Acetaminophen',
|
||||||
|
storeName: 'Walgreens',
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -66,11 +73,18 @@ describe(MedicinePricesService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses provided date when given', async () => {
|
it('uses provided date when given', async () => {
|
||||||
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
|
mockProductsRepo.findById.mockResolvedValue({
|
||||||
|
medicineName: 'Acetaminophen',
|
||||||
|
brand: 'Tylenol',
|
||||||
|
});
|
||||||
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
|
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
|
||||||
mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' });
|
mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' });
|
||||||
|
|
||||||
await service.recordPrice({ ...validInput, date: '2026-01-15T00:00:00.000Z' }, 'hh1', 'user-1');
|
await service.recordPrice(
|
||||||
|
{ ...validInput, date: '2026-01-15T00:00:00.000Z' },
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ date: new Date('2026-01-15T00:00:00.000Z') }),
|
expect.objectContaining({ date: new Date('2026-01-15T00:00:00.000Z') }),
|
||||||
|
|
@ -86,7 +100,10 @@ describe(MedicinePricesService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError when store not found', async () => {
|
it('throws NotFoundError when store not found', async () => {
|
||||||
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
|
mockProductsRepo.findById.mockResolvedValue({
|
||||||
|
medicineName: 'Acetaminophen',
|
||||||
|
brand: 'Tylenol',
|
||||||
|
});
|
||||||
mockStoresRepo.findById.mockResolvedValue(null);
|
mockStoresRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow(
|
await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow(
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,11 @@ export class MedicinePricesService {
|
||||||
private readonly medicineProductsRepository: MedicineProductsRepository;
|
private readonly medicineProductsRepository: MedicineProductsRepository;
|
||||||
private readonly storesRepository: StoresRepository;
|
private readonly storesRepository: StoresRepository;
|
||||||
|
|
||||||
public constructor({ medicinePricesRepository, medicineProductsRepository, storesRepository }: Deps) {
|
public constructor({
|
||||||
|
medicinePricesRepository,
|
||||||
|
medicineProductsRepository,
|
||||||
|
storesRepository,
|
||||||
|
}: Deps) {
|
||||||
this.medicinePricesRepository = medicinePricesRepository;
|
this.medicinePricesRepository = medicinePricesRepository;
|
||||||
this.medicineProductsRepository = medicineProductsRepository;
|
this.medicineProductsRepository = medicineProductsRepository;
|
||||||
this.storesRepository = storesRepository;
|
this.storesRepository = storesRepository;
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,9 @@ describe('medicine-products.routes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes concentration fields in response when present', async () => {
|
it('includes concentration fields in response when present', async () => {
|
||||||
mockFindById.mockResolvedValue(makeFakeProduct({ concentration: 5, concentrationUnit: 'mg/ml' }));
|
mockFindById.mockResolvedValue(
|
||||||
|
makeFakeProduct({ concentration: 5, concentrationUnit: 'mg/ml' }),
|
||||||
|
);
|
||||||
|
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,10 @@ describe(OrganizerRepository.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets hasMore when more items exist', async () => {
|
it('sets hasMore when more items exist', async () => {
|
||||||
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `fill-${i}`, regimenName: `R${i}` }));
|
const items = Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
_id: `fill-${i}`,
|
||||||
|
regimenName: `R${i}`,
|
||||||
|
}));
|
||||||
mockFind.mockResolvedValue(items);
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 2 });
|
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 2 });
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,7 @@ function makeFakeFill(overrides = {}) {
|
||||||
quantityTaken: 7,
|
quantityTaken: 7,
|
||||||
wasShort: false,
|
wasShort: false,
|
||||||
shortage: 0,
|
shortage: 0,
|
||||||
deductions: [
|
deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 7 }],
|
||||||
{ cabinetItemId: 'ci-1', quantityTaken: 7 },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
status: OrganizerFillStatus.COMPLETED,
|
status: OrganizerFillStatus.COMPLETED,
|
||||||
|
|
@ -182,11 +180,15 @@ describe('organizer.routes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(mockListFills).toHaveBeenCalledWith('hh1', 'kc-1', expect.objectContaining({
|
expect(mockListFills).toHaveBeenCalledWith(
|
||||||
regimenId: 'reg-1',
|
'hh1',
|
||||||
status: OrganizerFillStatus.COMPLETED,
|
'kc-1',
|
||||||
limit: 10,
|
expect.objectContaining({
|
||||||
}));
|
regimenId: 'reg-1',
|
||||||
|
status: OrganizerFillStatus.COMPLETED,
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles ObjectId and Date serialization in fill response', async () => {
|
it('handles ObjectId and Date serialization in fill response', async () => {
|
||||||
|
|
@ -439,7 +441,12 @@ describe('organizer.routes', () => {
|
||||||
expect(mockFill).toHaveBeenCalledWith(
|
expect(mockFill).toHaveBeenCalledWith(
|
||||||
'hh1',
|
'hh1',
|
||||||
'kc-1',
|
'kc-1',
|
||||||
expect.objectContaining({ regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'test note' }),
|
expect.objectContaining({
|
||||||
|
regimenId: 'reg-1',
|
||||||
|
numberOfDays: 7,
|
||||||
|
allowPartial: false,
|
||||||
|
notes: 'test note',
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,9 @@ describe(OrganizerService.name, () => {
|
||||||
const response = await service.listFills('hh1', 'user-1', { limit: 20 });
|
const response = await service.listFills('hh1', 'user-1', { limit: 20 });
|
||||||
|
|
||||||
expect(response).toEqual(result);
|
expect(response).toEqual(result);
|
||||||
expect(mockOrganizerRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', { limit: 20 });
|
expect(mockOrganizerRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', {
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,12 @@ export class OrganizerService {
|
||||||
return fill;
|
return fill;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async preview(householdId: string, userId: string, regimenId: string, numberOfDays: number) {
|
public async preview(
|
||||||
|
householdId: string,
|
||||||
|
userId: string,
|
||||||
|
regimenId: string,
|
||||||
|
numberOfDays: number,
|
||||||
|
) {
|
||||||
const regimen = await this.regimensService.getById(regimenId, householdId, userId);
|
const regimen = await this.regimensService.getById(regimenId, householdId, userId);
|
||||||
if (!regimen.isActive) {
|
if (!regimen.isActive) {
|
||||||
throw new BadRequestError('Regimen is not active');
|
throw new BadRequestError('Regimen is not active');
|
||||||
|
|
@ -132,7 +137,12 @@ export class OrganizerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async fill(householdId: string, userId: string, input: OrganizerFillInput) {
|
public async fill(householdId: string, userId: string, input: OrganizerFillInput) {
|
||||||
const previewResult = await this.preview(householdId, userId, input.regimenId, input.numberOfDays);
|
const previewResult = await this.preview(
|
||||||
|
householdId,
|
||||||
|
userId,
|
||||||
|
input.regimenId,
|
||||||
|
input.numberOfDays,
|
||||||
|
);
|
||||||
|
|
||||||
if (!input.allowPartial && previewResult.hasShortages) {
|
if (!input.allowPartial && previewResult.hasShortages) {
|
||||||
throw new BadRequestError(
|
throw new BadRequestError(
|
||||||
|
|
@ -243,7 +253,10 @@ export class OrganizerService {
|
||||||
for (const item of fill.items) {
|
for (const item of fill.items) {
|
||||||
for (const deduction of item.deductions) {
|
for (const deduction of item.deductions) {
|
||||||
// Get current quantity before restoring
|
// Get current quantity before restoring
|
||||||
const current = await this.cabinetRepository.findById(deduction.cabinetItemId, householdId);
|
const current = await this.cabinetRepository.findById(
|
||||||
|
deduction.cabinetItemId,
|
||||||
|
householdId,
|
||||||
|
);
|
||||||
const quantityBefore = current?.quantity ?? 0;
|
const quantityBefore = current?.quantity ?? 0;
|
||||||
|
|
||||||
await this.cabinetRepository.adjustQuantity(
|
await this.cabinetRepository.adjustQuantity(
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,13 @@ vi.mock('../../schemas/purchase.schema.js', () => {
|
||||||
|
|
||||||
class FakeModel {
|
class FakeModel {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
constructor(data: unknown) { this.data = data; }
|
constructor(data: unknown) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
save = mockSave;
|
save = mockSave;
|
||||||
toObject() { return this.data; }
|
toObject() {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
static find = vi.fn(() => findChain());
|
static find = vi.fn(() => findChain());
|
||||||
static findOne = vi.fn(() => findOneChain());
|
static findOne = vi.fn(() => findOneChain());
|
||||||
static findOneAndUpdate = vi.fn(() => updateChain());
|
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||||
|
|
@ -54,7 +58,15 @@ describe(PurchasesRepository.name, () => {
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('saves and returns plain object', async () => {
|
it('saves and returns plain object', async () => {
|
||||||
const data = { householdId: 'hh1', storeId: 'st-1', storeName: 'CVS', status: 'in_cabinet', items: [makeItem()], purchasedAt: new Date(), createdBy: 'u-1' };
|
const data = {
|
||||||
|
householdId: 'hh1',
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'CVS',
|
||||||
|
status: 'in_cabinet',
|
||||||
|
items: [makeItem()],
|
||||||
|
purchasedAt: new Date(),
|
||||||
|
createdBy: 'u-1',
|
||||||
|
};
|
||||||
mockSave.mockResolvedValue({ toObject: () => data });
|
mockSave.mockResolvedValue({ toObject: () => data });
|
||||||
|
|
||||||
const result = await repo.create(data);
|
const result = await repo.create(data);
|
||||||
|
|
@ -104,9 +116,7 @@ describe(PurchasesRepository.name, () => {
|
||||||
|
|
||||||
await repo.findByHousehold('hh1', { limit: 20, storeId: 'st-1' });
|
await repo.findByHousehold('hh1', { limit: 20, storeId: 'st-1' });
|
||||||
|
|
||||||
expect(PurchaseModel.find).toHaveBeenCalledWith(
|
expect(PurchaseModel.find).toHaveBeenCalledWith(expect.objectContaining({ storeId: 'st-1' }));
|
||||||
expect.objectContaining({ storeId: 'st-1' }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies cursor filter when provided', async () => {
|
it('applies cursor filter when provided', async () => {
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,7 @@ export class PurchasesRepository {
|
||||||
const hasMore = items.length > limit;
|
const hasMore = items.length > limit;
|
||||||
const data = hasMore ? items.slice(0, limit) : items;
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
const cursor =
|
const cursor =
|
||||||
data.length > 0
|
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||||
? Buffer.from(data[data.length - 1]._id.toString()).toString('base64')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,21 +18,16 @@ vi.mock('jose', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const { mockList, mockGetById, mockCreate, mockUpdate, mockReceive, mockDelete } = vi.hoisted(
|
||||||
mockList,
|
() => ({
|
||||||
mockGetById,
|
mockList: vi.fn(),
|
||||||
mockCreate,
|
mockGetById: vi.fn(),
|
||||||
mockUpdate,
|
mockCreate: vi.fn(),
|
||||||
mockReceive,
|
mockUpdate: vi.fn(),
|
||||||
mockDelete,
|
mockReceive: vi.fn(),
|
||||||
} = vi.hoisted(() => ({
|
mockDelete: vi.fn(),
|
||||||
mockList: vi.fn(),
|
}),
|
||||||
mockGetById: vi.fn(),
|
);
|
||||||
mockCreate: vi.fn(),
|
|
||||||
mockUpdate: vi.fn(),
|
|
||||||
mockReceive: vi.fn(),
|
|
||||||
mockDelete: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./purchases.repository.js', () => ({
|
vi.mock('./purchases.repository.js', () => ({
|
||||||
PurchasesRepository: class {
|
PurchasesRepository: class {
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,7 @@ type AnyPurchase = {
|
||||||
|
|
||||||
function toItemResponse(item: AnyPurchaseItem) {
|
function toItemResponse(item: AnyPurchaseItem) {
|
||||||
return {
|
return {
|
||||||
_id: item._id
|
_id: item._id ? (typeof item._id === 'string' ? item._id : item._id.toString()) : '',
|
||||||
? typeof item._id === 'string'
|
|
||||||
? item._id
|
|
||||||
: item._id.toString()
|
|
||||||
: '',
|
|
||||||
...(item.medicineProductId ? { medicineProductId: item.medicineProductId } : {}),
|
...(item.medicineProductId ? { medicineProductId: item.medicineProductId } : {}),
|
||||||
...(item.medicineId ? { medicineId: item.medicineId } : {}),
|
...(item.medicineId ? { medicineId: item.medicineId } : {}),
|
||||||
...(item.foodProductId ? { foodProductId: item.foodProductId } : {}),
|
...(item.foodProductId ? { foodProductId: item.foodProductId } : {}),
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,12 @@ describe(PurchasesService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const fakeStore = { _id: 'st-1', name: 'CVS' };
|
const fakeStore = { _id: 'st-1', name: 'CVS' };
|
||||||
const fakeProduct = { _id: 'mp-1', medicineId: 'med-1', medicineName: 'Ibuprofen', brand: 'Advil' };
|
const fakeProduct = {
|
||||||
|
_id: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Ibuprofen',
|
||||||
|
brand: 'Advil',
|
||||||
|
};
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
const validInput = {
|
const validInput = {
|
||||||
|
|
@ -165,7 +170,9 @@ describe(PurchasesService.name, () => {
|
||||||
it('throws NotFoundError when purchase not found', async () => {
|
it('throws NotFoundError when purchase not found', async () => {
|
||||||
mockPurchasesRepo.findById.mockResolvedValue(null);
|
mockPurchasesRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.receive('missing', 'hh1', 'user-1')).rejects.toThrow('Purchase not found');
|
await expect(service.receive('missing', 'hh1', 'user-1')).rejects.toThrow(
|
||||||
|
'Purchase not found',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws BadRequestError when status is not ordered', async () => {
|
it('throws BadRequestError when status is not ordered', async () => {
|
||||||
|
|
@ -309,7 +316,14 @@ describe(PurchasesService.name, () => {
|
||||||
storeName: 'CVS',
|
storeName: 'CVS',
|
||||||
purchasedAt: new Date(),
|
purchasedAt: new Date(),
|
||||||
items: [
|
items: [
|
||||||
{ medicineProductId: 'mp-1', medicineId: 'med-1', name: 'X', quantity: 10, unit: 'tablet', addedToCabinet: true },
|
{
|
||||||
|
medicineProductId: 'mp-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
name: 'X',
|
||||||
|
quantity: 10,
|
||||||
|
unit: 'tablet',
|
||||||
|
addedToCabinet: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
UpdatePurchaseInput,
|
UpdatePurchaseInput,
|
||||||
PurchaseQueryInput,
|
PurchaseQueryInput,
|
||||||
} from '@meshitrack/shared';
|
} from '@meshitrack/shared';
|
||||||
import { DosageUnit } from '@meshitrack/shared';
|
import { type DosageUnit } from '@meshitrack/shared';
|
||||||
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||||
|
|
||||||
interface Deps {
|
interface Deps {
|
||||||
|
|
@ -67,7 +67,8 @@ export class PurchasesService {
|
||||||
item.medicineProductId,
|
item.medicineProductId,
|
||||||
householdId,
|
householdId,
|
||||||
);
|
);
|
||||||
if (!product) throw new NotFoundError(`Medicine product not found: ${item.medicineProductId}`);
|
if (!product)
|
||||||
|
throw new NotFoundError(`Medicine product not found: ${item.medicineProductId}`);
|
||||||
if (!resolvedName || resolvedName === item.name) {
|
if (!resolvedName || resolvedName === item.name) {
|
||||||
resolvedName = product.brand ?? resolvedName;
|
resolvedName = product.brand ?? resolvedName;
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +129,8 @@ export class PurchasesService {
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit: item.unit,
|
unit: item.unit,
|
||||||
/* v8 ignore next */
|
/* v8 ignore next */
|
||||||
pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
|
pricePerUnit:
|
||||||
|
item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
|
||||||
date: purchasedAt,
|
date: purchasedAt,
|
||||||
isInsurancePrice: false,
|
isInsurancePrice: false,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
|
|
@ -259,9 +261,7 @@ export class PurchasesService {
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPendingStockByMedicine(
|
public async getPendingStockByMedicine(householdId: string): Promise<Map<string, number>> {
|
||||||
householdId: string,
|
|
||||||
): Promise<Map<string, number>> {
|
|
||||||
const results = await this.purchasesRepository.getPendingMedicineStock(householdId);
|
const results = await this.purchasesRepository.getPendingMedicineStock(householdId);
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,13 @@ vi.mock('../../schemas/refill-list.schema.js', () => {
|
||||||
|
|
||||||
class FakeModel {
|
class FakeModel {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
constructor(data: unknown) { this.data = data; }
|
constructor(data: unknown) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
save = mockSave;
|
save = mockSave;
|
||||||
toObject() { return this.data; }
|
toObject() {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
static find = vi.fn(() => chain());
|
static find = vi.fn(() => chain());
|
||||||
static findOne = vi.fn(() => findOneChain());
|
static findOne = vi.fn(() => findOneChain());
|
||||||
static findOneAndUpdate = vi.fn(() => updateChain());
|
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { RefillListModel } from '../../schemas/refill-list.schema.js';
|
import { RefillListModel } from '../../schemas/refill-list.schema.js';
|
||||||
import type { RefillListQueryInput, UpdateRefillListInput, UpdateRefillListItemInput } from '@meshitrack/shared';
|
import type {
|
||||||
|
RefillListQueryInput,
|
||||||
|
UpdateRefillListInput,
|
||||||
|
UpdateRefillListItemInput,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
|
||||||
export interface CreateRefillListData {
|
export interface CreateRefillListData {
|
||||||
householdId: string;
|
householdId: string;
|
||||||
|
|
@ -46,9 +50,7 @@ export class RefillsRepository {
|
||||||
const hasMore = items.length > limit;
|
const hasMore = items.length > limit;
|
||||||
const data = hasMore ? items.slice(0, limit) : items;
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
const cursor =
|
const cursor =
|
||||||
data.length > 0
|
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||||
? Buffer.from(data[data.length - 1]._id.toString()).toString('base64')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,20 @@ describe('refills.routes', () => {
|
||||||
dailyConsumption: 2,
|
dailyConsumption: 2,
|
||||||
currentStock: 6,
|
currentStock: 6,
|
||||||
suggestedQuantity: 60,
|
suggestedQuantity: 60,
|
||||||
lastKnownPrice: { price: 10, pricePerUnit: 0.1, storeName: 'CVS', storeId: 'st-1', date: new Date('2026-01-01T00:00:00.000Z') },
|
lastKnownPrice: {
|
||||||
cheapestOption: { price: 8, pricePerUnit: 0.08, storeName: 'Walmart', storeId: 'st-2', date: new Date('2026-01-02T00:00:00.000Z') },
|
price: 10,
|
||||||
|
pricePerUnit: 0.1,
|
||||||
|
storeName: 'CVS',
|
||||||
|
storeId: 'st-1',
|
||||||
|
date: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
cheapestOption: {
|
||||||
|
price: 8,
|
||||||
|
pricePerUnit: 0.08,
|
||||||
|
storeName: 'Walmart',
|
||||||
|
storeId: 'st-2',
|
||||||
|
date: new Date('2026-01-02T00:00:00.000Z'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -265,26 +277,28 @@ describe('refills.routes', () => {
|
||||||
|
|
||||||
it('includes optional list fields in response', async () => {
|
it('includes optional list fields in response', async () => {
|
||||||
mockList.mockResolvedValue({
|
mockList.mockResolvedValue({
|
||||||
data: [makeFakeRefillList({
|
data: [
|
||||||
preferredStoreId: 'st-1',
|
makeFakeRefillList({
|
||||||
totalEstimatedCost: 25.5,
|
preferredStoreId: 'st-1',
|
||||||
items: [
|
totalEstimatedCost: 25.5,
|
||||||
{
|
items: [
|
||||||
_id: 'item-1',
|
{
|
||||||
medicineId: 'med-1',
|
_id: 'item-1',
|
||||||
medicineName: 'Aspirin',
|
medicineId: 'med-1',
|
||||||
quantity: 30,
|
medicineName: 'Aspirin',
|
||||||
unit: 'tablet',
|
quantity: 30,
|
||||||
estimatedPrice: 10,
|
unit: 'tablet',
|
||||||
actualPrice: 9.5,
|
estimatedPrice: 10,
|
||||||
checked: true,
|
actualPrice: 9.5,
|
||||||
checkedAt: new Date('2026-01-10T00:00:00.000Z'),
|
checked: true,
|
||||||
addedToCabinet: false,
|
checkedAt: new Date('2026-01-10T00:00:00.000Z'),
|
||||||
storeId: 'st-1',
|
addedToCabinet: false,
|
||||||
notes: 'generic brand',
|
storeId: 'st-1',
|
||||||
},
|
notes: 'generic brand',
|
||||||
],
|
},
|
||||||
})],
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -322,22 +336,24 @@ describe('refills.routes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles ObjectId-style _id in list and items', async () => {
|
it('handles ObjectId-style _id in list and items', async () => {
|
||||||
mockGetById.mockResolvedValue(makeFakeRefillList({
|
mockGetById.mockResolvedValue(
|
||||||
_id: { toString: () => 'rl-obj' },
|
makeFakeRefillList({
|
||||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
_id: { toString: () => 'rl-obj' },
|
||||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
items: [
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
{
|
items: [
|
||||||
_id: { toString: () => 'item-obj' },
|
{
|
||||||
medicineId: 'med-1',
|
_id: { toString: () => 'item-obj' },
|
||||||
medicineName: 'Aspirin',
|
medicineId: 'med-1',
|
||||||
quantity: 30,
|
medicineName: 'Aspirin',
|
||||||
unit: 'tablet',
|
quantity: 30,
|
||||||
checked: false,
|
unit: 'tablet',
|
||||||
addedToCabinet: false,
|
checked: false,
|
||||||
},
|
addedToCabinet: false,
|
||||||
],
|
},
|
||||||
}));
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,13 @@ describe(RefillsService.name, () => {
|
||||||
describe('getAlerts', () => {
|
describe('getAlerts', () => {
|
||||||
it('returns empty array when no medicines are running low', async () => {
|
it('returns empty array when no medicines are running low', async () => {
|
||||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 1, totalInCabinet: 100, daysUntilEmpty: 100 },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
dailyConsumption: 1,
|
||||||
|
totalInCabinet: 100,
|
||||||
|
daysUntilEmpty: 100,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||||
|
|
@ -55,7 +61,13 @@ describe(RefillsService.name, () => {
|
||||||
|
|
||||||
it('returns alerts for medicines below threshold', async () => {
|
it('returns alerts for medicines below threshold', async () => {
|
||||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 10, daysUntilEmpty: 5 },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
dailyConsumption: 2,
|
||||||
|
totalInCabinet: 10,
|
||||||
|
daysUntilEmpty: 5,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||||
{ _id: 'med-1', medicineStrength: 500, medicineStrengthUnit: 'mg' },
|
{ _id: 'med-1', medicineStrength: 500, medicineStrengthUnit: 'mg' },
|
||||||
|
|
@ -73,7 +85,13 @@ describe(RefillsService.name, () => {
|
||||||
|
|
||||||
it('attaches lastKnownPrice when available', async () => {
|
it('attaches lastKnownPrice when available', async () => {
|
||||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
dailyConsumption: 2,
|
||||||
|
totalInCabinet: 5,
|
||||||
|
daysUntilEmpty: 2,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue({
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue({
|
||||||
|
|
@ -93,12 +111,25 @@ describe(RefillsService.name, () => {
|
||||||
|
|
||||||
it('attaches cheapestOption from compareStores', async () => {
|
it('attaches cheapestOption from compareStores', async () => {
|
||||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
dailyConsumption: 2,
|
||||||
|
totalInCabinet: 5,
|
||||||
|
daysUntilEmpty: 2,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||||
mockPricesRepo.compareStores.mockResolvedValue([
|
mockPricesRepo.compareStores.mockResolvedValue([
|
||||||
{ storeId: 'st-1', storeName: 'CVS', latestPrice: 8, latestPricePerUnit: 0.08, currency: 'USD', date: new Date() },
|
{
|
||||||
|
storeId: 'st-1',
|
||||||
|
storeName: 'CVS',
|
||||||
|
latestPrice: 8,
|
||||||
|
latestPricePerUnit: 0.08,
|
||||||
|
currency: 'USD',
|
||||||
|
date: new Date(),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||||
|
|
@ -109,7 +140,13 @@ describe(RefillsService.name, () => {
|
||||||
|
|
||||||
it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => {
|
it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => {
|
||||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 4, daysUntilEmpty: 2 },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
dailyConsumption: 2,
|
||||||
|
totalInCabinet: 4,
|
||||||
|
daysUntilEmpty: 2,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||||
|
|
@ -126,7 +163,13 @@ describe(RefillsService.name, () => {
|
||||||
|
|
||||||
it('excludes medicines with null daysUntilEmpty', async () => {
|
it('excludes medicines with null daysUntilEmpty', async () => {
|
||||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 0, daysUntilEmpty: null },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
dailyConsumption: 2,
|
||||||
|
totalInCabinet: 0,
|
||||||
|
daysUntilEmpty: null,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||||
|
|
@ -145,7 +188,9 @@ describe(RefillsService.name, () => {
|
||||||
name: 'My List',
|
name: 'My List',
|
||||||
fromAlerts: false,
|
fromAlerts: false,
|
||||||
thresholdDays: 7,
|
thresholdDays: 7,
|
||||||
items: [{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never }],
|
items: [
|
||||||
|
{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'hh1',
|
'hh1',
|
||||||
'user-1',
|
'user-1',
|
||||||
|
|
@ -160,7 +205,11 @@ describe(RefillsService.name, () => {
|
||||||
it('creates list with no items when neither fromAlerts nor items provided', async () => {
|
it('creates list with no items when neither fromAlerts nor items provided', async () => {
|
||||||
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
|
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||||
|
|
||||||
await service.createList({ name: 'Empty List', fromAlerts: false, thresholdDays: 7 }, 'hh1', 'user-1');
|
await service.createList(
|
||||||
|
{ name: 'Empty List', fromAlerts: false, thresholdDays: 7 },
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ items: [], totalEstimatedCost: undefined }),
|
expect.objectContaining({ items: [], totalEstimatedCost: undefined }),
|
||||||
|
|
@ -176,8 +225,20 @@ describe(RefillsService.name, () => {
|
||||||
fromAlerts: false,
|
fromAlerts: false,
|
||||||
thresholdDays: 7,
|
thresholdDays: 7,
|
||||||
items: [
|
items: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never, estimatedPrice: 10 },
|
{
|
||||||
{ medicineId: 'med-2', medicineName: 'Ibuprofen', quantity: 20, unit: 'tablet' as never, estimatedPrice: 8 },
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet' as never,
|
||||||
|
estimatedPrice: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
medicineId: 'med-2',
|
||||||
|
medicineName: 'Ibuprofen',
|
||||||
|
quantity: 20,
|
||||||
|
unit: 'tablet' as never,
|
||||||
|
estimatedPrice: 8,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'hh1',
|
'hh1',
|
||||||
|
|
@ -191,20 +252,28 @@ describe(RefillsService.name, () => {
|
||||||
|
|
||||||
it('creates list from alerts when fromAlerts is true', async () => {
|
it('creates list from alerts when fromAlerts is true', async () => {
|
||||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
dailyConsumption: 2,
|
||||||
|
totalInCabinet: 5,
|
||||||
|
daysUntilEmpty: 2,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||||
mockPricesRepo.compareStores.mockResolvedValue([]);
|
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||||
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
|
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||||
|
|
||||||
await service.createList({ name: 'Auto List', fromAlerts: true, thresholdDays: 7 }, 'hh1', 'user-1');
|
await service.createList(
|
||||||
|
{ name: 'Auto List', fromAlerts: true, thresholdDays: 7 },
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
items: expect.arrayContaining([
|
items: expect.arrayContaining([expect.objectContaining({ medicineId: 'med-1' })]),
|
||||||
expect.objectContaining({ medicineId: 'med-1' }),
|
|
||||||
]),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -251,7 +320,9 @@ describe(RefillsService.name, () => {
|
||||||
it('throws NotFoundError when list not found', async () => {
|
it('throws NotFoundError when list not found', async () => {
|
||||||
mockRepo.findById.mockResolvedValue(null);
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow('Refill list not found');
|
await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow(
|
||||||
|
'Refill list not found',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError when update returns null', async () => {
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
|
|
@ -290,7 +361,9 @@ describe(RefillsService.name, () => {
|
||||||
it('throws NotFoundError when list not found', async () => {
|
it('throws NotFoundError when list not found', async () => {
|
||||||
mockRepo.findById.mockResolvedValue(null);
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.updateItem('missing', 'hh1', 'item-1', {})).rejects.toThrow('Refill list not found');
|
await expect(service.updateItem('missing', 'hh1', 'item-1', {})).rejects.toThrow(
|
||||||
|
'Refill list not found',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError when item not found', async () => {
|
it('throws NotFoundError when item not found', async () => {
|
||||||
|
|
@ -308,7 +381,15 @@ describe(RefillsService.name, () => {
|
||||||
mockRepo.findById.mockResolvedValue({
|
mockRepo.findById.mockResolvedValue({
|
||||||
_id: 'rl-1',
|
_id: 'rl-1',
|
||||||
items: [
|
items: [
|
||||||
{ _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: false },
|
{
|
||||||
|
_id: { toString: () => 'item-1' },
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
checked: true,
|
||||||
|
addedToCabinet: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
|
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
|
||||||
|
|
@ -324,7 +405,16 @@ describe(RefillsService.name, () => {
|
||||||
mockRepo.findById.mockResolvedValue({
|
mockRepo.findById.mockResolvedValue({
|
||||||
_id: 'rl-1',
|
_id: 'rl-1',
|
||||||
items: [
|
items: [
|
||||||
{ _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', actualPrice: 9, checked: true, addedToCabinet: false },
|
{
|
||||||
|
_id: { toString: () => 'item-1' },
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
actualPrice: 9,
|
||||||
|
checked: true,
|
||||||
|
addedToCabinet: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
|
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
|
||||||
|
|
@ -343,7 +433,15 @@ describe(RefillsService.name, () => {
|
||||||
mockRepo.findById.mockResolvedValue({
|
mockRepo.findById.mockResolvedValue({
|
||||||
_id: 'rl-1',
|
_id: 'rl-1',
|
||||||
items: [
|
items: [
|
||||||
{ _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: false, addedToCabinet: false },
|
{
|
||||||
|
_id: 'item-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
checked: false,
|
||||||
|
addedToCabinet: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -357,7 +455,15 @@ describe(RefillsService.name, () => {
|
||||||
mockRepo.findById.mockResolvedValue({
|
mockRepo.findById.mockResolvedValue({
|
||||||
_id: 'rl-1',
|
_id: 'rl-1',
|
||||||
items: [
|
items: [
|
||||||
{ _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: true },
|
{
|
||||||
|
_id: 'item-1',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
checked: true,
|
||||||
|
addedToCabinet: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import type {
|
||||||
UpdateRefillListInput,
|
UpdateRefillListInput,
|
||||||
UpdateRefillListItemInput,
|
UpdateRefillListItemInput,
|
||||||
RefillListQueryInput,
|
RefillListQueryInput,
|
||||||
RefillAlertQueryInput,
|
|
||||||
} from '@meshitrack/shared';
|
} from '@meshitrack/shared';
|
||||||
import { RefillListStatus } from '@meshitrack/shared';
|
import { RefillListStatus } from '@meshitrack/shared';
|
||||||
import { NotFoundError } from '../../common/errors.js';
|
import { NotFoundError } from '../../common/errors.js';
|
||||||
|
|
@ -58,7 +57,10 @@ export class RefillsService {
|
||||||
|
|
||||||
// Get strength data from cabinet aggregate
|
// Get strength data from cabinet aggregate
|
||||||
const summaries = await this.cabinetRepository.getAggregateSummary(householdId);
|
const summaries = await this.cabinetRepository.getAggregateSummary(householdId);
|
||||||
const summaryMap = new Map<string, { medicineStrength: number; medicineStrengthUnit: string }>();
|
const summaryMap = new Map<
|
||||||
|
string,
|
||||||
|
{ medicineStrength: number; medicineStrengthUnit: string }
|
||||||
|
>();
|
||||||
for (const s of summaries) {
|
for (const s of summaries) {
|
||||||
summaryMap.set(s._id as string, {
|
summaryMap.set(s._id as string, {
|
||||||
medicineStrength: s.medicineStrength as number,
|
medicineStrength: s.medicineStrength as number,
|
||||||
|
|
@ -217,17 +219,19 @@ export class RefillsService {
|
||||||
public async addToCabinet(listId: string, householdId: string, userId: string) {
|
public async addToCabinet(listId: string, householdId: string, userId: string) {
|
||||||
const list = await this.getById(listId, householdId);
|
const list = await this.getById(listId, householdId);
|
||||||
|
|
||||||
const checkedItems = (list.items as Array<{
|
const checkedItems = (
|
||||||
_id: { toString: () => string };
|
list.items as Array<{
|
||||||
medicineId: string;
|
_id: { toString: () => string };
|
||||||
medicineName: string;
|
medicineId: string;
|
||||||
quantity: number;
|
medicineName: string;
|
||||||
unit: string;
|
quantity: number;
|
||||||
actualPrice?: number;
|
unit: string;
|
||||||
storeId?: string;
|
actualPrice?: number;
|
||||||
checked: boolean;
|
storeId?: string;
|
||||||
addedToCabinet: boolean;
|
checked: boolean;
|
||||||
}>).filter((item) => item.checked && !item.addedToCabinet);
|
addedToCabinet: boolean;
|
||||||
|
}>
|
||||||
|
).filter((item) => item.checked && !item.addedToCabinet);
|
||||||
|
|
||||||
if (checkedItems.length === 0) {
|
if (checkedItems.length === 0) {
|
||||||
return { addedCount: 0, priceRecordsCreated: 0 };
|
return { addedCount: 0, priceRecordsCreated: 0 };
|
||||||
|
|
@ -242,9 +246,10 @@ export class RefillsService {
|
||||||
medicineId: item.medicineId,
|
medicineId: item.medicineId,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit: item.unit as never,
|
unit: item.unit as never,
|
||||||
unitPrice: item.actualPrice !== undefined && item.quantity > 0
|
unitPrice:
|
||||||
? item.actualPrice / item.quantity
|
item.actualPrice !== undefined && item.quantity > 0
|
||||||
: undefined,
|
? item.actualPrice / item.quantity
|
||||||
|
: undefined,
|
||||||
totalPrice: item.actualPrice,
|
totalPrice: item.actualPrice,
|
||||||
storeId: item.storeId,
|
storeId: item.storeId,
|
||||||
purchaseDate: new Date().toISOString(),
|
purchaseDate: new Date().toISOString(),
|
||||||
|
|
@ -265,9 +270,7 @@ export class RefillsService {
|
||||||
const list = await this.getById(listId, householdId);
|
const list = await this.getById(listId, householdId);
|
||||||
|
|
||||||
const medicineIds = [
|
const medicineIds = [
|
||||||
...new Set(
|
...new Set((list.items as Array<{ medicineId: string }>).map((item) => item.medicineId)),
|
||||||
(list.items as Array<{ medicineId: string }>).map((item) => item.medicineId),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const comparisons = await Promise.all(
|
const comparisons = await Promise.all(
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,12 @@ export class RegimensRepository {
|
||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async create(data: CreateRegimenData, householdId: string, userId: string, createdBy: string) {
|
public async create(
|
||||||
|
data: CreateRegimenData,
|
||||||
|
householdId: string,
|
||||||
|
userId: string,
|
||||||
|
createdBy: string,
|
||||||
|
) {
|
||||||
const regimen = new RegimenModel({ ...data, householdId, userId, createdBy });
|
const regimen = new RegimenModel({ ...data, householdId, userId, createdBy });
|
||||||
const saved = await regimen.save();
|
const saved = await regimen.save();
|
||||||
return saved.toObject();
|
return saved.toObject();
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,15 @@ vi.mock('jose', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { mockList, mockGetById, mockCreate, mockUpdate, mockDelete, mockCalculateBurnRates } = vi.hoisted(() => ({
|
const { mockList, mockGetById, mockCreate, mockUpdate, mockDelete, mockCalculateBurnRates } =
|
||||||
mockList: vi.fn(),
|
vi.hoisted(() => ({
|
||||||
mockGetById: vi.fn(),
|
mockList: vi.fn(),
|
||||||
mockCreate: vi.fn(),
|
mockGetById: vi.fn(),
|
||||||
mockUpdate: vi.fn(),
|
mockCreate: vi.fn(),
|
||||||
mockDelete: vi.fn(),
|
mockUpdate: vi.fn(),
|
||||||
mockCalculateBurnRates: vi.fn(),
|
mockDelete: vi.fn(),
|
||||||
}));
|
mockCalculateBurnRates: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./regimens.repository.js', () => ({
|
vi.mock('./regimens.repository.js', () => ({
|
||||||
RegimensRepository: class {
|
RegimensRepository: class {
|
||||||
|
|
@ -467,7 +468,12 @@ describe('regimens.routes', () => {
|
||||||
payload: { isActive: false },
|
payload: { isActive: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockUpdate).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1', expect.objectContaining({ isActive: false }));
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
'reg-1',
|
||||||
|
'hh1',
|
||||||
|
'kc-1',
|
||||||
|
expect.objectContaining({ isActive: false }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ function toRegimenResponse(doc: AnyRegimenDoc) {
|
||||||
dosage: med.dosage,
|
dosage: med.dosage,
|
||||||
dosageUnit: med.dosageUnit,
|
dosageUnit: med.dosageUnit,
|
||||||
frequency: med.frequency,
|
frequency: med.frequency,
|
||||||
...(med.customFrequencyPerDay != null ? { customFrequencyPerDay: med.customFrequencyPerDay } : {}),
|
...(med.customFrequencyPerDay != null
|
||||||
|
? { customFrequencyPerDay: med.customFrequencyPerDay }
|
||||||
|
: {}),
|
||||||
...(med.timeOfDay ? { timeOfDay: med.timeOfDay } : {}),
|
...(med.timeOfDay ? { timeOfDay: med.timeOfDay } : {}),
|
||||||
...(med.instructions ? { instructions: med.instructions } : {}),
|
...(med.instructions ? { instructions: med.instructions } : {}),
|
||||||
})),
|
})),
|
||||||
|
|
@ -143,7 +145,11 @@ export default fp(
|
||||||
},
|
},
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
const service = fastify.diContainer.resolve('regimensService');
|
const service = fastify.diContainer.resolve('regimensService');
|
||||||
const regimen = await service.getById(request.params.id, request.params.householdId, request.user.keycloakId);
|
const regimen = await service.getById(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
return reply.send(toRegimenResponse(regimen));
|
return reply.send(toRegimenResponse(regimen));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -199,7 +205,11 @@ export default fp(
|
||||||
},
|
},
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
const service = fastify.diContainer.resolve('regimensService');
|
const service = fastify.diContainer.resolve('regimensService');
|
||||||
await service.delete(request.params.id, request.params.householdId, request.user.keycloakId);
|
await service.delete(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
return reply.status(204).send();
|
return reply.status(204).send();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,9 @@ describe(RegimensService.name, () => {
|
||||||
it('throws NotFoundError when not found', async () => {
|
it('throws NotFoundError when not found', async () => {
|
||||||
mockRegimensRepo.findById.mockResolvedValue(null);
|
mockRegimensRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.getById('reg-missing', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
|
await expect(service.getById('reg-missing', 'hh1', 'user-1')).rejects.toThrow(
|
||||||
|
'Regimen not found',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -108,7 +110,11 @@ describe(RegimensService.name, () => {
|
||||||
strengthUnit: 'mg',
|
strengthUnit: 'mg',
|
||||||
form: 'tablet',
|
form: 'tablet',
|
||||||
});
|
});
|
||||||
const created = { _id: 'reg-1', ...createInput, medications: [{ medicineId: 'med-1', medicineName: 'Metformin' }] };
|
const created = {
|
||||||
|
_id: 'reg-1',
|
||||||
|
...createInput,
|
||||||
|
medications: [{ medicineId: 'med-1', medicineName: 'Metformin' }],
|
||||||
|
};
|
||||||
mockRegimensRepo.create.mockResolvedValue(created);
|
mockRegimensRepo.create.mockResolvedValue(created);
|
||||||
|
|
||||||
const result = await service.create(createInput, 'hh1', 'user-1');
|
const result = await service.create(createInput, 'hh1', 'user-1');
|
||||||
|
|
@ -255,7 +261,9 @@ describe(RegimensService.name, () => {
|
||||||
const result = await service.update('reg-1', 'hh1', 'user-1', { name: 'Evening' });
|
const result = await service.update('reg-1', 'hh1', 'user-1', { name: 'Evening' });
|
||||||
|
|
||||||
expect(result).toEqual(updated);
|
expect(result).toEqual(updated);
|
||||||
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { name: 'Evening' });
|
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {
|
||||||
|
name: 'Evening',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates isActive only', async () => {
|
it('updates isActive only', async () => {
|
||||||
|
|
@ -264,7 +272,9 @@ describe(RegimensService.name, () => {
|
||||||
|
|
||||||
await service.update('reg-1', 'hh1', 'user-1', { isActive: false });
|
await service.update('reg-1', 'hh1', 'user-1', { isActive: false });
|
||||||
|
|
||||||
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { isActive: false });
|
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates medications with denormalization', async () => {
|
it('updates medications with denormalization', async () => {
|
||||||
|
|
@ -304,9 +314,9 @@ describe(RegimensService.name, () => {
|
||||||
it('throws NotFoundError when regimen not found on initial lookup', async () => {
|
it('throws NotFoundError when regimen not found on initial lookup', async () => {
|
||||||
mockRegimensRepo.findById.mockResolvedValue(null);
|
mockRegimensRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' })).rejects.toThrow(
|
await expect(
|
||||||
'Regimen not found',
|
service.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' }),
|
||||||
);
|
).rejects.toThrow('Regimen not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError when update returns null', async () => {
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
|
|
@ -360,7 +370,9 @@ describe(RegimensService.name, () => {
|
||||||
it('throws NotFoundError when regimen not found on initial lookup', async () => {
|
it('throws NotFoundError when regimen not found on initial lookup', async () => {
|
||||||
mockRegimensRepo.findById.mockResolvedValue(null);
|
mockRegimensRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.delete('reg-missing', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
|
await expect(service.delete('reg-missing', 'hh1', 'user-1')).rejects.toThrow(
|
||||||
|
'Regimen not found',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError when softDelete returns null', async () => {
|
it('throws NotFoundError when softDelete returns null', async () => {
|
||||||
|
|
@ -444,13 +456,23 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.DAILY,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
_id: 'reg-2',
|
_id: 'reg-2',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.TWICE_DAILY },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.TWICE_DAILY,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -479,7 +501,12 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Metformin',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.DAILY,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -498,7 +525,12 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Ibuprofen', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Ibuprofen',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.AS_NEEDED,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -562,9 +594,24 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY },
|
{
|
||||||
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY },
|
medicineId: 'med-1',
|
||||||
{ medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
|
medicineName: 'Med A',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.DAILY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
medicineId: 'med-2',
|
||||||
|
medicineName: 'Med B',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.DAILY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
medicineId: 'med-3',
|
||||||
|
medicineName: 'Med C',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.AS_NEEDED,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -590,8 +637,18 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
|
{
|
||||||
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Med A',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.AS_NEEDED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
medicineId: 'med-2',
|
||||||
|
medicineName: 'Med B',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.AS_NEEDED,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -606,9 +663,24 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
|
{
|
||||||
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY },
|
medicineId: 'med-1',
|
||||||
{ medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.DAILY },
|
medicineName: 'Med A',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.AS_NEEDED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
medicineId: 'med-2',
|
||||||
|
medicineName: 'Med B',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.DAILY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
medicineId: 'med-3',
|
||||||
|
medicineName: 'Med C',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.DAILY,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -634,7 +706,12 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Weekly Med', dosage: 1, frequency: DosageFrequency.WEEKLY },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Weekly Med',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.WEEKLY,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -656,7 +733,12 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'EOD Med', dosage: 1, frequency: DosageFrequency.EVERY_OTHER_DAY },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'EOD Med',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.EVERY_OTHER_DAY,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -678,7 +760,12 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'TID Med', dosage: 1, frequency: DosageFrequency.THREE_TIMES_DAILY },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'TID Med',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.THREE_TIMES_DAILY,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -699,7 +786,12 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Expensive Med', dosage: 2, frequency: DosageFrequency.DAILY },
|
{
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Expensive Med',
|
||||||
|
dosage: 2,
|
||||||
|
frequency: DosageFrequency.DAILY,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -725,8 +817,18 @@ describe(RegimensService.name, () => {
|
||||||
{
|
{
|
||||||
_id: 'reg-1',
|
_id: 'reg-1',
|
||||||
medications: [
|
medications: [
|
||||||
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY },
|
{
|
||||||
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.TWICE_DAILY },
|
medicineId: 'med-1',
|
||||||
|
medicineName: 'Med A',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.DAILY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
medicineId: 'med-2',
|
||||||
|
medicineName: 'Med B',
|
||||||
|
dosage: 1,
|
||||||
|
frequency: DosageFrequency.TWICE_DAILY,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -737,9 +839,7 @@ describe(RegimensService.name, () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
|
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
|
||||||
new Map([
|
new Map([['med-1', { avgUnitPrice: 2.0, currency: 'USD' }]]),
|
||||||
['med-1', { avgUnitPrice: 2.0, currency: 'USD' }],
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,7 @@ export class RegimensService {
|
||||||
const regimens = await this.regimensRepository.findActiveByUser(householdId, userId);
|
const regimens = await this.regimensRepository.findActiveByUser(householdId, userId);
|
||||||
|
|
||||||
// Sum daily consumption per medicine across all active regimens
|
// Sum daily consumption per medicine across all active regimens
|
||||||
const consumptionMap = new Map<
|
const consumptionMap = new Map<string, { medicineName: string; dailyConsumption: number }>();
|
||||||
string,
|
|
||||||
{ medicineName: string; dailyConsumption: number }
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const regimen of regimens) {
|
for (const regimen of regimens) {
|
||||||
for (const med of regimen.medications) {
|
for (const med of regimen.medications) {
|
||||||
|
|
@ -115,10 +112,7 @@ export class RegimensService {
|
||||||
|
|
||||||
// Get cabinet summary for all medicines in regimens
|
// Get cabinet summary for all medicines in regimens
|
||||||
const summaryResults = await this.cabinetRepository.getAggregateSummary(householdId);
|
const summaryResults = await this.cabinetRepository.getAggregateSummary(householdId);
|
||||||
const stockMap = new Map<
|
const stockMap = new Map<string, { totalQuantity: number; earliestExpiry: Date | null }>();
|
||||||
string,
|
|
||||||
{ totalQuantity: number; earliestExpiry: Date | null }
|
|
||||||
>();
|
|
||||||
for (const s of summaryResults) {
|
for (const s of summaryResults) {
|
||||||
stockMap.set(s._id as string, {
|
stockMap.set(s._id as string, {
|
||||||
totalQuantity: s.totalQuantity as number,
|
totalQuantity: s.totalQuantity as number,
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,13 @@ vi.mock('../../schemas/store.schema.js', () => {
|
||||||
|
|
||||||
class FakeModel {
|
class FakeModel {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
constructor(data: unknown) { this.data = data; }
|
constructor(data: unknown) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
save = mockSave;
|
save = mockSave;
|
||||||
toObject() { return this.data; }
|
toObject() {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
static find = vi.fn(() => chain());
|
static find = vi.fn(() => chain());
|
||||||
static findOne = vi.fn(() => findOneChain());
|
static findOne = vi.fn(() => findOneChain());
|
||||||
static findOneAndUpdate = vi.fn(() => updateChain());
|
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ export class StoresRepository {
|
||||||
const filter: Record<string, unknown> = { householdId };
|
const filter: Record<string, unknown> = { householdId };
|
||||||
|
|
||||||
if (query.tags) {
|
if (query.tags) {
|
||||||
const tagList = query.tags.split(',').map((t) => t.trim()).filter(Boolean);
|
const tagList = query.tags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
if (tagList.length > 0) filter['tags'] = { $in: tagList };
|
if (tagList.length > 0) filter['tags'] = { $in: tagList };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,11 +140,13 @@ describe('stores.routes', () => {
|
||||||
|
|
||||||
it('handles ObjectId and Date in response', async () => {
|
it('handles ObjectId and Date in response', async () => {
|
||||||
mockList.mockResolvedValue({
|
mockList.mockResolvedValue({
|
||||||
data: [makeFakeStore({
|
data: [
|
||||||
_id: { toString: () => 'st-obj' },
|
makeFakeStore({
|
||||||
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
_id: { toString: () => 'st-obj' },
|
||||||
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
|
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||||
})],
|
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -162,12 +164,14 @@ describe('stores.routes', () => {
|
||||||
|
|
||||||
it('includes optional fields in response when present', async () => {
|
it('includes optional fields in response when present', async () => {
|
||||||
mockList.mockResolvedValue({
|
mockList.mockResolvedValue({
|
||||||
data: [makeFakeStore({
|
data: [
|
||||||
address: '123 Main St',
|
makeFakeStore({
|
||||||
location: { lat: 40.7128, lng: -74.006 },
|
address: '123 Main St',
|
||||||
url: 'https://walgreens.com',
|
location: { lat: 40.7128, lng: -74.006 },
|
||||||
notes: 'Open 24h',
|
url: 'https://walgreens.com',
|
||||||
})],
|
notes: 'Open 24h',
|
||||||
|
}),
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,11 @@ describe(StoresService.name, () => {
|
||||||
const store = { _id: 'st-1', name: 'CVS' };
|
const store = { _id: 'st-1', name: 'CVS' };
|
||||||
mockRepo.create.mockResolvedValue(store);
|
mockRepo.create.mockResolvedValue(store);
|
||||||
|
|
||||||
const result = await service.create({ name: 'CVS', tags: [], isActive: true } as never, 'hh1', 'user-1');
|
const result = await service.create(
|
||||||
|
{ name: 'CVS', tags: [], isActive: true } as never,
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual(store);
|
expect(result).toEqual(store);
|
||||||
expect(mockRepo.create).toHaveBeenCalledWith(expect.anything(), 'hh1', 'user-1');
|
expect(mockRepo.create).toHaveBeenCalledWith(expect.anything(), 'hh1', 'user-1');
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,7 @@ async function migrate() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
await db
|
await db.collection('regimens').updateOne({ _id: regimen._id }, { $set: { medications } });
|
||||||
.collection('regimens')
|
|
||||||
.updateOne({ _id: regimen._id }, { $set: { medications } });
|
|
||||||
console.log(`regimens: updated medications in regimen ${regimen._id}`);
|
console.log(`regimens: updated medications in regimen ${regimen._id}`);
|
||||||
total++;
|
total++;
|
||||||
}
|
}
|
||||||
|
|
@ -88,9 +86,7 @@ async function migrate() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
await db
|
await db.collection('refillists').updateOne({ _id: list._id }, { $set: { items } });
|
||||||
.collection('refillists')
|
|
||||||
.updateOne({ _id: list._id }, { $set: { items } });
|
|
||||||
console.log(`refillists: updated items in list ${list._id}`);
|
console.log(`refillists: updated items in list ${list._id}`);
|
||||||
total++;
|
total++;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import { CabinetEventType, CabinetEventSourceType } from './cabinet-event.enums.js';
|
||||||
CabinetEventType,
|
|
||||||
CabinetEventSourceType,
|
|
||||||
} from './cabinet-event.enums.js';
|
|
||||||
|
|
||||||
describe(CabinetEventType.name, () => {
|
describe(CabinetEventType.name, () => {
|
||||||
it('has exactly 6 values', () => {
|
it('has exactly 6 values', () => {
|
||||||
|
|
@ -32,8 +29,6 @@ describe(CabinetEventSourceType.name, () => {
|
||||||
['ORGANIZER_UNDO', 'organizer_undo'],
|
['ORGANIZER_UNDO', 'organizer_undo'],
|
||||||
['REFILL_LIST', 'refill_list'],
|
['REFILL_LIST', 'refill_list'],
|
||||||
])('%s = %s', (key, value) => {
|
])('%s = %s', (key, value) => {
|
||||||
expect(
|
expect(CabinetEventSourceType[key as keyof typeof CabinetEventSourceType]).toBe(value);
|
||||||
CabinetEventSourceType[key as keyof typeof CabinetEventSourceType],
|
|
||||||
).toBe(value);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import { DosageFrequency, TimeOfDay, OrganizerFillStatus } from './regimen.enums.js';
|
||||||
DosageFrequency,
|
|
||||||
TimeOfDay,
|
|
||||||
OrganizerFillStatus,
|
|
||||||
} from './regimen.enums.js';
|
|
||||||
|
|
||||||
describe(DosageFrequency.name, () => {
|
describe(DosageFrequency.name, () => {
|
||||||
it('has exactly 7 values', () => {
|
it('has exactly 7 values', () => {
|
||||||
|
|
@ -48,8 +44,6 @@ describe(OrganizerFillStatus.name, () => {
|
||||||
['PARTIAL', 'partial'],
|
['PARTIAL', 'partial'],
|
||||||
['REVERSED', 'reversed'],
|
['REVERSED', 'reversed'],
|
||||||
])('%s = %s', (key, value) => {
|
])('%s = %s', (key, value) => {
|
||||||
expect(
|
expect(OrganizerFillStatus[key as keyof typeof OrganizerFillStatus]).toBe(value);
|
||||||
OrganizerFillStatus[key as keyof typeof OrganizerFillStatus],
|
|
||||||
).toBe(value);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import type {
|
import type { CabinetEventType, CabinetEventSourceType } from '../enums/cabinet-event.enums.js';
|
||||||
CabinetEventType,
|
|
||||||
CabinetEventSourceType,
|
|
||||||
} from '../enums/cabinet-event.enums.js';
|
|
||||||
|
|
||||||
export interface CabinetEvent {
|
export interface CabinetEvent {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import type {
|
import type { DosageFrequency, TimeOfDay } from '../enums/regimen.enums.js';
|
||||||
DosageFrequency,
|
|
||||||
TimeOfDay,
|
|
||||||
} from '../enums/regimen.enums.js';
|
|
||||||
import type { DosageUnit, MedicineForm, StrengthUnit } from '../enums/medicine.enums.js';
|
import type { DosageUnit, MedicineForm, StrengthUnit } from '../enums/medicine.enums.js';
|
||||||
|
|
||||||
export interface Regimen {
|
export interface Regimen {
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,7 @@ import { DosageFrequency } from '../enums/regimen.enums.js';
|
||||||
* For example, TWICE_DAILY returns 2, WEEKLY returns 1/7.
|
* For example, TWICE_DAILY returns 2, WEEKLY returns 1/7.
|
||||||
* AS_NEEDED returns 0 (excluded from calculations).
|
* AS_NEEDED returns 0 (excluded from calculations).
|
||||||
*/
|
*/
|
||||||
export function getFrequencyMultiplier(
|
export function getFrequencyMultiplier(frequency: DosageFrequency, customPerDay?: number): number {
|
||||||
frequency: DosageFrequency,
|
|
||||||
customPerDay?: number,
|
|
||||||
): number {
|
|
||||||
switch (frequency) {
|
switch (frequency) {
|
||||||
case DosageFrequency.DAILY:
|
case DosageFrequency.DAILY:
|
||||||
return 1;
|
return 1;
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,21 @@ describe('CreateMedicinePriceRecordSchema', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-positive price', () => {
|
it('rejects non-positive price', () => {
|
||||||
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, price: 0 }).success).toBe(false);
|
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, price: 0 }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-positive quantity', () => {
|
it('rejects non-positive quantity', () => {
|
||||||
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, quantity: -1 }).success).toBe(false);
|
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, quantity: -1 }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid unit', () => {
|
it('rejects invalid unit', () => {
|
||||||
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, unit: 'spoon' }).success).toBe(false);
|
expect(
|
||||||
|
CreateMedicinePriceRecordSchema.safeParse({ ...validInput, unit: 'spoon' }).success,
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,26 +71,32 @@ export const StoreComparisonResponseSchema = z.object({
|
||||||
|
|
||||||
export const MedicineSpendingAnalyticsResponseSchema = z.object({
|
export const MedicineSpendingAnalyticsResponseSchema = z.object({
|
||||||
spendingOverTime: z.array(z.object({ period: z.string(), total: z.number() })),
|
spendingOverTime: z.array(z.object({ period: z.string(), total: z.number() })),
|
||||||
topBySpending: z.array(z.object({
|
topBySpending: z.array(
|
||||||
medicineId: z.string(),
|
z.object({
|
||||||
medicineName: z.string(),
|
medicineId: z.string(),
|
||||||
totalSpent: z.number(),
|
medicineName: z.string(),
|
||||||
avgPricePerUnit: z.number(),
|
totalSpent: z.number(),
|
||||||
})),
|
avgPricePerUnit: z.number(),
|
||||||
spendingByStore: z.array(z.object({
|
}),
|
||||||
storeId: z.string(),
|
),
|
||||||
storeName: z.string(),
|
spendingByStore: z.array(
|
||||||
totalSpent: z.number(),
|
z.object({
|
||||||
purchaseCount: z.number(),
|
storeId: z.string(),
|
||||||
})),
|
storeName: z.string(),
|
||||||
priceAlerts: z.array(z.object({
|
totalSpent: z.number(),
|
||||||
medicineId: z.string(),
|
purchaseCount: z.number(),
|
||||||
medicineName: z.string(),
|
}),
|
||||||
storeName: z.string(),
|
),
|
||||||
previousPrice: z.number(),
|
priceAlerts: z.array(
|
||||||
currentPrice: z.number(),
|
z.object({
|
||||||
changePercent: z.number(),
|
medicineId: z.string(),
|
||||||
})),
|
medicineName: z.string(),
|
||||||
|
storeName: z.string(),
|
||||||
|
previousPrice: z.number(),
|
||||||
|
currentPrice: z.number(),
|
||||||
|
changePercent: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateMedicinePriceRecordInput = z.infer<typeof CreateMedicinePriceRecordSchema>;
|
export type CreateMedicinePriceRecordInput = z.infer<typeof CreateMedicinePriceRecordSchema>;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ import {
|
||||||
|
|
||||||
describe('CreatePurchaseItemSchema', () => {
|
describe('CreatePurchaseItemSchema', () => {
|
||||||
it('accepts minimal valid item', () => {
|
it('accepts minimal valid item', () => {
|
||||||
const result = CreatePurchaseItemSchema.safeParse({ name: 'Tylenol 30ct', quantity: 30, unit: 'tablet' });
|
const result = CreatePurchaseItemSchema.safeParse({
|
||||||
|
name: 'Tylenol 30ct',
|
||||||
|
quantity: 30,
|
||||||
|
unit: 'tablet',
|
||||||
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) expect(result.data.addedToCabinet).toBeUndefined();
|
if (result.success) expect(result.data.addedToCabinet).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
@ -27,19 +31,29 @@ describe('CreatePurchaseItemSchema', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects empty name', () => {
|
it('rejects empty name', () => {
|
||||||
expect(CreatePurchaseItemSchema.safeParse({ name: '', quantity: 1, unit: 'tablet' }).success).toBe(false);
|
expect(
|
||||||
|
CreatePurchaseItemSchema.safeParse({ name: '', quantity: 1, unit: 'tablet' }).success,
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-positive quantity', () => {
|
it('rejects non-positive quantity', () => {
|
||||||
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 0, unit: 'tablet' }).success).toBe(false);
|
expect(
|
||||||
|
CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 0, unit: 'tablet' }).success,
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects empty unit', () => {
|
it('rejects empty unit', () => {
|
||||||
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 1, unit: '' }).success).toBe(false);
|
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 1, unit: '' }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trims name whitespace', () => {
|
it('trims name whitespace', () => {
|
||||||
const result = CreatePurchaseItemSchema.parse({ name: ' Tylenol ', quantity: 1, unit: 'tablet' });
|
const result = CreatePurchaseItemSchema.parse({
|
||||||
|
name: ' Tylenol ',
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'tablet',
|
||||||
|
});
|
||||||
expect(result.name).toBe('Tylenol');
|
expect(result.name).toBe('Tylenol');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -53,7 +67,11 @@ describe('CreatePurchaseSchema', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts ordered status', () => {
|
it('accepts ordered status', () => {
|
||||||
const result = CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'ordered', items: [validItem] });
|
const result = CreatePurchaseSchema.safeParse({
|
||||||
|
storeId: 'st-1',
|
||||||
|
status: 'ordered',
|
||||||
|
items: [validItem],
|
||||||
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -66,7 +84,10 @@ describe('CreatePurchaseSchema', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid status', () => {
|
it('rejects invalid status', () => {
|
||||||
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'pending', items: [validItem] }).success).toBe(false);
|
expect(
|
||||||
|
CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'pending', items: [validItem] })
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts optional purchasedAt datetime', () => {
|
it('accepts optional purchasedAt datetime', () => {
|
||||||
|
|
@ -79,7 +100,13 @@ describe('CreatePurchaseSchema', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid purchasedAt', () => {
|
it('rejects invalid purchasedAt', () => {
|
||||||
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', items: [validItem], purchasedAt: 'not-a-date' }).success).toBe(false);
|
expect(
|
||||||
|
CreatePurchaseSchema.safeParse({
|
||||||
|
storeId: 'st-1',
|
||||||
|
items: [validItem],
|
||||||
|
purchasedAt: 'not-a-date',
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,8 @@ describe('UpdateRefillListItemSchema', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts actualPrice', () => {
|
it('accepts actualPrice', () => {
|
||||||
const result = UpdateRefillListItemSchema.parse({ actualPrice: 12.50 });
|
const result = UpdateRefillListItemSchema.parse({ actualPrice: 12.5 });
|
||||||
expect(result.actualPrice).toBe(12.50);
|
expect(result.actualPrice).toBe(12.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects negative actualPrice', () => {
|
it('rejects negative actualPrice', () => {
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,7 @@ describe('CreateRegimenSchema', () => {
|
||||||
it('accepts multiple medications', () => {
|
it('accepts multiple medications', () => {
|
||||||
const result = CreateRegimenSchema.safeParse({
|
const result = CreateRegimenSchema.safeParse({
|
||||||
...validRegimen,
|
...validRegimen,
|
||||||
medications: [
|
medications: [validMedication, { ...validMedication, medicineId: 'med-2', dosage: 2 }],
|
||||||
validMedication,
|
|
||||||
{ ...validMedication, medicineId: 'med-2', dosage: 2 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,7 @@ export const RegimenMedicationInputSchema = z
|
||||||
instructions: z.string().max(500).trim().optional(),
|
instructions: z.string().max(500).trim().optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) =>
|
(data) => data.frequency !== DosageFrequency.CUSTOM || data.customFrequencyPerDay !== undefined,
|
||||||
data.frequency !== DosageFrequency.CUSTOM ||
|
|
||||||
data.customFrequencyPerDay !== undefined,
|
|
||||||
{ message: 'customFrequencyPerDay is required when frequency is custom' },
|
{ message: 'customFrequencyPerDay is required when frequency is custom' },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,15 @@ describe('CreateStoreSchema', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid location lat', () => {
|
it('rejects invalid location lat', () => {
|
||||||
expect(
|
expect(CreateStoreSchema.safeParse({ name: 'X', location: { lat: 91, lng: 0 } }).success).toBe(
|
||||||
CreateStoreSchema.safeParse({ name: 'X', location: { lat: 91, lng: 0 } }).success,
|
false,
|
||||||
).toBe(false);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid location lng', () => {
|
it('rejects invalid location lng', () => {
|
||||||
expect(
|
expect(CreateStoreSchema.safeParse({ name: 'X', location: { lat: 0, lng: 181 } }).success).toBe(
|
||||||
CreateStoreSchema.safeParse({ name: 'X', location: { lat: 0, lng: 181 } }).success,
|
false,
|
||||||
).toBe(false);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trims name whitespace', () => {
|
it('trims name whitespace', () => {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,12 @@ export default tseslint.config(
|
||||||
settings: {
|
settings: {
|
||||||
react: { version: 'detect' },
|
react: { version: 'detect' },
|
||||||
},
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
project: ['./tsconfig.json'],
|
||||||
|
},
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-explicit-any': 'error',
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit' }],
|
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit' }],
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ const nextConfig: NextConfig = {
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
// The shared package source uses ESM `.js` extensions on imports (e.g. `./enums/index.js`).
|
// The shared package source uses ESM `.js` extensions on imports (e.g. `./enums/index.js`).
|
||||||
// When Next.js resolves via tsconfig paths to the raw `.ts` source, webpack needs to
|
// When Next.js resolves via tsconfig paths to the raw `.ts` source, webpack needs to
|
||||||
// know that `.js` imports inside that directory should resolve to `.ts` files.
|
// know that `.js` imports inside that directory should resolve to `.ts`/`.tsx` files.
|
||||||
config.resolve = config.resolve ?? {};
|
config.resolve = config.resolve ?? {};
|
||||||
config.resolve.extensionAlias = {
|
config.resolve.extensionAlias = {
|
||||||
...config.resolve.extensionAlias,
|
...config.resolve.extensionAlias,
|
||||||
'.js': ['.ts', '.js'],
|
'.js': ['.tsx', '.ts', '.jsx', '.js'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure the shared source directory is included in the module resolution
|
// Ensure the shared source directory is included in the module resolution
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ import DashboardLayout from '../layout';
|
||||||
|
|
||||||
describe('DashboardLayout', () => {
|
describe('DashboardLayout', () => {
|
||||||
it('renders sidebar, topbar and children', () => {
|
it('renders sidebar, topbar and children', () => {
|
||||||
render(<DashboardLayout>
|
render(
|
||||||
<div data-testid="child">content</div>
|
<DashboardLayout>
|
||||||
</DashboardLayout>);
|
<div data-testid="child">content</div>
|
||||||
|
</DashboardLayout>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('topbar')).toBeInTheDocument();
|
expect(screen.getByTestId('topbar')).toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render, container } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
import DashboardLoading from '../loading';
|
import DashboardLoading from '../loading';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
vi.mock('swr', () => ({ default: vi.fn(() => ({ data: undefined })) }));
|
||||||
vi.mock('next/link', () => ({
|
vi.mock('next/link', () => ({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||||
|
|
@ -8,21 +11,29 @@ vi.mock('next/link', () => ({
|
||||||
|
|
||||||
import DashboardPage from '../page';
|
import DashboardPage from '../page';
|
||||||
|
|
||||||
describe('DashboardPage', () => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true, profile: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(DashboardPage.name, () => {
|
||||||
it('renders heading', () => {
|
it('renders heading', () => {
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders Medicines card linking to /medicines', () => {
|
it('shows loading skeleton when session loading', () => {
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
const link = screen.getByRole('link', { name: /medicines/i });
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
expect(link).toHaveAttribute('href', '/medicines');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders Settings card linking to /settings', () => {
|
it('renders page when household loaded', () => {
|
||||||
|
mockUseApi.mockReturnValue({
|
||||||
|
householdId: 'hh1',
|
||||||
|
isLoading: false,
|
||||||
|
profile: { displayName: 'Alice' },
|
||||||
|
});
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
const link = screen.getByRole('link', { name: /settings/i });
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
expect(link).toHaveAttribute('href', '/settings');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,488 @@
|
||||||
import Link from 'next/link';
|
'use client';
|
||||||
|
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { getCabinetSummary, listCabinetItems } from '@/services/cabinet';
|
||||||
|
import { listPurchases } from '@/services/purchases';
|
||||||
|
import { getRefillAlerts } from '@/services/refills';
|
||||||
|
import { listCabinetEvents } from '@/services/cabinet-events';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { Card, CardHeader } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Pill } from '@/components/ui/Pill';
|
||||||
|
import { Icon } from '@/components/ui/Icon';
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
function greeting() {
|
||||||
|
const h = now().getHours();
|
||||||
|
if (h < 12) return 'Good morning';
|
||||||
|
if (h < 17) return 'Good afternoon';
|
||||||
|
return 'Good evening';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: Date) {
|
||||||
|
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { householdId, profile, isLoading } = useApi();
|
||||||
|
const name = profile?.displayName?.split(' ')[0] ?? 'there';
|
||||||
|
|
||||||
|
const { data: summary } = useSWR(householdId ? `cabinet-summary-${householdId}` : null, () =>
|
||||||
|
getCabinetSummary(householdId!),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: cabinetItems } = useSWR(householdId ? `cabinet-items-${householdId}` : null, () =>
|
||||||
|
listCabinetItems(householdId!, { limit: 10 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: pendingPurchases } = useSWR(
|
||||||
|
householdId ? `purchases-ordered-${householdId}` : null,
|
||||||
|
() => listPurchases(householdId!, { status: 'ordered', limit: 5 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: refillAlerts } = useSWR(householdId ? `refill-alerts-${householdId}` : null, () =>
|
||||||
|
getRefillAlerts(householdId!, { thresholdDays: 14 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: recentEvents } = useSWR(householdId ? `cabinet-events-${householdId}` : null, () =>
|
||||||
|
listCabinetEvents(householdId!, { limit: 5 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const today = formatDate(now());
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Dashboard" subtitle="Household overview" />
|
||||||
|
<DashboardSkeleton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
|
<SetPageHeader title="Dashboard" subtitle="Household overview" />
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div style={{ padding: '28px 32px 56px', maxWidth: 1400, width: '100%' }}>
|
||||||
<DashboardCard
|
{/* Hero */}
|
||||||
title="Medicines"
|
<div
|
||||||
description="Manage your medicines, products and inventory"
|
style={{
|
||||||
href="/medicines"
|
display: 'flex',
|
||||||
/>
|
justifyContent: 'space-between',
|
||||||
<DashboardCard
|
alignItems: 'flex-start',
|
||||||
title="Settings"
|
gap: 24,
|
||||||
description="Manage household and account settings"
|
paddingBottom: 24,
|
||||||
href="/settings"
|
borderBottom: '1px solid var(--border)',
|
||||||
/>
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{today}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
color: 'var(--ink-strong)',
|
||||||
|
lineHeight: 1.05,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{greeting()}, {name}.
|
||||||
|
</div>
|
||||||
|
{summary && (
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--ink-muted)', marginTop: 8 }}>
|
||||||
|
{refillAlerts && refillAlerts.data.some((a) => a.daysUntilEmpty <= 7) ? (
|
||||||
|
<span style={{ color: 'var(--danger)' }}>
|
||||||
|
Some medicines are critically low — check refills.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Your cabinet is in good shape.'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cabinet stats */}
|
||||||
|
{summary && (
|
||||||
|
<div style={{ display: 'flex', gap: 16, flexShrink: 0 }}>
|
||||||
|
<StatBadge label="Total medicines" value={summary.data.length} />
|
||||||
|
<StatBadge label="Running low" value={refillAlerts?.data.length ?? 0} tone="warn" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(12, 1fr)',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Days of supply */}
|
||||||
|
<div style={{ gridColumn: 'span 7' }}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Cabinet — days of supply"
|
||||||
|
subtitle="At current usage"
|
||||||
|
action={
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
Open cabinet <Icon name="arrow" size={12} />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 18px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cabinetItems?.data.length ? (
|
||||||
|
cabinetItems.data.slice(0, 8).map((item) => (
|
||||||
|
<div
|
||||||
|
key={item._id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '140px 1fr',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--ink)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.medicineName ?? 'Unknown'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 6,
|
||||||
|
background: 'var(--bg-inset)',
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.min(100, (item.quantity / 100) * 100)}%`,
|
||||||
|
background: 'var(--brand)',
|
||||||
|
borderRadius: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="num"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
minWidth: 40,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.quantity} {item.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState message="No cabinet items yet." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Running low */}
|
||||||
|
<div style={{ gridColumn: 'span 5' }}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Running low"
|
||||||
|
subtitle={`${refillAlerts?.data.length ?? 0} need attention`}
|
||||||
|
action={
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
Refills
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 18px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{refillAlerts?.data.length ? (
|
||||||
|
refillAlerts.data.slice(0, 5).map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.medicineId}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px dashed var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
background: 'var(--brand-soft)',
|
||||||
|
color: 'var(--brand)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="pill" size={12} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--ink-strong)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alert.medicineName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
<div
|
||||||
|
className="num"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: alert.daysUntilEmpty <= 7 ? 'var(--danger)' : 'var(--warn)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alert.daysUntilEmpty}d
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--ink-faint)' }}>left</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState message="No alerts — all stocked." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending orders */}
|
||||||
|
<div style={{ gridColumn: 'span 6' }}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Pending orders"
|
||||||
|
subtitle={`${pendingPurchases?.data.length ?? 0} awaiting arrival`}
|
||||||
|
action={
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
All purchases
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 18px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pendingPurchases?.data.length ? (
|
||||||
|
pendingPurchases.data.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p._id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '10px 0',
|
||||||
|
borderBottom: '1px dashed var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--brand-soft)',
|
||||||
|
color: 'var(--brand-soft-ink)',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="truck" size={14} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 13 }}>
|
||||||
|
{p.storeName ?? 'Unknown store'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>
|
||||||
|
{p.items.length} item{p.items.length > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
<Pill tone={p.status === 'ordered' ? 'warn' : 'ok'}>{p.status}</Pill>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState message="No pending orders." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent activity */}
|
||||||
|
<div style={{ gridColumn: 'span 6' }}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Recent activity"
|
||||||
|
subtitle="Cabinet changes"
|
||||||
|
action={
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
See all
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: '4px 18px 16px' }}>
|
||||||
|
{recentEvents?.data.length ? (
|
||||||
|
recentEvents.data.slice(0, 5).map((event) => (
|
||||||
|
<div
|
||||||
|
key={event._id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px dashed var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pill
|
||||||
|
tone={
|
||||||
|
event.eventType === 'consumed'
|
||||||
|
? 'info'
|
||||||
|
: event.eventType === 'added'
|
||||||
|
? 'ok'
|
||||||
|
: 'warn'
|
||||||
|
}
|
||||||
|
style={{ minWidth: 76, justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
{event.eventType}
|
||||||
|
</Pill>
|
||||||
|
<span style={{ flex: 1, fontSize: 12 }}>
|
||||||
|
<strong style={{ fontWeight: 500 }}>{event.medicineName}</strong>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mono"
|
||||||
|
style={{ fontSize: 10, color: 'var(--ink-faint)', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{new Date(event.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState message="No recent activity." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatBadge({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
tone?: 'warn' | 'danger';
|
||||||
|
}) {
|
||||||
|
const color =
|
||||||
|
tone === 'danger' ? 'var(--danger)' : tone === 'warn' ? 'var(--warn)' : 'var(--brand)';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
padding: '10px 14px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="num"
|
||||||
|
style={{ fontSize: 24, fontWeight: 600, color, letterSpacing: '-0.02em', lineHeight: 1.15 }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 2 }}>{label}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardCard({
|
function EmptyState({ message }: { message: string }) {
|
||||||
title,
|
|
||||||
description,
|
|
||||||
href,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
href: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div
|
||||||
href={href}
|
style={{ padding: '12px 0', fontSize: 13, color: 'var(--ink-muted)', textAlign: 'center' }}
|
||||||
className="rounded-xl border bg-white p-6 shadow-sm hover:shadow-md transition-shadow"
|
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
{message}
|
||||||
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
</div>
|
||||||
</Link>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px', maxWidth: 1400 }}>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
background: 'var(--bg-inset)',
|
||||||
|
marginBottom: 16,
|
||||||
|
animation: 'pulse 1.5s infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,31 @@
|
||||||
import { Sidebar } from '@/components/layout/Sidebar';
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
import { TopBar } from '@/components/layout/TopBar';
|
import { TopBar } from '@/components/layout/TopBar';
|
||||||
|
import { PageHeaderProvider } from '@/components/layout/PageHeaderContext';
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<PageHeaderProvider>
|
||||||
<Sidebar />
|
<div
|
||||||
<div className="flex flex-1 flex-col">
|
style={{
|
||||||
<TopBar />
|
display: 'grid',
|
||||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
gridTemplateColumns: '248px 1fr',
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
<TopBar />
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageHeaderProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
|
||||||
|
|
@ -28,7 +29,9 @@ vi.mock('@/services/medicines', () => ({
|
||||||
listMedicineProducts: mockListMedicineProducts,
|
listMedicineProducts: mockListMedicineProducts,
|
||||||
}));
|
}));
|
||||||
vi.mock('@/services/stores', () => ({ listStores: mockListStores }));
|
vi.mock('@/services/stores', () => ({ listStores: mockListStores }));
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||||
|
}));
|
||||||
|
|
||||||
import MedicinePricesPage from '../page';
|
import MedicinePricesPage from '../page';
|
||||||
|
|
||||||
|
|
@ -74,7 +77,9 @@ describe('MedicinePricesPage', () => {
|
||||||
it('loads price history when medicine selected', async () => {
|
it('loads price history when medicine selected', async () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockGetPriceHistory.mockResolvedValue({
|
mockGetPriceHistory.mockResolvedValue({
|
||||||
|
|
@ -102,7 +107,9 @@ describe('MedicinePricesPage', () => {
|
||||||
await waitFor(() => screen.getByText('Select a medicine'));
|
await waitFor(() => screen.getByText('Select a medicine'));
|
||||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
await waitFor(() => expect(mockGetPriceHistory).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)));
|
await waitFor(() =>
|
||||||
|
expect(mockGetPriceHistory).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)),
|
||||||
|
);
|
||||||
// Wait for price records to render
|
// Wait for price records to render
|
||||||
await waitFor(() => expect(screen.getByText('Walgreens')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Walgreens')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
@ -116,13 +123,15 @@ describe('MedicinePricesPage', () => {
|
||||||
await waitFor(() => screen.getByRole('button', { name: 'Record Price', hidden: false }));
|
await waitFor(() => screen.getByRole('button', { name: 'Record Price', hidden: false }));
|
||||||
|
|
||||||
// The submit button inside the form also has text 'Record Price'
|
// The submit button inside the form also has text 'Record Price'
|
||||||
const submitBtn = screen.getAllByRole('button', { name: 'Record Price' }).find(
|
const submitBtn = screen
|
||||||
(b) => b.getAttribute('type') === 'submit',
|
.getAllByRole('button', { name: 'Record Price' })
|
||||||
);
|
.find((b) => b.getAttribute('type') === 'submit');
|
||||||
if (submitBtn) {
|
if (submitBtn) {
|
||||||
fireEvent.submit(submitBtn.closest('form')!);
|
fireEvent.submit(submitBtn.closest('form')!);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText('Please select a medicine, a product, and a store.')).toBeInTheDocument(),
|
expect(
|
||||||
|
screen.getByText('Please select a medicine, a product, and a store.'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -130,7 +139,9 @@ describe('MedicinePricesPage', () => {
|
||||||
it('shows error when price history fails', async () => {
|
it('shows error when price history fails', async () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
|
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
|
||||||
|
|
@ -146,11 +157,21 @@ describe('MedicinePricesPage', () => {
|
||||||
it('records a price successfully', async () => {
|
it('records a price successfully', async () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockListStores.mockResolvedValue({
|
mockListStores.mockResolvedValue({
|
||||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'st-1',
|
||||||
|
name: 'Walgreens',
|
||||||
|
tags: [],
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockListMedicineProducts.mockResolvedValue({
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
|
|
@ -176,17 +197,28 @@ describe('MedicinePricesPage', () => {
|
||||||
fireEvent.change(screen.getByDisplayValue('Select product'), { target: { value: 'prod-1' } });
|
fireEvent.change(screen.getByDisplayValue('Select product'), { target: { value: 'prod-1' } });
|
||||||
|
|
||||||
// Submit form
|
// Submit form
|
||||||
fireEvent.submit(screen.getByRole('button', { name: 'Record Price', hidden: true }).closest('form')!);
|
fireEvent.submit(
|
||||||
|
screen.getByRole('button', { name: 'Record Price', hidden: true }).closest('form')!,
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(mockRecordPrice).toHaveBeenCalledWith('hh1', expect.objectContaining({ medicineId: 'med-1' })));
|
await waitFor(() =>
|
||||||
|
expect(mockRecordPrice).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ medicineId: 'med-1' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
// Form should close after success
|
// Form should close after success
|
||||||
await waitFor(() => expect(screen.queryByText('Record Price', { selector: 'h2' })).not.toBeInTheDocument());
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText('Record Price', { selector: 'h2' })).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Load more button in price history', async () => {
|
it('shows Load more button in price history', async () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockGetPriceHistory.mockResolvedValue({
|
mockGetPriceHistory.mockResolvedValue({
|
||||||
|
|
@ -223,11 +255,21 @@ describe('MedicinePricesPage', () => {
|
||||||
it('shows store filter when medicine selected and changes it', async () => {
|
it('shows store filter when medicine selected and changes it', async () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockListStores.mockResolvedValue({
|
mockListStores.mockResolvedValue({
|
||||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'st-1',
|
||||||
|
name: 'Walgreens',
|
||||||
|
tags: [],
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -245,7 +287,9 @@ describe('MedicinePricesPage', () => {
|
||||||
it('dismisses price history error', async () => {
|
it('dismisses price history error', async () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
|
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
|
||||||
|
|
@ -263,13 +307,31 @@ describe('MedicinePricesPage', () => {
|
||||||
it('shows store comparison table', async () => {
|
it('shows store comparison table', async () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockCompareStores.mockResolvedValue({
|
mockCompareStores.mockResolvedValue({
|
||||||
data: [
|
data: [
|
||||||
{ storeId: 'st-1', storeName: 'Walgreens', latestPrice: 12.99, latestPricePerUnit: 0.14, currency: 'USD', date: '2026-01-01T00:00:00.000Z', isInsurancePrice: false },
|
{
|
||||||
{ storeId: 'st-2', storeName: 'CVS', latestPrice: 14.99, latestPricePerUnit: 0.17, currency: 'USD', date: '2026-01-01T00:00:00.000Z', isInsurancePrice: false },
|
storeId: 'st-1',
|
||||||
|
storeName: 'Walgreens',
|
||||||
|
latestPrice: 12.99,
|
||||||
|
latestPricePerUnit: 0.14,
|
||||||
|
currency: 'USD',
|
||||||
|
date: '2026-01-01T00:00:00.000Z',
|
||||||
|
isInsurancePrice: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
storeId: 'st-2',
|
||||||
|
storeName: 'CVS',
|
||||||
|
latestPrice: 14.99,
|
||||||
|
latestPricePerUnit: 0.17,
|
||||||
|
currency: 'USD',
|
||||||
|
date: '2026-01-01T00:00:00.000Z',
|
||||||
|
isInsurancePrice: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -308,7 +370,9 @@ describe('MedicinePricesPage', () => {
|
||||||
await userEvent.click(screen.getByText('Record Price'));
|
await userEvent.click(screen.getByText('Record Price'));
|
||||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), { target: { value: 'Met' } });
|
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), {
|
||||||
|
target: { value: 'Met' },
|
||||||
|
});
|
||||||
|
|
||||||
// Notes field (no placeholder, but maxLength 1000)
|
// Notes field (no placeholder, but maxLength 1000)
|
||||||
const notesInput = document.querySelector('input[maxLength="1000"]') as HTMLElement;
|
const notesInput = document.querySelector('input[maxLength="1000"]') as HTMLElement;
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,8 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useApi } from '@/lib/useApi';
|
import { useApi } from '@/lib/useApi';
|
||||||
import {
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
recordPrice,
|
import { recordPrice, getPriceHistory, compareStores } from '@/services/medicine-prices';
|
||||||
getPriceHistory,
|
|
||||||
compareStores,
|
|
||||||
} from '@/services/medicine-prices';
|
|
||||||
import { listMedicines, listMedicineProducts } from '@/services/medicines';
|
import { listMedicines, listMedicineProducts } from '@/services/medicines';
|
||||||
import { listStores } from '@/services/stores';
|
import { listStores } from '@/services/stores';
|
||||||
import { DosageUnit } from '@meshitrack/shared';
|
import { DosageUnit } from '@meshitrack/shared';
|
||||||
|
|
@ -120,22 +117,18 @@ function RecordPriceForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card mb-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Record Price</h2>
|
<h2 className="text-lg font-semibold mb-4">Record Price</h2>
|
||||||
{error && (
|
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Store</label>
|
<label className="mt-field-label">Store</label>
|
||||||
<select
|
<select
|
||||||
value={storeId}
|
value={storeId}
|
||||||
onChange={(e) => setStoreId(e.target.value)}
|
onChange={(e) => setStoreId(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
<option value="">Select store</option>
|
<option value="">Select store</option>
|
||||||
{stores.map((s) => (
|
{stores.map((s) => (
|
||||||
|
|
@ -147,7 +140,7 @@ function RecordPriceForm({
|
||||||
{stores.length === 0 && (
|
{stores.length === 0 && (
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
No stores yet.{' '}
|
No stores yet.{' '}
|
||||||
<Link href="/stores" className="text-primary-600 underline">
|
<Link href="/stores" className="mt-link">
|
||||||
Add a store first
|
Add a store first
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -155,19 +148,19 @@ function RecordPriceForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Medicine</label>
|
<label className="mt-field-label">Medicine</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={medicineSearch}
|
value={medicineSearch}
|
||||||
onChange={(e) => setMedicineSearch(e.target.value)}
|
onChange={(e) => setMedicineSearch(e.target.value)}
|
||||||
placeholder="Search medicines..."
|
placeholder="Search medicines..."
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none mb-2"
|
className="mt-field mb-2"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={medicineId}
|
value={medicineId}
|
||||||
onChange={(e) => handleMedicineChange(e.target.value)}
|
onChange={(e) => handleMedicineChange(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
<option value="">Select medicine</option>
|
<option value="">Select medicine</option>
|
||||||
{filteredMedicines.map((m) => (
|
{filteredMedicines.map((m) => (
|
||||||
|
|
@ -179,7 +172,7 @@ function RecordPriceForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Product</label>
|
<label className="mt-field-label">Product</label>
|
||||||
{productsLoading ? (
|
{productsLoading ? (
|
||||||
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
|
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -188,9 +181,11 @@ function RecordPriceForm({
|
||||||
onChange={(e) => handleProductChange(e.target.value)}
|
onChange={(e) => handleProductChange(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={!medicineId}
|
disabled={!medicineId}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
<option value="">{medicineId ? 'Select product' : 'Select a medicine first'}</option>
|
<option value="">
|
||||||
|
{medicineId ? 'Select product' : 'Select a medicine first'}
|
||||||
|
</option>
|
||||||
{products.map((p) => (
|
{products.map((p) => (
|
||||||
<option key={p._id} value={p._id}>
|
<option key={p._id} value={p._id}>
|
||||||
{p.brand ?? 'Generic'} — {p.packageSize} {p.packageUnit}
|
{p.brand ?? 'Generic'} — {p.packageSize} {p.packageUnit}
|
||||||
|
|
@ -201,7 +196,7 @@ function RecordPriceForm({
|
||||||
{medicineId && !productsLoading && products.length === 0 && (
|
{medicineId && !productsLoading && products.length === 0 && (
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
No products for this medicine.{' '}
|
No products for this medicine.{' '}
|
||||||
<Link href={`/medicines/${medicineId}`} className="text-primary-600 underline">
|
<Link href={`/medicines/${medicineId}`} className="mt-link">
|
||||||
Add a product first
|
Add a product first
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -210,7 +205,7 @@ function RecordPriceForm({
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Price</label>
|
<label className="mt-field-label">Price</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
|
|
@ -219,11 +214,11 @@ function RecordPriceForm({
|
||||||
value={price}
|
value={price}
|
||||||
onChange={(e) => setPrice(e.target.value)}
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
placeholder="9.99"
|
placeholder="9.99"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Currency</label>
|
<label className="mt-field-label">Currency</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
|
@ -231,14 +226,14 @@ function RecordPriceForm({
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={(e) => setCurrency(e.target.value)}
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
placeholder="USD"
|
placeholder="USD"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Package size</label>
|
<label className="mt-field-label">Package size</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
|
|
@ -247,15 +242,15 @@ function RecordPriceForm({
|
||||||
value={quantity}
|
value={quantity}
|
||||||
onChange={(e) => setQuantity(e.target.value)}
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
placeholder="90"
|
placeholder="90"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
|
<label className="mt-field-label">Unit</label>
|
||||||
<select
|
<select
|
||||||
value={unit}
|
value={unit}
|
||||||
onChange={(e) => setUnit(e.target.value as DosageUnit)}
|
onChange={(e) => setUnit(e.target.value as DosageUnit)}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
{Object.values(DosageUnit).map((u) => (
|
{Object.values(DosageUnit).map((u) => (
|
||||||
<option key={u} value={u}>
|
<option key={u} value={u}>
|
||||||
|
|
@ -267,15 +262,13 @@ function RecordPriceForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="mt-field-label">Notes (optional)</label>
|
||||||
Notes (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -285,7 +278,7 @@ function RecordPriceForm({
|
||||||
id="isInsurancePrice"
|
id="isInsurancePrice"
|
||||||
checked={isInsurancePrice}
|
checked={isInsurancePrice}
|
||||||
onChange={(e) => setIsInsurancePrice(e.target.checked)}
|
onChange={(e) => setIsInsurancePrice(e.target.checked)}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="isInsurancePrice" className="text-sm font-medium text-gray-700">
|
<label htmlFor="isInsurancePrice" className="text-sm font-medium text-gray-700">
|
||||||
Insurance price
|
Insurance price
|
||||||
|
|
@ -294,18 +287,10 @@ function RecordPriceForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button
|
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{submitting ? 'Recording...' : 'Record Price'}
|
{submitting ? 'Recording...' : 'Record Price'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -371,14 +356,15 @@ function PriceHistory({
|
||||||
}, [householdId, selectedMedicineId, selectedStoreId]);
|
}, [householdId, selectedMedicineId, selectedStoreId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card">
|
||||||
<h2 className="text-lg font-semibold mb-4">Price History</h2>
|
<h2 className="text-lg font-semibold mb-4">Price History</h2>
|
||||||
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
<select
|
<select
|
||||||
value={selectedMedicineId}
|
value={selectedMedicineId}
|
||||||
onChange={(e) => setSelectedMedicineId(e.target.value)}
|
onChange={(e) => setSelectedMedicineId(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
<option value="">Select a medicine</option>
|
<option value="">Select a medicine</option>
|
||||||
{medicines.map((m) => (
|
{medicines.map((m) => (
|
||||||
|
|
@ -391,7 +377,8 @@ function PriceHistory({
|
||||||
<select
|
<select
|
||||||
value={selectedStoreId}
|
value={selectedStoreId}
|
||||||
onChange={(e) => setSelectedStoreId(e.target.value)}
|
onChange={(e) => setSelectedStoreId(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
<option value="">All stores</option>
|
<option value="">All stores</option>
|
||||||
{stores.map((s) => (
|
{stores.map((s) => (
|
||||||
|
|
@ -404,7 +391,7 @@ function PriceHistory({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
<div className="mt-alert mt-alert--danger mb-4">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 underline">
|
<button onClick={() => setError('')} className="ml-2 underline">
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|
@ -439,18 +426,15 @@ function PriceHistory({
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{comparison.map((item, i) => (
|
{comparison.map((item, i) => (
|
||||||
<tr key={item.storeId} className={i === 0 ? 'text-green-700 font-medium' : ''}>
|
<tr
|
||||||
|
key={item.storeId}
|
||||||
|
className={i === 0 ? 'text-green-700 font-medium' : ''}
|
||||||
|
>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
{item.storeName}
|
{item.storeName}
|
||||||
{i === 0 && (
|
{i === 0 && <span className="ml-2 mt-pill mt-pill--ok">cheapest</span>}
|
||||||
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs">
|
|
||||||
cheapest
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.isInsurancePrice && (
|
{item.isInsurancePrice && (
|
||||||
<span className="ml-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs">
|
<span className="ml-1 mt-pill mt-pill--info">insurance</span>
|
||||||
insurance
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
|
|
@ -490,9 +474,7 @@ function PriceHistory({
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
{r.storeName}
|
{r.storeName}
|
||||||
{r.isInsurancePrice && (
|
{r.isInsurancePrice && (
|
||||||
<span className="ml-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs">
|
<span className="ml-1 mt-pill mt-pill--info">ins</span>
|
||||||
ins
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{r.notes && (
|
{r.notes && (
|
||||||
<span className="ml-1 text-xs text-gray-400"> — {r.notes}</span>
|
<span className="ml-1 text-xs text-gray-400"> — {r.notes}</span>
|
||||||
|
|
@ -540,18 +522,18 @@ function MedicinePricesContent({ householdId }: { householdId: string }) {
|
||||||
const [historyKey, setHistoryKey] = useState(0);
|
const [historyKey, setHistoryKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listMedicines(householdId, { limit: 100 }).then((r) => setMedicines(r.data)).catch(() => {});
|
listMedicines(householdId, { limit: 100 })
|
||||||
listStores(householdId, { limit: 100 }).then((r) => setStores(r.data)).catch(() => {});
|
.then((r) => setMedicines(r.data))
|
||||||
|
.catch(() => {});
|
||||||
|
listStores(householdId, { limit: 100 })
|
||||||
|
.then((r) => setStores(r.data))
|
||||||
|
.catch(() => {});
|
||||||
}, [householdId]);
|
}, [householdId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold">Medicine Prices</h1>
|
<button onClick={() => setShowForm(!showForm)} className="mt-btn mt-btn--primary">
|
||||||
<button
|
|
||||||
onClick={() => setShowForm(!showForm)}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
|
||||||
>
|
|
||||||
{showForm ? 'Cancel' : 'Record Price'}
|
{showForm ? 'Cancel' : 'Record Price'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -586,33 +568,54 @@ export default function MedicinePricesPage() {
|
||||||
|
|
||||||
if (sessionLoading) {
|
if (sessionLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Medicine Prices</h1>
|
<SetPageHeader title="Medicine Prices" subtitle="Track & compare across stores" />
|
||||||
<div className="animate-pulse space-y-4">
|
<div style={{ padding: '28px 32px' }}>
|
||||||
<div className="h-10 w-48 rounded-lg bg-gray-200" />
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div className="h-40 rounded-xl bg-gray-200" />
|
{[1, 2, 3].map((i) => (
|
||||||
<div className="h-40 rounded-xl bg-gray-200" />
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ height: 64, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!householdId) {
|
if (!householdId) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Medicine Prices</h1>
|
<SetPageHeader title="Medicine Prices" subtitle="Track & compare across stores" />
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div style={{ padding: '28px 32px' }}>
|
||||||
<p className="text-gray-500">
|
<div
|
||||||
You need to{' '}
|
style={{
|
||||||
<Link href="/settings" className="text-primary-600 underline">
|
background: 'var(--bg-elev)',
|
||||||
create or join a household
|
border: '1px solid var(--border)',
|
||||||
</Link>{' '}
|
borderRadius: 'var(--r-md)',
|
||||||
before tracking prices.
|
padding: 24,
|
||||||
</p>
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
You need to{' '}
|
||||||
|
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
|
||||||
|
create or join a household
|
||||||
|
</Link>{' '}
|
||||||
|
before tracking prices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MedicinePricesContent householdId={householdId} />;
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Medicine Prices" subtitle="Track & compare across stores" />
|
||||||
|
<div className="mt-page">
|
||||||
|
<MedicinePricesContent householdId={householdId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,7 @@ import { listCabinetEvents, getSpendingSummary } from '@/services/cabinet-events
|
||||||
import { listMedicines } from '@/services/medicines';
|
import { listMedicines } from '@/services/medicines';
|
||||||
import { CabinetEventType } from '@meshitrack/shared';
|
import { CabinetEventType } from '@meshitrack/shared';
|
||||||
import type { z } from 'zod/v4';
|
import type { z } from 'zod/v4';
|
||||||
import type {
|
import type { CabinetEventResponseSchema, SpendingSummaryResponseSchema } from '@meshitrack/shared';
|
||||||
CabinetEventResponseSchema,
|
|
||||||
SpendingSummaryResponseSchema,
|
|
||||||
} from '@meshitrack/shared';
|
|
||||||
|
|
||||||
type CabinetEvent = z.infer<typeof CabinetEventResponseSchema>;
|
type CabinetEvent = z.infer<typeof CabinetEventResponseSchema>;
|
||||||
type SpendingSummary = z.infer<typeof SpendingSummaryResponseSchema>;
|
type SpendingSummary = z.infer<typeof SpendingSummaryResponseSchema>;
|
||||||
|
|
@ -27,13 +24,13 @@ const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||||
deleted: 'Deleted',
|
deleted: 'Deleted',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
const EVENT_TYPE_PILL: Record<string, string> = {
|
||||||
purchased: 'bg-green-100 text-green-700',
|
purchased: 'mt-pill--ok',
|
||||||
consumed: 'bg-blue-100 text-blue-700',
|
consumed: 'mt-pill--info',
|
||||||
adjusted: 'bg-yellow-100 text-yellow-700',
|
adjusted: 'mt-pill--warn',
|
||||||
discarded: 'bg-red-100 text-red-700',
|
discarded: 'mt-pill--danger',
|
||||||
restored: 'bg-purple-100 text-purple-700',
|
restored: 'mt-pill--brand',
|
||||||
deleted: 'bg-gray-100 text-gray-600',
|
deleted: 'mt-pill--ghost',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDateTime(dateStr: string): string {
|
function formatDateTime(dateStr: string): string {
|
||||||
|
|
@ -41,7 +38,7 @@ function formatDateTime(dateStr: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* v8 ignore next 4 */
|
/* v8 ignore next 4 */
|
||||||
function formatQuantityChange(event: CabinetEvent): string {
|
function _formatQuantityChange(event: CabinetEvent): string {
|
||||||
const sign = event.quantity > 0 ? '+' : '';
|
const sign = event.quantity > 0 ? '+' : '';
|
||||||
return `${sign}${event.quantity}`;
|
return `${sign}${event.quantity}`;
|
||||||
}
|
}
|
||||||
|
|
@ -49,9 +46,7 @@ function formatQuantityChange(event: CabinetEvent): string {
|
||||||
function QuantityBadge({ quantity }: { quantity: number }) {
|
function QuantityBadge({ quantity }: { quantity: number }) {
|
||||||
const isPositive = quantity > 0;
|
const isPositive = quantity > 0;
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`text-sm font-semibold ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
className={`text-sm font-semibold ${isPositive ? 'text-green-600' : 'text-red-600'}`}
|
|
||||||
>
|
|
||||||
{isPositive ? '+' : ''}
|
{isPositive ? '+' : ''}
|
||||||
{quantity}
|
{quantity}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -96,14 +91,15 @@ function SpendingSummaryView({
|
||||||
const PERIOD_LABELS = { month: 'This month', quarter: 'This quarter', year: 'This year' };
|
const PERIOD_LABELS = { month: 'This month', quarter: 'This quarter', year: 'This year' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card">
|
||||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||||
<h2 className="text-lg font-semibold">Spending Summary</h2>
|
<h2 className="text-lg font-semibold">Spending Summary</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={period}
|
value={period}
|
||||||
onChange={(e) => setPeriod(e.target.value as 'month' | 'quarter' | 'year')}
|
onChange={(e) => setPeriod(e.target.value as 'month' | 'quarter' | 'year')}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
{Object.entries(PERIOD_LABELS).map(([v, label]) => (
|
{Object.entries(PERIOD_LABELS).map(([v, label]) => (
|
||||||
<option key={v} value={v}>
|
<option key={v} value={v}>
|
||||||
|
|
@ -114,7 +110,8 @@ function SpendingSummaryView({
|
||||||
<select
|
<select
|
||||||
value={medicineId}
|
value={medicineId}
|
||||||
onChange={(e) => setMedicineId(e.target.value)}
|
onChange={(e) => setMedicineId(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
<option value="">All medicines</option>
|
<option value="">All medicines</option>
|
||||||
{medicines.map((m) => (
|
{medicines.map((m) => (
|
||||||
|
|
@ -126,11 +123,7 @@ function SpendingSummaryView({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="animate-pulse space-y-2">
|
<div className="animate-pulse space-y-2">
|
||||||
|
|
@ -155,9 +148,7 @@ function SpendingSummaryView({
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-900">
|
<span className="text-sm font-medium text-gray-900">{item.medicineName}</span>
|
||||||
{item.medicineName}
|
|
||||||
</span>
|
|
||||||
<span className="ml-2 text-xs text-gray-500">
|
<span className="ml-2 text-xs text-gray-500">
|
||||||
{item.purchaseCount} purchase{item.purchaseCount !== 1 ? 's' : ''} •{' '}
|
{item.purchaseCount} purchase{item.purchaseCount !== 1 ? 's' : ''} •{' '}
|
||||||
avg {summary.currency ? `${summary.currency} ` : ''}
|
avg {summary.currency ? `${summary.currency} ` : ''}
|
||||||
|
|
@ -256,14 +247,15 @@ function EventTimeline({
|
||||||
}, [householdId, filterEventType, filterMedicineId, startDate, endDate]);
|
}, [householdId, filterEventType, filterMedicineId, startDate, endDate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card">
|
||||||
<h2 className="text-lg font-semibold mb-4">Cabinet Activity</h2>
|
<h2 className="text-lg font-semibold mb-4">Cabinet Activity</h2>
|
||||||
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
<select
|
<select
|
||||||
value={filterEventType}
|
value={filterEventType}
|
||||||
onChange={(e) => setFilterEventType(e.target.value)}
|
onChange={(e) => setFilterEventType(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
<option value="">All event types</option>
|
<option value="">All event types</option>
|
||||||
{Object.values(CabinetEventType).map((t) => (
|
{Object.values(CabinetEventType).map((t) => (
|
||||||
|
|
@ -275,7 +267,8 @@ function EventTimeline({
|
||||||
<select
|
<select
|
||||||
value={filterMedicineId}
|
value={filterMedicineId}
|
||||||
onChange={(e) => setFilterMedicineId(e.target.value)}
|
onChange={(e) => setFilterMedicineId(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
<option value="">All medicines</option>
|
<option value="">All medicines</option>
|
||||||
{medicines.map((m) => (
|
{medicines.map((m) => (
|
||||||
|
|
@ -288,14 +281,16 @@ function EventTimeline({
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
title="Start date"
|
title="Start date"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
title="End date"
|
title="End date"
|
||||||
/>
|
/>
|
||||||
{(filterEventType || filterMedicineId || startDate || endDate) && (
|
{(filterEventType || filterMedicineId || startDate || endDate) && (
|
||||||
|
|
@ -306,7 +301,7 @@ function EventTimeline({
|
||||||
setStartDate('');
|
setStartDate('');
|
||||||
setEndDate('');
|
setEndDate('');
|
||||||
}}
|
}}
|
||||||
className="text-sm text-gray-500 underline"
|
className="mt-link text-sm"
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -314,7 +309,7 @@ function EventTimeline({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
<div className="mt-alert mt-alert--danger mb-4">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 underline">
|
<button onClick={() => setError('')} className="ml-2 underline">
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|
@ -349,7 +344,7 @@ function EventTimeline({
|
||||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${/* v8 ignore next */ EVENT_TYPE_COLORS[event.eventType] ?? 'bg-gray-100 text-gray-600'}`}
|
className={`mt-pill ${/* v8 ignore next */ EVENT_TYPE_PILL[event.eventType] ?? 'mt-pill--ghost'}`}
|
||||||
>
|
>
|
||||||
{/* v8 ignore next */ EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
|
{/* v8 ignore next */ EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -385,10 +380,7 @@ function EventTimeline({
|
||||||
|
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<button
|
<button onClick={() => fetchEvents(true)} className="mt-btn mt-btn--ghost">
|
||||||
onClick={() => fetchEvents(true)}
|
|
||||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Load more
|
Load more
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -407,7 +399,9 @@ export function ActivityTab({ householdId }: { householdId: string }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listMedicines(householdId, { limit: 100 })
|
listMedicines(householdId, { limit: 100 })
|
||||||
.then((r) =>
|
.then((r) =>
|
||||||
setMedicines(r.data.map((m: { _id: string; name: string }) => ({ _id: m._id, name: m.name }))),
|
setMedicines(
|
||||||
|
r.data.map((m: { _id: string; name: string }) => ({ _id: m._id, name: m.name })),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [householdId]);
|
}, [householdId]);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -29,11 +29,11 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||||
other: 'Other',
|
other: 'Other',
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_PILL: Record<string, string> = {
|
||||||
prescription: 'bg-blue-100 text-blue-700',
|
prescription: 'mt-pill--info',
|
||||||
otc: 'bg-green-100 text-green-700',
|
otc: 'mt-pill--ok',
|
||||||
supplement: 'bg-purple-100 text-purple-700',
|
supplement: 'mt-pill--brand',
|
||||||
other: 'bg-gray-100 text-gray-700',
|
other: 'mt-pill--ghost',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LibraryTab({ householdId }: { householdId: string }) {
|
export function LibraryTab({ householdId }: { householdId: string }) {
|
||||||
|
|
@ -83,16 +83,13 @@ export function LibraryTab({ householdId }: { householdId: string }) {
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div />
|
<div />
|
||||||
<button
|
<button onClick={() => setShowForm(!showForm)} className="mt-btn mt-btn--primary">
|
||||||
onClick={() => setShowForm(!showForm)}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
|
||||||
>
|
|
||||||
{showForm ? 'Cancel' : 'Add Medicine'}
|
{showForm ? 'Cancel' : 'Add Medicine'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
<div className="mt-alert mt-alert--danger mb-4">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 underline">
|
<button onClick={() => setError('')} className="ml-2 underline">
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|
@ -117,12 +114,13 @@ export function LibraryTab({ householdId }: { householdId: string }) {
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Search medicines..."
|
placeholder="Search medicines..."
|
||||||
className="w-full max-w-md rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field max-w-md"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={filterCategory}
|
value={filterCategory}
|
||||||
onChange={(e) => setFilterCategory(e.target.value)}
|
onChange={(e) => setFilterCategory(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{Object.values(MedicineCategory).map((c) => (
|
{Object.values(MedicineCategory).map((c) => (
|
||||||
|
|
@ -134,7 +132,8 @@ export function LibraryTab({ householdId }: { householdId: string }) {
|
||||||
<select
|
<select
|
||||||
value={filterForm}
|
value={filterForm}
|
||||||
onChange={(e) => setFilterForm(e.target.value)}
|
onChange={(e) => setFilterForm(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
<option value="">All Forms</option>
|
<option value="">All Forms</option>
|
||||||
{Object.values(MedicineForm).map((f) => (
|
{Object.values(MedicineForm).map((f) => (
|
||||||
|
|
@ -176,13 +175,13 @@ export function LibraryTab({ householdId }: { householdId: string }) {
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-3 ml-4">
|
<div className="flex items-center gap-3 ml-4">
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${CATEGORY_COLORS[med.category] ?? CATEGORY_COLORS['other']}`}
|
className={`mt-pill ${CATEGORY_PILL[med.category] ?? CATEGORY_PILL['other']}`}
|
||||||
>
|
>
|
||||||
{CATEGORY_LABELS[med.category] ?? med.category}
|
{CATEGORY_LABELS[med.category] ?? med.category}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(med._id, med.name)}
|
onClick={() => handleDelete(med._id, med.name)}
|
||||||
className="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
className="mt-btn mt-btn--danger-icon"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -242,17 +241,13 @@ function CreateMedicineForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card mb-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Add Medicine</h2>
|
<h2 className="text-lg font-semibold mb-4">Add Medicine</h2>
|
||||||
{error && (
|
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
<label className="mt-field-label">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
|
@ -260,15 +255,15 @@ function CreateMedicineForm({
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
placeholder="e.g. Metformin"
|
placeholder="e.g. Metformin"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Form</label>
|
<label className="mt-field-label">Form</label>
|
||||||
<select
|
<select
|
||||||
value={formData.form}
|
value={formData.form}
|
||||||
onChange={(e) => setFormData({ ...formData, form: e.target.value as MedicineForm })}
|
onChange={(e) => setFormData({ ...formData, form: e.target.value as MedicineForm })}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
{Object.values(MedicineForm).map((f) => (
|
{Object.values(MedicineForm).map((f) => (
|
||||||
<option key={f} value={f}>
|
<option key={f} value={f}>
|
||||||
|
|
@ -279,7 +274,7 @@ function CreateMedicineForm({
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Strength</label>
|
<label className="mt-field-label">Strength</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
|
|
@ -288,17 +283,17 @@ function CreateMedicineForm({
|
||||||
value={formData.strength || ''}
|
value={formData.strength || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, strength: Number(e.target.value) })}
|
onChange={(e) => setFormData({ ...formData, strength: Number(e.target.value) })}
|
||||||
placeholder="500"
|
placeholder="500"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
|
<label className="mt-field-label">Unit</label>
|
||||||
<select
|
<select
|
||||||
value={formData.strengthUnit}
|
value={formData.strengthUnit}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, strengthUnit: e.target.value as StrengthUnit })
|
setFormData({ ...formData, strengthUnit: e.target.value as StrengthUnit })
|
||||||
}
|
}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
{Object.values(StrengthUnit).map((u) => (
|
{Object.values(StrengthUnit).map((u) => (
|
||||||
<option key={u} value={u}>
|
<option key={u} value={u}>
|
||||||
|
|
@ -309,13 +304,13 @@ function CreateMedicineForm({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
<label className="mt-field-label">Category</label>
|
||||||
<select
|
<select
|
||||||
value={formData.category}
|
value={formData.category}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, category: e.target.value as MedicineCategory })
|
setFormData({ ...formData, category: e.target.value as MedicineCategory })
|
||||||
}
|
}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
{Object.values(MedicineCategory).map((c) => (
|
{Object.values(MedicineCategory).map((c) => (
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>
|
||||||
|
|
@ -325,30 +320,22 @@ function CreateMedicineForm({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notes (optional)</label>
|
<label className="mt-field-label">Notes (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
value={formData.notes ?? ''}
|
value={formData.notes ?? ''}
|
||||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value || undefined })}
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value || undefined })}
|
||||||
placeholder="Any additional notes"
|
placeholder="Any additional notes"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button
|
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{submitting ? 'Creating...' : 'Create Medicine'}
|
{submitting ? 'Creating...' : 'Create Medicine'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import { listFills, previewFill, executeFill, undoFill } from '@/services/organizer';
|
||||||
listFills,
|
|
||||||
previewFill,
|
|
||||||
executeFill,
|
|
||||||
undoFill,
|
|
||||||
} from '@/services/organizer';
|
|
||||||
import { listRegimens } from '@/services/regimens';
|
import { listRegimens } from '@/services/regimens';
|
||||||
import { OrganizerFillStatus } from '@meshitrack/shared';
|
import { OrganizerFillStatus } from '@meshitrack/shared';
|
||||||
import type { z } from 'zod/v4';
|
import type { z } from 'zod/v4';
|
||||||
|
|
@ -26,12 +21,6 @@ const STATUS_LABELS: Record<string, string> = {
|
||||||
reversed: 'Reversed',
|
reversed: 'Reversed',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
|
||||||
completed: 'bg-green-100 text-green-700',
|
|
||||||
partial: 'bg-yellow-100 text-yellow-700',
|
|
||||||
reversed: 'bg-gray-100 text-gray-500',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString();
|
return new Date(dateStr).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
@ -54,20 +43,16 @@ function PreviewResult({
|
||||||
onTogglePartial: (v: boolean) => void;
|
onTogglePartial: (v: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm space-y-5">
|
<div className="mt-card">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
Preview: {preview.regimenName} — {preview.numberOfDays} day
|
Preview: {preview.regimenName} — {preview.numberOfDays} day
|
||||||
{preview.numberOfDays !== 1 ? 's' : ''}
|
{preview.numberOfDays !== 1 ? 's' : ''}
|
||||||
</h3>
|
</h3>
|
||||||
{preview.hasShortages ? (
|
{preview.hasShortages ? (
|
||||||
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-medium text-yellow-700">
|
<span className="mt-pill mt-pill--warn">Shortages detected</span>
|
||||||
Shortages detected
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-700">
|
<span className="mt-pill mt-pill--ok">Ready to fill</span>
|
||||||
Ready to fill
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -87,9 +72,7 @@ function PreviewResult({
|
||||||
Available: <strong>{item.quantityAvailable}</strong>
|
Available: <strong>{item.quantityAvailable}</strong>
|
||||||
</span>
|
</span>
|
||||||
{item.isShort && (
|
{item.isShort && (
|
||||||
<span className="text-yellow-700 font-semibold">
|
<span className="text-yellow-700 font-semibold">Short: {item.shortage}</span>
|
||||||
Short: {item.shortage}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -98,7 +81,9 @@ function PreviewResult({
|
||||||
{item.cabinetBreakdown.map((b, i) => (
|
{item.cabinetBreakdown.map((b, i) => (
|
||||||
<span key={i} className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
|
<span key={i} className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
|
||||||
{b.quantityToTake} units
|
{b.quantityToTake} units
|
||||||
{b.expirationDate ? ` (exp ${new Date(b.expirationDate).toLocaleDateString()})` : ''}
|
{b.expirationDate
|
||||||
|
? ` (exp ${new Date(b.expirationDate).toLocaleDateString()})`
|
||||||
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -114,7 +99,7 @@ function PreviewResult({
|
||||||
id="allowPartial"
|
id="allowPartial"
|
||||||
checked={allowPartial}
|
checked={allowPartial}
|
||||||
onChange={(e) => onTogglePartial(e.target.checked)}
|
onChange={(e) => onTogglePartial(e.target.checked)}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="allowPartial" className="text-sm text-gray-700">
|
<label htmlFor="allowPartial" className="text-sm text-gray-700">
|
||||||
Allow partial fill (fill what is available)
|
Allow partial fill (fill what is available)
|
||||||
|
|
@ -126,14 +111,11 @@ function PreviewResult({
|
||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={submitting || (preview.hasShortages && !allowPartial)}
|
disabled={submitting || (preview.hasShortages && !allowPartial)}
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
className="mt-btn mt-btn--primary"
|
||||||
>
|
>
|
||||||
{submitting ? 'Filling...' : 'Confirm fill'}
|
{submitting ? 'Filling...' : 'Confirm fill'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={onCancel} className="mt-btn mt-btn--ghost">
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,7 +164,12 @@ function FillWizard({
|
||||||
setError('');
|
setError('');
|
||||||
setFilling(true);
|
setFilling(true);
|
||||||
try {
|
try {
|
||||||
await executeFill(householdId, { regimenId, numberOfDays, allowPartial, notes: notes || undefined });
|
await executeFill(householdId, {
|
||||||
|
regimenId,
|
||||||
|
numberOfDays,
|
||||||
|
allowPartial,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setRegimenId('');
|
setRegimenId('');
|
||||||
setNumberOfDays(7);
|
setNumberOfDays(7);
|
||||||
|
|
@ -218,13 +205,9 @@ function FillWizard({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card">
|
||||||
<h2 className="text-lg font-semibold mb-4">Fill Pill Organizer</h2>
|
<h2 className="text-lg font-semibold mb-4">Fill Pill Organizer</h2>
|
||||||
{error && (
|
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeRegimens.length === 0 ? (
|
{activeRegimens.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
No active regimens found. Create and activate a regimen before filling.
|
No active regimens found. Create and activate a regimen before filling.
|
||||||
|
|
@ -233,23 +216,24 @@ function FillWizard({
|
||||||
<form onSubmit={handlePreview} className="space-y-4">
|
<form onSubmit={handlePreview} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Regimen</label>
|
<label className="mt-field-label">Regimen</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={regimenId}
|
value={regimenId}
|
||||||
onChange={(e) => setRegimenId(e.target.value)}
|
onChange={(e) => setRegimenId(e.target.value)}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
<option value="">Select regimen...</option>
|
<option value="">Select regimen...</option>
|
||||||
{activeRegimens.map((r) => (
|
{activeRegimens.map((r) => (
|
||||||
<option key={r._id} value={r._id}>
|
<option key={r._id} value={r._id}>
|
||||||
{r.name} ({r.medications.length} medication{r.medications.length !== 1 ? 's' : ''})
|
{r.name} ({r.medications.length} medication
|
||||||
|
{r.medications.length !== 1 ? 's' : ''})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Number of days</label>
|
<label className="mt-field-label">Number of days</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
|
|
@ -257,28 +241,22 @@ function FillWizard({
|
||||||
max={90}
|
max={90}
|
||||||
value={numberOfDays}
|
value={numberOfDays}
|
||||||
onChange={(e) => setNumberOfDays(Number(e.target.value))}
|
onChange={(e) => setNumberOfDays(Number(e.target.value))}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="mt-field-label">Notes (optional)</label>
|
||||||
Notes (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
placeholder="Any notes for this fill"
|
placeholder="Any notes for this fill"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="submit" disabled={previewing} className="mt-btn mt-btn--primary">
|
||||||
type="submit"
|
|
||||||
disabled={previewing}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{previewing ? 'Calculating...' : 'Preview fill'}
|
{previewing ? 'Calculating...' : 'Preview fill'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -289,13 +267,7 @@ function FillWizard({
|
||||||
|
|
||||||
// --- Fill history list ---
|
// --- Fill history list ---
|
||||||
|
|
||||||
function FillHistory({
|
function FillHistory({ householdId, refreshKey }: { householdId: string; refreshKey: number }) {
|
||||||
householdId,
|
|
||||||
refreshKey,
|
|
||||||
}: {
|
|
||||||
householdId: string;
|
|
||||||
refreshKey: number;
|
|
||||||
}) {
|
|
||||||
const [fills, setFills] = useState<OrganizerFill[]>([]);
|
const [fills, setFills] = useState<OrganizerFill[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
@ -331,13 +303,14 @@ function FillHistory({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">Fill History</h2>
|
<h2 className="text-lg font-semibold">Fill History</h2>
|
||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
{Object.values(OrganizerFillStatus).map((s) => (
|
{Object.values(OrganizerFillStatus).map((s) => (
|
||||||
|
|
@ -349,7 +322,7 @@ function FillHistory({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
<div className="mt-alert mt-alert--danger mb-4">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 underline">
|
<button onClick={() => setError('')} className="ml-2 underline">
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|
@ -374,7 +347,7 @@ function FillHistory({
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="font-medium text-gray-900">{fill.regimenName}</span>
|
<span className="font-medium text-gray-900">{fill.regimenName}</span>
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[fill.status] ?? STATUS_COLORS['completed']}`}
|
className={`mt-pill ${fill.status === 'completed' ? 'mt-pill--ok' : fill.status === 'partial' ? 'mt-pill--warn' : 'mt-pill--ghost'}`}
|
||||||
>
|
>
|
||||||
{STATUS_LABELS[fill.status] ?? fill.status}
|
{STATUS_LABELS[fill.status] ?? fill.status}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -384,18 +357,12 @@ function FillHistory({
|
||||||
{fill.items.length} medicine{fill.items.length !== 1 ? 's' : ''} •{' '}
|
{fill.items.length} medicine{fill.items.length !== 1 ? 's' : ''} •{' '}
|
||||||
{formatDate(fill.fillDate)}
|
{formatDate(fill.fillDate)}
|
||||||
</p>
|
</p>
|
||||||
{fill.notes && (
|
{fill.notes && <p className="text-xs text-gray-400 mt-1">{fill.notes}</p>}
|
||||||
<p className="text-xs text-gray-400 mt-1">{fill.notes}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{fill.items.map((item, i) => (
|
{fill.items.map((item, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
className={`mt-pill ${item.wasShort ? 'mt-pill--warn' : 'mt-pill--info'}`}
|
||||||
item.wasShort
|
|
||||||
? 'bg-yellow-50 text-yellow-700'
|
|
||||||
: 'bg-blue-50 text-blue-700'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{item.medicineName}: {item.quantityTaken}/{item.quantityNeeded}
|
{item.medicineName}: {item.quantityTaken}/{item.quantityNeeded}
|
||||||
{item.wasShort ? ' (short)' : ''}
|
{item.wasShort ? ' (short)' : ''}
|
||||||
|
|
@ -406,7 +373,7 @@ function FillHistory({
|
||||||
{fill.status !== OrganizerFillStatus.REVERSED && (
|
{fill.status !== OrganizerFillStatus.REVERSED && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUndo(fill._id)}
|
onClick={() => handleUndo(fill._id)}
|
||||||
className="shrink-0 rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition-colors"
|
className="mt-btn mt-btn--danger-ghost"
|
||||||
>
|
>
|
||||||
Undo
|
Undo
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -443,11 +410,7 @@ export function OrganizerTab({ householdId }: { householdId: string }) {
|
||||||
{regimensLoading ? (
|
{regimensLoading ? (
|
||||||
<div className="animate-pulse rounded-xl border bg-white p-6 h-40" />
|
<div className="animate-pulse rounded-xl border bg-white p-6 h-40" />
|
||||||
) : (
|
) : (
|
||||||
<FillWizard
|
<FillWizard householdId={householdId} regimens={regimens} onFilled={handleFilled} />
|
||||||
householdId={householdId}
|
|
||||||
regimens={regimens}
|
|
||||||
onFilled={handleFilled}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<FillHistory householdId={householdId} refreshKey={fillRefreshKey} />
|
<FillHistory householdId={householdId} refreshKey={fillRefreshKey} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
DosageFrequency,
|
DosageFrequency,
|
||||||
TimeOfDay,
|
TimeOfDay,
|
||||||
DosageUnit,
|
DosageUnit,
|
||||||
MedicineForm,
|
type MedicineForm,
|
||||||
allowedUnitsForForm,
|
allowedUnitsForForm,
|
||||||
defaultUnitForForm,
|
defaultUnitForForm,
|
||||||
} from '@meshitrack/shared';
|
} from '@meshitrack/shared';
|
||||||
|
|
@ -99,23 +99,28 @@ function MedicationRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemove(index)}
|
onClick={() => onRemove(index)}
|
||||||
className="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
className="mt-btn mt-btn--danger-icon"
|
||||||
title="Remove medication"
|
title="Remove medication"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Medicine</label>
|
<label className="mt-field-label">Medicine</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={medication.medicineId}
|
value={medication.medicineId}
|
||||||
onChange={(e) => handleMedicineChange(e.target.value)}
|
onChange={(e) => handleMedicineChange(e.target.value)}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
<option value="">Select medicine...</option>
|
<option value="">Select medicine...</option>
|
||||||
{medicines.map((m) => (
|
{medicines.map((m) => (
|
||||||
|
|
@ -128,7 +133,7 @@ function MedicationRow({
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Dosage</label>
|
<label className="mt-field-label">Dosage</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
|
|
@ -136,17 +141,17 @@ function MedicationRow({
|
||||||
step="any"
|
step="any"
|
||||||
value={medication.dosage || ''}
|
value={medication.dosage || ''}
|
||||||
onChange={(e) => onChange(index, { ...medication, dosage: Number(e.target.value) })}
|
onChange={(e) => onChange(index, { ...medication, dosage: Number(e.target.value) })}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Unit</label>
|
<label className="mt-field-label">Unit</label>
|
||||||
<select
|
<select
|
||||||
value={medication.dosageUnit}
|
value={medication.dosageUnit}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange(index, { ...medication, dosageUnit: e.target.value as DosageUnit })
|
onChange(index, { ...medication, dosageUnit: e.target.value as DosageUnit })
|
||||||
}
|
}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
{allowedUnits.map((u) => (
|
{allowedUnits.map((u) => (
|
||||||
<option key={u} value={u}>
|
<option key={u} value={u}>
|
||||||
|
|
@ -158,7 +163,7 @@ function MedicationRow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Frequency</label>
|
<label className="mt-field-label">Frequency</label>
|
||||||
<select
|
<select
|
||||||
value={medication.frequency}
|
value={medication.frequency}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|
@ -171,7 +176,7 @@ function MedicationRow({
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
{Object.values(DosageFrequency).map((f) => (
|
{Object.values(DosageFrequency).map((f) => (
|
||||||
<option key={f} value={f}>
|
<option key={f} value={f}>
|
||||||
|
|
@ -183,7 +188,7 @@ function MedicationRow({
|
||||||
|
|
||||||
{medication.frequency === DosageFrequency.CUSTOM && (
|
{medication.frequency === DosageFrequency.CUSTOM && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Times per day</label>
|
<label className="mt-field-label">Times per day</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
|
|
@ -193,15 +198,13 @@ function MedicationRow({
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange(index, { ...medication, customFrequencyPerDay: Number(e.target.value) })
|
onChange(index, { ...medication, customFrequencyPerDay: Number(e.target.value) })
|
||||||
}
|
}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
<label className="mt-field-label">Time of day (optional)</label>
|
||||||
Time of day (optional)
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={medication.timeOfDay ?? ''}
|
value={medication.timeOfDay ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|
@ -210,7 +213,7 @@ function MedicationRow({
|
||||||
timeOfDay: e.target.value ? (e.target.value as TimeOfDay) : undefined,
|
timeOfDay: e.target.value ? (e.target.value as TimeOfDay) : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
<option value="">Any time</option>
|
<option value="">Any time</option>
|
||||||
{Object.values(TimeOfDay).map((t) => (
|
{Object.values(TimeOfDay).map((t) => (
|
||||||
|
|
@ -222,9 +225,7 @@ function MedicationRow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
<label className="mt-field-label">Instructions (optional)</label>
|
||||||
Instructions (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
|
|
@ -233,7 +234,7 @@ function MedicationRow({
|
||||||
onChange(index, { ...medication, instructions: e.target.value || undefined })
|
onChange(index, { ...medication, instructions: e.target.value || undefined })
|
||||||
}
|
}
|
||||||
placeholder="e.g. Take with food"
|
placeholder="e.g. Take with food"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -317,17 +318,13 @@ function RegimenForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card mb-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Regimen' : 'New Regimen'}</h2>
|
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Regimen' : 'New Regimen'}</h2>
|
||||||
{error && (
|
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
<label className="mt-field-label">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
|
@ -335,7 +332,7 @@ function RegimenForm({
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g. Morning routine"
|
placeholder="e.g. Morning routine"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 pt-6">
|
<div className="flex items-center gap-3 pt-6">
|
||||||
|
|
@ -344,7 +341,7 @@ function RegimenForm({
|
||||||
id="isActive"
|
id="isActive"
|
||||||
checked={isActive}
|
checked={isActive}
|
||||||
onChange={(e) => setIsActive(e.target.checked)}
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||||
Active
|
Active
|
||||||
|
|
@ -355,11 +352,7 @@ function RegimenForm({
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-800">Medications</h3>
|
<h3 className="text-sm font-semibold text-gray-800">Medications</h3>
|
||||||
<button
|
<button type="button" onClick={addMedication} className="mt-btn mt-btn--ghost">
|
||||||
type="button"
|
|
||||||
onClick={addMedication}
|
|
||||||
className="rounded-lg border border-primary-600 px-3 py-1.5 text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
|
|
||||||
>
|
|
||||||
+ Add medication
|
+ Add medication
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -382,18 +375,10 @@ function RegimenForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button
|
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{submitting ? 'Saving...' : initial ? 'Save changes' : 'Create regimen'}
|
{submitting ? 'Saving...' : initial ? 'Save changes' : 'Create regimen'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -549,16 +534,14 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
||||||
<select
|
<select
|
||||||
value={filterActive}
|
value={filterActive}
|
||||||
onChange={(e) => setFilterActive(e.target.value as 'all' | 'active' | 'inactive')}
|
onChange={(e) => setFilterActive(e.target.value as 'all' | 'active' | 'inactive')}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
<option value="all">All regimens</option>
|
<option value="all">All regimens</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="inactive">Inactive</option>
|
<option value="inactive">Inactive</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button onClick={handleShowBurnRate} className="mt-btn mt-btn--ghost">
|
||||||
onClick={handleShowBurnRate}
|
|
||||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
{showBurnRate ? 'Hide burn rate' : 'Burn rate'}
|
{showBurnRate ? 'Hide burn rate' : 'Burn rate'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -567,14 +550,14 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
||||||
setEditingRegimen(null);
|
setEditingRegimen(null);
|
||||||
setShowForm(!showForm);
|
setShowForm(!showForm);
|
||||||
}}
|
}}
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
className="mt-btn mt-btn--primary"
|
||||||
>
|
>
|
||||||
{showForm ? 'Cancel' : 'New Regimen'}
|
{showForm ? 'Cancel' : 'New Regimen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
<div className="mt-alert mt-alert--danger mb-4">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 underline">
|
<button onClick={() => setError('')} className="ml-2 underline">
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|
@ -583,7 +566,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showBurnRate && (
|
{showBurnRate && (
|
||||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mb-6 mt-card">
|
||||||
<h2 className="text-lg font-semibold mb-4">Burn Rate & Spending Projections</h2>
|
<h2 className="text-lg font-semibold mb-4">Burn Rate & Spending Projections</h2>
|
||||||
{burnRateLoading ? (
|
{burnRateLoading ? (
|
||||||
<div className="animate-pulse space-y-2">
|
<div className="animate-pulse space-y-2">
|
||||||
|
|
@ -630,7 +613,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : regimens.length === 0 ? (
|
) : regimens.length === 0 ? (
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
|
<div className="mt-card text-center" style={{ color: 'var(--ink-muted)' }}>
|
||||||
{filterActive !== 'all'
|
{filterActive !== 'all'
|
||||||
? `No ${filterActive} regimens found.`
|
? `No ${filterActive} regimens found.`
|
||||||
: isFormOpen
|
: isFormOpen
|
||||||
|
|
@ -640,17 +623,13 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{regimens.map((regimen) => (
|
{regimens.map((regimen) => (
|
||||||
<div key={regimen._id} className="rounded-xl border bg-white p-4 shadow-sm">
|
<div key={regimen._id} className="mt-card">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="font-semibold text-gray-900">{regimen.name}</h3>
|
<h3 className="font-semibold text-gray-900">{regimen.name}</h3>
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
className={`mt-pill ${regimen.isActive ? 'mt-pill--ok' : 'mt-pill--ghost'}`}
|
||||||
regimen.isActive
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-gray-100 text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{regimen.isActive ? 'Active' : 'Inactive'}
|
{regimen.isActive ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -661,21 +640,20 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{regimen.medications.map((med, i) => (
|
{regimen.medications.map((med, i) => (
|
||||||
<span
|
<span key={i} className="mt-pill mt-pill--info">
|
||||||
key={i}
|
|
||||||
className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700"
|
|
||||||
>
|
|
||||||
{med.medicineName} — {med.dosage} {med.dosageUnit} (
|
{med.medicineName} — {med.dosage} {med.dosageUnit} (
|
||||||
{FREQUENCY_LABELS[med.frequency] ?? med.frequency})
|
{FREQUENCY_LABELS[med.frequency] ?? med.frequency})
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-gray-400">Created {formatDate(regimen.createdAt)}</p>
|
<p className="mt-2 text-xs text-gray-400">
|
||||||
|
Created {formatDate(regimen.createdAt)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleActive(regimen)}
|
onClick={() => handleToggleActive(regimen)}
|
||||||
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-gray-50 transition-colors"
|
className="mt-btn mt-btn--ghost"
|
||||||
title={regimen.isActive ? 'Deactivate' : 'Activate'}
|
title={regimen.isActive ? 'Deactivate' : 'Activate'}
|
||||||
>
|
>
|
||||||
{regimen.isActive ? 'Deactivate' : 'Activate'}
|
{regimen.isActive ? 'Deactivate' : 'Activate'}
|
||||||
|
|
@ -685,7 +663,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingRegimen(regimen);
|
setEditingRegimen(regimen);
|
||||||
}}
|
}}
|
||||||
className="rounded p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
|
className="mt-btn mt-btn--icon"
|
||||||
title="Edit"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -699,7 +677,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(regimen._id, regimen.name)}
|
onClick={() => handleDelete(regimen._id, regimen.name)}
|
||||||
className="rounded p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
className="mt-btn mt-btn--danger-icon"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import type React from 'react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
|
@ -22,16 +22,21 @@ const {
|
||||||
mockUpdateMedicineProduct: vi.fn(),
|
mockUpdateMedicineProduct: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { mockListCabinetItems, mockAdjustCabinetItemQuantity, mockDeleteCabinetItem } =
|
const { mockListCabinetItems, mockAdjustCabinetItemQuantity, mockDeleteCabinetItem } = vi.hoisted(
|
||||||
vi.hoisted(() => ({
|
() => ({
|
||||||
mockListCabinetItems: vi.fn(),
|
mockListCabinetItems: vi.fn(),
|
||||||
mockAdjustCabinetItemQuantity: vi.fn(),
|
mockAdjustCabinetItemQuantity: vi.fn(),
|
||||||
mockDeleteCabinetItem: vi.fn(),
|
mockDeleteCabinetItem: vi.fn(),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
vi.mock('next/navigation', () => ({ useParams: mockUseParams }));
|
vi.mock('next/navigation', () => ({ useParams: mockUseParams }));
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => (
|
||||||
|
<a href={props.href}>{props.children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/services/medicines', () => ({
|
vi.mock('@/services/medicines', () => ({
|
||||||
getMedicine: mockGetMedicine,
|
getMedicine: mockGetMedicine,
|
||||||
|
|
@ -109,9 +114,7 @@ describe('MedicineDetailPage', () => {
|
||||||
it('shows empty products state', async () => {
|
it('shows empty products state', async () => {
|
||||||
render(<MedicineDetailPage />);
|
render(<MedicineDetailPage />);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText(/No products yet/)).toBeInTheDocument());
|
||||||
expect(screen.getByText(/No products yet/)).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toggles Add Product form', async () => {
|
it('toggles Add Product form', async () => {
|
||||||
|
|
@ -146,7 +149,9 @@ describe('MedicineDetailPage', () => {
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
|
||||||
await waitFor(() => expect(mockUpdateMedicine).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)));
|
await waitFor(() =>
|
||||||
|
expect(mockUpdateMedicine).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes a product after confirmation', async () => {
|
it('deletes a product after confirmation', async () => {
|
||||||
|
|
@ -250,7 +255,9 @@ describe('MedicineDetailPage', () => {
|
||||||
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||||
|
|
||||||
await waitFor(() => screen.getByDisplayValue('Glucophage'));
|
await waitFor(() => screen.getByDisplayValue('Glucophage'));
|
||||||
fireEvent.change(screen.getByDisplayValue('Glucophage'), { target: { value: 'Glucophage XR' } });
|
fireEvent.change(screen.getByDisplayValue('Glucophage'), {
|
||||||
|
target: { value: 'Glucophage XR' },
|
||||||
|
});
|
||||||
fireEvent.submit(screen.getByDisplayValue('Glucophage XR').closest('form')!);
|
fireEvent.submit(screen.getByDisplayValue('Glucophage XR').closest('form')!);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|
@ -275,9 +282,9 @@ describe('MedicineDetailPage', () => {
|
||||||
|
|
||||||
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||||
// Change package unit to ml to show concentration fields
|
// Change package unit to ml to show concentration fields
|
||||||
const unitSelect = screen.getAllByRole('combobox').find(
|
const unitSelect = screen
|
||||||
(s) => (s as HTMLSelectElement).value === 'vial',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find((s) => (s as HTMLSelectElement).value === 'vial') as HTMLSelectElement;
|
||||||
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
|
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByPlaceholderText('e.g. 100')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByPlaceholderText('e.g. 100')).toBeInTheDocument());
|
||||||
|
|
@ -337,9 +344,9 @@ describe('MedicineDetailPage', () => {
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '100' } });
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '100' } });
|
||||||
|
|
||||||
// Change concentration unit
|
// Change concentration unit
|
||||||
const concUnitSelect = screen.getAllByRole('combobox').find(
|
const concUnitSelect = screen
|
||||||
(s) => (s as HTMLSelectElement).options[0]?.text === '--',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find((s) => (s as HTMLSelectElement).options[0]?.text === '--') as HTMLSelectElement;
|
||||||
if (concUnitSelect) {
|
if (concUnitSelect) {
|
||||||
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
|
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
|
||||||
}
|
}
|
||||||
|
|
@ -372,9 +379,9 @@ describe('MedicineDetailPage', () => {
|
||||||
fireEvent.change(screen.getByDisplayValue('60'), { target: { value: '90' } });
|
fireEvent.change(screen.getByDisplayValue('60'), { target: { value: '90' } });
|
||||||
|
|
||||||
// Change package unit
|
// Change package unit
|
||||||
const unitSelect = screen.getAllByRole('combobox').find(
|
const unitSelect = screen
|
||||||
(s) => (s as HTMLSelectElement).value === 'tablet',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find((s) => (s as HTMLSelectElement).value === 'tablet') as HTMLSelectElement;
|
||||||
fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
|
fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
|
||||||
|
|
||||||
expect(screen.getByDisplayValue('Glucophage')).toBeInTheDocument();
|
expect(screen.getByDisplayValue('Glucophage')).toBeInTheDocument();
|
||||||
|
|
@ -398,13 +405,17 @@ describe('MedicineDetailPage', () => {
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. Pfizer'), { target: { value: '' } });
|
fireEvent.change(screen.getByPlaceholderText('e.g. Pfizer'), { target: { value: '' } });
|
||||||
|
|
||||||
// Change notes (truthy) then clear (falsy → undefined)
|
// Change notes (truthy) then clear (falsy → undefined)
|
||||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: 'Store in fridge' } });
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
||||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: '' } });
|
target: { value: 'Store in fridge' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
||||||
|
target: { value: '' },
|
||||||
|
});
|
||||||
|
|
||||||
// Change unit to ml to show concentration fields
|
// Change unit to ml to show concentration fields
|
||||||
const unitSelect = screen.getAllByRole('combobox').find(
|
const unitSelect = screen
|
||||||
(s) => (s as HTMLSelectElement).value === 'vial',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find((s) => (s as HTMLSelectElement).value === 'vial') as HTMLSelectElement;
|
||||||
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
|
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
|
||||||
|
|
||||||
await waitFor(() => screen.getByPlaceholderText('e.g. 100'));
|
await waitFor(() => screen.getByPlaceholderText('e.g. 100'));
|
||||||
|
|
@ -413,9 +424,9 @@ describe('MedicineDetailPage', () => {
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '' } });
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '' } });
|
||||||
|
|
||||||
// Change concentration unit
|
// Change concentration unit
|
||||||
const concUnitSelect = screen.getAllByRole('combobox').find(
|
const concUnitSelect = screen
|
||||||
(s) => (s as HTMLSelectElement).value === '',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find((s) => (s as HTMLSelectElement).value === '') as HTMLSelectElement;
|
||||||
if (concUnitSelect) {
|
if (concUnitSelect) {
|
||||||
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
|
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
|
||||||
}
|
}
|
||||||
|
|
@ -544,9 +555,9 @@ describe('MedicineDetailPage', () => {
|
||||||
if (nameInput) fireEvent.change(nameInput, { target: { value: 'Metformin XR' } });
|
if (nameInput) fireEvent.change(nameInput, { target: { value: 'Metformin XR' } });
|
||||||
|
|
||||||
// Change form select
|
// Change form select
|
||||||
const formSelect = screen.getAllByRole('combobox').find(
|
const formSelect = screen
|
||||||
(s) => (s as HTMLSelectElement).options[0]?.value === 'tablet',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find((s) => (s as HTMLSelectElement).options[0]?.value === 'tablet') as HTMLSelectElement;
|
||||||
if (formSelect) fireEvent.change(formSelect, { target: { value: 'capsule' } });
|
if (formSelect) fireEvent.change(formSelect, { target: { value: 'capsule' } });
|
||||||
|
|
||||||
// Change strength
|
// Change strength
|
||||||
|
|
@ -554,9 +565,11 @@ describe('MedicineDetailPage', () => {
|
||||||
if (strengthInput) fireEvent.change(strengthInput, { target: { value: '250' } });
|
if (strengthInput) fireEvent.change(strengthInput, { target: { value: '250' } });
|
||||||
|
|
||||||
// Change category select
|
// Change category select
|
||||||
const catSelect = screen.getAllByRole('combobox').find(
|
const catSelect = screen
|
||||||
(s) => (s as HTMLSelectElement).options[0]?.value === 'prescription',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find(
|
||||||
|
(s) => (s as HTMLSelectElement).options[0]?.value === 'prescription',
|
||||||
|
) as HTMLSelectElement;
|
||||||
if (catSelect) fireEvent.change(catSelect, { target: { value: 'otc' } });
|
if (catSelect) fireEvent.change(catSelect, { target: { value: 'otc' } });
|
||||||
|
|
||||||
// Change notes (truthy value)
|
// Change notes (truthy value)
|
||||||
|
|
@ -607,9 +620,9 @@ describe('MedicineDetailPage', () => {
|
||||||
|
|
||||||
await waitFor(() => screen.getByText('Edit Medicine'));
|
await waitFor(() => screen.getByText('Edit Medicine'));
|
||||||
// Change strength unit select (the one with 'mg' options)
|
// Change strength unit select (the one with 'mg' options)
|
||||||
const unitSelect = screen.getAllByRole('combobox').find(
|
const unitSelect = screen
|
||||||
(s) => (s as HTMLSelectElement).value === 'mg',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find((s) => (s as HTMLSelectElement).value === 'mg') as HTMLSelectElement;
|
||||||
if (unitSelect) fireEvent.change(unitSelect, { target: { value: 'mcg' } });
|
if (unitSelect) fireEvent.change(unitSelect, { target: { value: 'mcg' } });
|
||||||
|
|
||||||
expect(screen.getByText('Edit Medicine')).toBeInTheDocument();
|
expect(screen.getByText('Edit Medicine')).toBeInTheDocument();
|
||||||
|
|
@ -643,7 +656,9 @@ describe('MedicineDetailPage', () => {
|
||||||
await userEvent.click(screen.getByText('Add Product'));
|
await userEvent.click(screen.getByText('Add Product'));
|
||||||
|
|
||||||
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||||
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), { target: { value: 'Brand X' } });
|
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), {
|
||||||
|
target: { value: 'Brand X' },
|
||||||
|
});
|
||||||
fireEvent.submit(screen.getByPlaceholderText('e.g. CVS Health').closest('form')!);
|
fireEvent.submit(screen.getByPlaceholderText('e.g. CVS Health').closest('form')!);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Failed to create product')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Failed to create product')).toBeInTheDocument());
|
||||||
|
|
@ -772,6 +787,8 @@ describe('MedicineDetailPage', () => {
|
||||||
await waitFor(() => screen.getAllByTitle('Delete'));
|
await waitFor(() => screen.getAllByTitle('Delete'));
|
||||||
await userEvent.click(screen.getAllByTitle('Delete')[0]!);
|
await userEvent.click(screen.getAllByTitle('Delete')[0]!);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Failed to delete cabinet item')).toBeInTheDocument());
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('Failed to delete cabinet item')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,7 @@ import {
|
||||||
updateMedicine,
|
updateMedicine,
|
||||||
updateMedicineProduct,
|
updateMedicineProduct,
|
||||||
} from '@/services/medicines';
|
} from '@/services/medicines';
|
||||||
import {
|
import { listCabinetItems, adjustCabinetItemQuantity, deleteCabinetItem } from '@/services/cabinet';
|
||||||
listCabinetItems,
|
|
||||||
adjustCabinetItemQuantity,
|
|
||||||
deleteCabinetItem,
|
|
||||||
} from '@/services/cabinet';
|
|
||||||
import {
|
import {
|
||||||
DosageUnit,
|
DosageUnit,
|
||||||
ConcentrationUnit,
|
ConcentrationUnit,
|
||||||
|
|
@ -162,7 +158,9 @@ export default function MedicineDetailPage() {
|
||||||
function startEditProduct(product: MedicineProduct) {
|
function startEditProduct(product: MedicineProduct) {
|
||||||
setEditingProductId(product._id);
|
setEditingProductId(product._id);
|
||||||
const validUnits = Object.values(DosageUnit) as string[];
|
const validUnits = Object.values(DosageUnit) as string[];
|
||||||
const allowedUnits = allowedUnitsForForm((medicine?.form as MedicineForm) ?? MedicineForm.OTHER);
|
const allowedUnits = allowedUnitsForForm(
|
||||||
|
(medicine?.form as MedicineForm) ?? MedicineForm.OTHER,
|
||||||
|
);
|
||||||
const storedUnit = product.packageUnit;
|
const storedUnit = product.packageUnit;
|
||||||
const packageUnit = validUnits.includes(storedUnit)
|
const packageUnit = validUnits.includes(storedUnit)
|
||||||
? (storedUnit as DosageUnit)
|
? (storedUnit as DosageUnit)
|
||||||
|
|
@ -650,7 +648,8 @@ export default function MedicineDetailPage() {
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
{allowedUnitsForForm(
|
{allowedUnitsForForm(
|
||||||
/* v8 ignore next */ (medicine?.form as MedicineForm) ?? MedicineForm.OTHER,
|
/* v8 ignore next */ (medicine?.form as MedicineForm) ??
|
||||||
|
MedicineForm.OTHER,
|
||||||
).map((u) => (
|
).map((u) => (
|
||||||
<option key={u} value={u}>
|
<option key={u} value={u}>
|
||||||
{u}
|
{u}
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,17 @@ describe('ActivityTab', () => {
|
||||||
it('fetches spending summary on mount', async () => {
|
it('fetches spending summary on mount', async () => {
|
||||||
render(<ActivityTab householdId="hh1" />);
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.any(Object)));
|
await waitFor(() =>
|
||||||
|
expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.any(Object)),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches events on mount', async () => {
|
it('fetches events on mount', async () => {
|
||||||
render(<ActivityTab householdId="hh1" />);
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledWith('hh1', expect.any(Object)));
|
await waitFor(() =>
|
||||||
|
expect(mockListCabinetEvents).toHaveBeenCalledWith('hh1', expect.any(Object)),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows empty state when no events', async () => {
|
it('shows empty state when no events', async () => {
|
||||||
|
|
@ -103,20 +107,18 @@ describe('ActivityTab', () => {
|
||||||
|
|
||||||
it('shows spending summary with data', async () => {
|
it('shows spending summary with data', async () => {
|
||||||
mockGetSpendingSummary.mockResolvedValue({
|
mockGetSpendingSummary.mockResolvedValue({
|
||||||
totalSpent: 125.50,
|
totalSpent: 125.5,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
byMedicine: [
|
byMedicine: [
|
||||||
{
|
{
|
||||||
medicineId: 'med-1',
|
medicineId: 'med-1',
|
||||||
medicineName: 'Metformin',
|
medicineName: 'Metformin',
|
||||||
totalSpent: 125.50,
|
totalSpent: 125.5,
|
||||||
purchaseCount: 2,
|
purchaseCount: 2,
|
||||||
avgUnitPrice: 0.69,
|
avgUnitPrice: 0.69,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
byPeriod: [
|
byPeriod: [{ period: '2026-01', totalSpent: 125.5 }],
|
||||||
{ period: '2026-01', totalSpent: 125.50 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<ActivityTab householdId="hh1" />);
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
@ -138,7 +140,9 @@ describe('ActivityTab', () => {
|
||||||
render(<ActivityTab householdId="hh1" />);
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(1));
|
||||||
fireEvent.change(screen.getByDisplayValue('All event types'), { target: { value: 'purchased' } });
|
fireEvent.change(screen.getByDisplayValue('All event types'), {
|
||||||
|
target: { value: 'purchased' },
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(2));
|
||||||
});
|
});
|
||||||
|
|
@ -151,7 +155,9 @@ describe('ActivityTab', () => {
|
||||||
|
|
||||||
render(<ActivityTab householdId="hh1" />);
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getAllByDisplayValue('All medicines').length).toBeGreaterThan(1));
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByDisplayValue('All medicines').length).toBeGreaterThan(1),
|
||||||
|
);
|
||||||
const allMedSelects = screen.getAllByDisplayValue('All medicines');
|
const allMedSelects = screen.getAllByDisplayValue('All medicines');
|
||||||
// The last select is the cabinet events medicine filter
|
// The last select is the cabinet events medicine filter
|
||||||
fireEvent.change(allMedSelects[allMedSelects.length - 1]!, { target: { value: 'med-1' } });
|
fireEvent.change(allMedSelects[allMedSelects.length - 1]!, { target: { value: 'med-1' } });
|
||||||
|
|
@ -163,7 +169,9 @@ describe('ActivityTab', () => {
|
||||||
render(<ActivityTab householdId="hh1" />);
|
render(<ActivityTab householdId="hh1" />);
|
||||||
|
|
||||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalled());
|
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalled());
|
||||||
fireEvent.change(screen.getByDisplayValue('All event types'), { target: { value: 'purchased' } });
|
fireEvent.change(screen.getByDisplayValue('All event types'), {
|
||||||
|
target: { value: 'purchased' },
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => screen.getByText('Clear filters'));
|
await waitFor(() => screen.getByText('Clear filters'));
|
||||||
await userEvent.click(screen.getByText('Clear filters'));
|
await userEvent.click(screen.getByText('Clear filters'));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mockListCabinetItems,
|
mockListCabinetItems,
|
||||||
|
|
@ -28,7 +29,11 @@ vi.mock('@/services/cabinet', () => ({
|
||||||
|
|
||||||
vi.mock('@/services/medicines', () => ({ listMedicines: mockListMedicines }));
|
vi.mock('@/services/medicines', () => ({ listMedicines: mockListMedicines }));
|
||||||
|
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => (
|
||||||
|
<a href={props.href}>{props.children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
import { CabinetTab } from '../CabinetTab';
|
import { CabinetTab } from '../CabinetTab';
|
||||||
|
|
||||||
|
|
@ -206,9 +211,7 @@ describe('CabinetTab', () => {
|
||||||
// Submit without selecting a medicine - should show error
|
// Submit without selecting a medicine - should show error
|
||||||
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText('Please select a medicine')).toBeInTheDocument());
|
||||||
expect(screen.getByText('Please select a medicine')).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submits AddToCabinetForm successfully', async () => {
|
it('submits AddToCabinetForm successfully', async () => {
|
||||||
|
|
@ -241,7 +244,10 @@ describe('CabinetTab', () => {
|
||||||
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mockCreateCabinetItem).toHaveBeenCalledWith('hh1', expect.objectContaining({ medicineId: 'med-1' })),
|
expect(mockCreateCabinetItem).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ medicineId: 'med-1' }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -590,7 +596,9 @@ describe('CabinetTab', () => {
|
||||||
|
|
||||||
it('shows create error when medicine is selected and create fails', async () => {
|
it('shows create error when medicine is selected and create fails', async () => {
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
|
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
|
||||||
|
|
@ -623,7 +631,9 @@ describe('CabinetTab', () => {
|
||||||
|
|
||||||
it('waits for medicines to load then selects medicine in form', async () => {
|
it('waits for medicines to load then selects medicine in form', async () => {
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -648,7 +658,9 @@ describe('CabinetTab', () => {
|
||||||
|
|
||||||
it('shows fallback error when non-Error is thrown during create', async () => {
|
it('shows fallback error when non-Error is thrown during create', async () => {
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockCreateCabinetItem.mockRejectedValue('unexpected');
|
mockCreateCabinetItem.mockRejectedValue('unexpected');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
const { mockListMedicines, mockCreateMedicine, mockDeleteMedicine } = vi.hoisted(() => ({
|
const { mockListMedicines, mockCreateMedicine, mockDeleteMedicine } = vi.hoisted(() => ({
|
||||||
mockListMedicines: vi.fn(),
|
mockListMedicines: vi.fn(),
|
||||||
|
|
@ -14,7 +15,11 @@ vi.mock('@/services/medicines', () => ({
|
||||||
deleteMedicine: mockDeleteMedicine,
|
deleteMedicine: mockDeleteMedicine,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => (
|
||||||
|
<a href={props.href}>{props.children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
import { LibraryTab } from '../LibraryTab';
|
import { LibraryTab } from '../LibraryTab';
|
||||||
|
|
||||||
|
|
@ -40,7 +45,10 @@ describe('LibraryTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders medicine list after load', async () => {
|
it('renders medicine list after load', async () => {
|
||||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [medicine],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
render(<LibraryTab householdId="hh1" />);
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
||||||
|
|
@ -98,11 +106,19 @@ describe('LibraryTab', () => {
|
||||||
await userEvent.type(screen.getByPlaceholderText('500'), '100');
|
await userEvent.type(screen.getByPlaceholderText('500'), '100');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Create Medicine' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Create Medicine' }));
|
||||||
|
|
||||||
await waitFor(() => expect(mockCreateMedicine).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Aspirin' })));
|
await waitFor(() =>
|
||||||
|
expect(mockCreateMedicine).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ name: 'Aspirin' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes medicine after confirmation', async () => {
|
it('deletes medicine after confirmation', async () => {
|
||||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [medicine],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
mockDeleteMedicine.mockResolvedValue({});
|
mockDeleteMedicine.mockResolvedValue({});
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
|
@ -131,9 +147,13 @@ describe('LibraryTab', () => {
|
||||||
// Change category
|
// Change category
|
||||||
fireEvent.change(screen.getByDisplayValue('OTC'), { target: { value: 'prescription' } });
|
fireEvent.change(screen.getByDisplayValue('OTC'), { target: { value: 'prescription' } });
|
||||||
// Change notes (covers truthy branch)
|
// Change notes (covers truthy branch)
|
||||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: 'test notes' } });
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
||||||
|
target: { value: 'test notes' },
|
||||||
|
});
|
||||||
// Clear notes (covers falsy branch → undefined)
|
// Clear notes (covers falsy branch → undefined)
|
||||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: '' } });
|
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
||||||
|
target: { value: '' },
|
||||||
|
});
|
||||||
|
|
||||||
// Verify form is still visible
|
// Verify form is still visible
|
||||||
expect(screen.getByPlaceholderText('e.g. Metformin')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('e.g. Metformin')).toBeInTheDocument();
|
||||||
|
|
@ -150,10 +170,14 @@ describe('LibraryTab', () => {
|
||||||
await waitFor(() => screen.getByText('Metformin'));
|
await waitFor(() => screen.getByText('Metformin'));
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), { target: { value: 'met' } });
|
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), {
|
||||||
|
target: { value: 'met' },
|
||||||
|
});
|
||||||
|
|
||||||
// Category filter
|
// Category filter
|
||||||
fireEvent.change(screen.getByDisplayValue('All Categories'), { target: { value: 'prescription' } });
|
fireEvent.change(screen.getByDisplayValue('All Categories'), {
|
||||||
|
target: { value: 'prescription' },
|
||||||
|
});
|
||||||
|
|
||||||
// Form filter
|
// Form filter
|
||||||
fireEvent.change(screen.getByDisplayValue('All Forms'), { target: { value: 'tablet' } });
|
fireEvent.change(screen.getByDisplayValue('All Forms'), { target: { value: 'tablet' } });
|
||||||
|
|
@ -162,7 +186,10 @@ describe('LibraryTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows fallback error when non-Error is thrown on delete', async () => {
|
it('shows fallback error when non-Error is thrown on delete', async () => {
|
||||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [medicine],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
mockDeleteMedicine.mockRejectedValue('oops');
|
mockDeleteMedicine.mockRejectedValue('oops');
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
|
@ -207,7 +234,10 @@ describe('LibraryTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not delete medicine if confirmation cancelled', async () => {
|
it('does not delete medicine if confirmation cancelled', async () => {
|
||||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
mockListMedicines.mockResolvedValue({
|
||||||
|
data: [medicine],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
|
||||||
render(<LibraryTab householdId="hh1" />);
|
render(<LibraryTab householdId="hh1" />);
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,7 @@ describe('OrganizerTab', () => {
|
||||||
it('shows no active regimens message when none exist', async () => {
|
it('shows no active regimens message when none exist', async () => {
|
||||||
render(<OrganizerTab householdId="hh1" />);
|
render(<OrganizerTab householdId="hh1" />);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText(/No active regimens found/)).toBeInTheDocument());
|
||||||
expect(screen.getByText(/No active regimens found/)).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows fill form when active regimens exist', async () => {
|
it('shows fill form when active regimens exist', async () => {
|
||||||
|
|
@ -69,9 +67,7 @@ describe('OrganizerTab', () => {
|
||||||
it('shows empty fill history', async () => {
|
it('shows empty fill history', async () => {
|
||||||
render(<OrganizerTab householdId="hh1" />);
|
render(<OrganizerTab householdId="hh1" />);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText('No fills recorded yet.')).toBeInTheDocument());
|
||||||
expect(screen.getByText('No fills recorded yet.')).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders fill history entries', async () => {
|
it('renders fill history entries', async () => {
|
||||||
|
|
@ -118,7 +114,10 @@ describe('OrganizerTab', () => {
|
||||||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mockPreviewFill).toHaveBeenCalledWith('hh1', expect.objectContaining({ regimenId: 'reg-1' })),
|
expect(mockPreviewFill).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ regimenId: 'reg-1' }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -159,7 +158,12 @@ describe('OrganizerTab', () => {
|
||||||
|
|
||||||
await userEvent.click(screen.getByText('Confirm fill'));
|
await userEvent.click(screen.getByText('Confirm fill'));
|
||||||
|
|
||||||
await waitFor(() => expect(mockExecuteFill).toHaveBeenCalledWith('hh1', expect.objectContaining({ regimenId: 'reg-1' })));
|
await waitFor(() =>
|
||||||
|
expect(mockExecuteFill).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ regimenId: 'reg-1' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows shortage warning in preview', async () => {
|
it('shows shortage warning in preview', async () => {
|
||||||
|
|
@ -291,15 +295,17 @@ describe('OrganizerTab', () => {
|
||||||
status: 'partial',
|
status: 'partial',
|
||||||
numberOfDays: 7,
|
numberOfDays: 7,
|
||||||
fillDate: '2026-01-01T00:00:00.000Z',
|
fillDate: '2026-01-01T00:00:00.000Z',
|
||||||
items: [{
|
items: [
|
||||||
medicineId: 'med-1',
|
{
|
||||||
medicineName: 'Metformin',
|
medicineId: 'med-1',
|
||||||
quantityNeeded: 7,
|
medicineName: 'Metformin',
|
||||||
quantityTaken: 3,
|
quantityNeeded: 7,
|
||||||
wasShort: true,
|
quantityTaken: 3,
|
||||||
shortage: 4,
|
wasShort: true,
|
||||||
deductions: [],
|
shortage: 4,
|
||||||
}],
|
deductions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
|
|
@ -395,7 +401,11 @@ describe('OrganizerTab', () => {
|
||||||
isShort: false,
|
isShort: false,
|
||||||
shortage: 0,
|
shortage: 0,
|
||||||
cabinetBreakdown: [
|
cabinetBreakdown: [
|
||||||
{ cabinetItemId: 'ci-1', quantityToTake: 7, expirationDate: '2027-06-01T00:00:00.000Z' },
|
{
|
||||||
|
cabinetItemId: 'ci-1',
|
||||||
|
quantityToTake: 7,
|
||||||
|
expirationDate: '2027-06-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
{ cabinetItemId: 'ci-2', quantityToTake: 3 },
|
{ cabinetItemId: 'ci-2', quantityToTake: 3 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
const { mockListRegimens, mockCreateRegimen, mockUpdateRegimen, mockDeleteRegimen, mockGetBurnRates } =
|
const {
|
||||||
vi.hoisted(() => ({
|
mockListRegimens,
|
||||||
mockListRegimens: vi.fn(),
|
mockCreateRegimen,
|
||||||
mockCreateRegimen: vi.fn(),
|
mockUpdateRegimen,
|
||||||
mockUpdateRegimen: vi.fn(),
|
mockDeleteRegimen,
|
||||||
mockDeleteRegimen: vi.fn(),
|
mockGetBurnRates,
|
||||||
mockGetBurnRates: vi.fn(),
|
} = vi.hoisted(() => ({
|
||||||
}));
|
mockListRegimens: vi.fn(),
|
||||||
|
mockCreateRegimen: vi.fn(),
|
||||||
|
mockUpdateRegimen: vi.fn(),
|
||||||
|
mockDeleteRegimen: vi.fn(),
|
||||||
|
mockGetBurnRates: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const { mockListMedicines } = vi.hoisted(() => ({ mockListMedicines: vi.fn() }));
|
const { mockListMedicines } = vi.hoisted(() => ({ mockListMedicines: vi.fn() }));
|
||||||
|
|
||||||
|
|
@ -62,7 +67,10 @@ describe('RegimensTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders regimen list', async () => {
|
it('renders regimen list', async () => {
|
||||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [regimen],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
render(<RegimensTab householdId="hh1" />);
|
render(<RegimensTab householdId="hh1" />);
|
||||||
|
|
||||||
|
|
@ -122,7 +130,10 @@ describe('RegimensTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes regimen after confirmation', async () => {
|
it('deletes regimen after confirmation', async () => {
|
||||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [regimen],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
mockDeleteRegimen.mockResolvedValue({});
|
mockDeleteRegimen.mockResolvedValue({});
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
|
@ -142,12 +153,17 @@ describe('RegimensTab', () => {
|
||||||
await waitFor(() => screen.getByText('Burn rate'));
|
await waitFor(() => screen.getByText('Burn rate'));
|
||||||
await userEvent.click(screen.getByText('Burn rate'));
|
await userEvent.click(screen.getByText('Burn rate'));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Burn Rate & Spending Projections')).toBeInTheDocument());
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('Burn Rate & Spending Projections')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
expect(mockGetBurnRates).toHaveBeenCalledWith('hh1');
|
expect(mockGetBurnRates).toHaveBeenCalledWith('hh1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens edit form for regimen', async () => {
|
it('opens edit form for regimen', async () => {
|
||||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [regimen],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
render(<RegimensTab householdId="hh1" />);
|
render(<RegimensTab householdId="hh1" />);
|
||||||
|
|
||||||
|
|
@ -158,7 +174,10 @@ describe('RegimensTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saves edited regimen', async () => {
|
it('saves edited regimen', async () => {
|
||||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [regimen],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
mockUpdateRegimen.mockResolvedValue({ ...regimen, name: 'Evening Routine' });
|
mockUpdateRegimen.mockResolvedValue({ ...regimen, name: 'Evening Routine' });
|
||||||
|
|
||||||
render(<RegimensTab householdId="hh1" />);
|
render(<RegimensTab householdId="hh1" />);
|
||||||
|
|
@ -167,11 +186,17 @@ describe('RegimensTab', () => {
|
||||||
await userEvent.click(screen.getByTitle('Edit'));
|
await userEvent.click(screen.getByTitle('Edit'));
|
||||||
|
|
||||||
await waitFor(() => screen.getByDisplayValue('Morning Routine'));
|
await waitFor(() => screen.getByDisplayValue('Morning Routine'));
|
||||||
fireEvent.change(screen.getByDisplayValue('Morning Routine'), { target: { value: 'Evening Routine' } });
|
fireEvent.change(screen.getByDisplayValue('Morning Routine'), {
|
||||||
|
target: { value: 'Evening Routine' },
|
||||||
|
});
|
||||||
fireEvent.submit(screen.getByDisplayValue('Evening Routine').closest('form')!);
|
fireEvent.submit(screen.getByDisplayValue('Evening Routine').closest('form')!);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mockUpdateRegimen).toHaveBeenCalledWith('hh1', 'reg-1', expect.objectContaining({ name: 'Evening Routine' })),
|
expect(mockUpdateRegimen).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
'reg-1',
|
||||||
|
expect.objectContaining({ name: 'Evening Routine' }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -196,7 +221,10 @@ describe('RegimensTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when delete fails', async () => {
|
it('shows error when delete fails', async () => {
|
||||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [regimen],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
mockDeleteRegimen.mockRejectedValue(new Error('Delete failed'));
|
mockDeleteRegimen.mockRejectedValue(new Error('Delete failed'));
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
|
@ -237,7 +265,10 @@ describe('RegimensTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels edit form and hides it', async () => {
|
it('cancels edit form and hides it', async () => {
|
||||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [regimen],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
render(<RegimensTab householdId="hh1" />);
|
render(<RegimensTab householdId="hh1" />);
|
||||||
|
|
||||||
|
|
@ -251,7 +282,10 @@ describe('RegimensTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters regimens by active status', async () => {
|
it('filters regimens by active status', async () => {
|
||||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [regimen],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
render(<RegimensTab householdId="hh1" />);
|
render(<RegimensTab householdId="hh1" />);
|
||||||
|
|
||||||
|
|
@ -288,7 +322,9 @@ describe('RegimensTab', () => {
|
||||||
|
|
||||||
it('changes medicine, dosage, and unit in medication row', async () => {
|
it('changes medicine, dosage, and unit in medication row', async () => {
|
||||||
mockListMedicines.mockResolvedValue({
|
mockListMedicines.mockResolvedValue({
|
||||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
data: [
|
||||||
|
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -300,9 +336,11 @@ describe('RegimensTab', () => {
|
||||||
await waitFor(() => screen.getByText('Select medicine...'));
|
await waitFor(() => screen.getByText('Select medicine...'));
|
||||||
|
|
||||||
// Select a medicine in the medication row
|
// Select a medicine in the medication row
|
||||||
const medicineSelect = screen.getAllByRole('combobox').find(
|
const medicineSelect = screen
|
||||||
(s) => (s as HTMLSelectElement).options[0]?.text === 'Select medicine...',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find(
|
||||||
|
(s) => (s as HTMLSelectElement).options[0]?.text === 'Select medicine...',
|
||||||
|
) as HTMLSelectElement;
|
||||||
expect(medicineSelect).toBeDefined();
|
expect(medicineSelect).toBeDefined();
|
||||||
fireEvent.change(medicineSelect!, { target: { value: 'med-1' } });
|
fireEvent.change(medicineSelect!, { target: { value: 'med-1' } });
|
||||||
|
|
||||||
|
|
@ -311,9 +349,9 @@ describe('RegimensTab', () => {
|
||||||
if (dosageInput) fireEvent.change(dosageInput, { target: { value: '2' } });
|
if (dosageInput) fireEvent.change(dosageInput, { target: { value: '2' } });
|
||||||
|
|
||||||
// Change dosage unit
|
// Change dosage unit
|
||||||
const unitSelect = screen.getAllByRole('combobox').find(
|
const unitSelect = screen
|
||||||
(s) => (s as HTMLSelectElement).value === 'tablet',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find((s) => (s as HTMLSelectElement).value === 'tablet') as HTMLSelectElement;
|
||||||
if (unitSelect) fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
|
if (unitSelect) fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText('e.g. Morning routine')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('e.g. Morning routine')).toBeInTheDocument();
|
||||||
|
|
@ -342,9 +380,9 @@ describe('RegimensTab', () => {
|
||||||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||||
|
|
||||||
// The frequency select has 'daily' as its first option value
|
// The frequency select has 'daily' as its first option value
|
||||||
const frequencySelect = screen.getAllByRole('combobox').find(
|
const frequencySelect = screen
|
||||||
(s) => (s as HTMLSelectElement).options[0]?.value === 'daily',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find((s) => (s as HTMLSelectElement).options[0]?.value === 'daily') as HTMLSelectElement;
|
||||||
expect(frequencySelect).toBeDefined();
|
expect(frequencySelect).toBeDefined();
|
||||||
fireEvent.change(frequencySelect!, { target: { value: 'custom' } });
|
fireEvent.change(frequencySelect!, { target: { value: 'custom' } });
|
||||||
|
|
||||||
|
|
@ -368,9 +406,9 @@ describe('RegimensTab', () => {
|
||||||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||||
|
|
||||||
// The time-of-day select has 'Any time' as its first option text
|
// The time-of-day select has 'Any time' as its first option text
|
||||||
const timeOfDaySelect = screen.getAllByRole('combobox').find(
|
const timeOfDaySelect = screen
|
||||||
(s) => (s as HTMLSelectElement).options[0]?.text === 'Any time',
|
.getAllByRole('combobox')
|
||||||
) as HTMLSelectElement;
|
.find((s) => (s as HTMLSelectElement).options[0]?.text === 'Any time') as HTMLSelectElement;
|
||||||
expect(timeOfDaySelect).toBeDefined();
|
expect(timeOfDaySelect).toBeDefined();
|
||||||
fireEvent.change(timeOfDaySelect!, { target: { value: 'morning' } });
|
fireEvent.change(timeOfDaySelect!, { target: { value: 'morning' } });
|
||||||
|
|
||||||
|
|
@ -389,7 +427,10 @@ describe('RegimensTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when update fails', async () => {
|
it('shows error when update fails', async () => {
|
||||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [regimen],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
mockUpdateRegimen.mockRejectedValue(new Error('Update failed'));
|
mockUpdateRegimen.mockRejectedValue(new Error('Update failed'));
|
||||||
|
|
||||||
render(<RegimensTab householdId="hh1" />);
|
render(<RegimensTab householdId="hh1" />);
|
||||||
|
|
@ -401,7 +442,10 @@ describe('RegimensTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toggles active/inactive status', async () => {
|
it('toggles active/inactive status', async () => {
|
||||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [regimen],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
mockUpdateRegimen.mockResolvedValue({ ...regimen, isActive: false });
|
mockUpdateRegimen.mockResolvedValue({ ...regimen, isActive: false });
|
||||||
|
|
||||||
render(<RegimensTab householdId="hh1" />);
|
render(<RegimensTab householdId="hh1" />);
|
||||||
|
|
@ -452,9 +496,7 @@ describe('RegimensTab', () => {
|
||||||
await waitFor(() => screen.getByDisplayValue('All regimens'));
|
await waitFor(() => screen.getByDisplayValue('All regimens'));
|
||||||
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
|
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText('No active regimens found.')).toBeInTheDocument());
|
||||||
expect(screen.getByText('No active regimens found.')).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows null when form is open and regimens list is empty', async () => {
|
it('shows null when form is open and regimens list is empty', async () => {
|
||||||
|
|
@ -516,7 +558,10 @@ describe('RegimensTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('initializes edit form with existing medications', async () => {
|
it('initializes edit form with existing medications', async () => {
|
||||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [regimen],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
render(<RegimensTab householdId="hh1" />);
|
render(<RegimensTab householdId="hh1" />);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||||
|
}));
|
||||||
vi.mock('@/app/(dashboard)/medicines/ActivityTab', () => ({
|
vi.mock('@/app/(dashboard)/medicines/ActivityTab', () => ({
|
||||||
ActivityTab: ({ householdId }: { householdId: string }) => (
|
ActivityTab: ({ householdId }: { householdId: string }) => (
|
||||||
<div data-testid="activity-tab">{householdId}</div>
|
<div data-testid="activity-tab">{householdId}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,49 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useApi } from '@/lib/useApi';
|
import { useApi } from '@/lib/useApi';
|
||||||
import { ActivityTab } from '../ActivityTab';
|
import { ActivityTab } from '../ActivityTab';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { PageSkeleton, NoHousehold } from '../helpers';
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const { householdId, isLoading: sessionLoading } = useApi();
|
const { householdId, isLoading: sessionLoading } = useApi();
|
||||||
|
|
||||||
if (sessionLoading) {
|
if (sessionLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
|
<SetPageHeader
|
||||||
<div className="animate-pulse space-y-3">
|
title="Cabinet Activity"
|
||||||
<div className="h-40 rounded-xl bg-gray-200" />
|
subtitle="Spending and cabinet changes"
|
||||||
<div className="h-40 rounded-xl bg-gray-200" />
|
crumbs={['Medicines', 'Activity']}
|
||||||
</div>
|
/>
|
||||||
</div>
|
<PageSkeleton />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!householdId) {
|
if (!householdId) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
|
<SetPageHeader
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
title="Cabinet Activity"
|
||||||
<p className="text-gray-500">
|
subtitle="Spending and cabinet changes"
|
||||||
You need to{' '}
|
crumbs={['Medicines', 'Activity']}
|
||||||
<Link href="/settings" className="text-primary-600 underline">
|
/>
|
||||||
create or join a household
|
<NoHousehold />
|
||||||
</Link>{' '}
|
</>
|
||||||
before viewing cabinet activity.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<SetPageHeader
|
||||||
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
|
title="Cabinet Activity"
|
||||||
Medicines
|
subtitle="Spending and cabinet changes"
|
||||||
</Link>
|
crumbs={['Medicines', 'Activity']}
|
||||||
<span className="text-gray-400">/</span>
|
/>
|
||||||
<h1 className="text-2xl font-bold">Cabinet Activity</h1>
|
<div className="mt-page">
|
||||||
|
<ActivityTab householdId={householdId} />
|
||||||
</div>
|
</div>
|
||||||
<ActivityTab householdId={householdId} />
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||||
|
}));
|
||||||
vi.mock('@/app/(dashboard)/medicines/CabinetTab', () => ({
|
vi.mock('@/app/(dashboard)/medicines/CabinetTab', () => ({
|
||||||
CabinetTab: ({ householdId }: { householdId: string }) => (
|
CabinetTab: ({ householdId }: { householdId: string }) => (
|
||||||
<div data-testid="cabinet-tab">{householdId}</div>
|
<div data-testid="cabinet-tab">{householdId}</div>
|
||||||
|
|
|
||||||
|
|
@ -3,39 +3,77 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useApi } from '@/lib/useApi';
|
import { useApi } from '@/lib/useApi';
|
||||||
import { CabinetTab } from '../CabinetTab';
|
import { CabinetTab } from '../CabinetTab';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
|
||||||
export default function CabinetPage() {
|
export default function CabinetPage() {
|
||||||
const { householdId, isLoading: sessionLoading } = useApi();
|
const { householdId, isLoading: sessionLoading } = useApi();
|
||||||
|
|
||||||
if (sessionLoading) {
|
if (sessionLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Medicine Cabinet</h1>
|
<SetPageHeader
|
||||||
<div className="animate-pulse space-y-3">
|
title="Medicine Cabinet"
|
||||||
<div className="h-10 w-64 rounded-lg bg-gray-200" />
|
subtitle="Everything on hand, with days of supply"
|
||||||
<div className="h-20 rounded-xl bg-gray-200" />
|
crumbs={['Medicines', 'Cabinet']}
|
||||||
<div className="h-20 rounded-xl bg-gray-200" />
|
/>
|
||||||
|
<div style={{ padding: '28px 32px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
background: 'var(--bg-inset)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!householdId) {
|
if (!householdId) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Medicine Cabinet</h1>
|
<SetPageHeader
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
title="Medicine Cabinet"
|
||||||
<p className="text-gray-500">
|
subtitle="Everything on hand, with days of supply"
|
||||||
You need to{' '}
|
crumbs={['Medicines', 'Cabinet']}
|
||||||
<Link href="/settings" className="text-primary-600 underline">
|
/>
|
||||||
create or join a household
|
<div style={{ padding: '28px 32px' }}>
|
||||||
</Link>{' '}
|
<div
|
||||||
before managing medicines.
|
style={{
|
||||||
</p>
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
You need to{' '}
|
||||||
|
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
|
||||||
|
create or join a household
|
||||||
|
</Link>{' '}
|
||||||
|
before managing medicines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CabinetTab householdId={householdId} />;
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader
|
||||||
|
title="Medicine Cabinet"
|
||||||
|
subtitle="Everything on hand, with days of supply"
|
||||||
|
crumbs={['Medicines', 'Cabinet']}
|
||||||
|
/>
|
||||||
|
<div className="mt-page">
|
||||||
|
<CabinetTab householdId={householdId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
packages/web/src/app/(dashboard)/medicines/helpers.tsx
Normal file
43
packages/web/src/app/(dashboard)/medicines/helpers.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
background: 'var(--bg-inset)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoHousehold() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
You need to{' '}
|
||||||
|
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
|
||||||
|
create or join a household
|
||||||
|
</Link>{' '}
|
||||||
|
before managing medicines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||||
|
}));
|
||||||
vi.mock('@/app/(dashboard)/medicines/LibraryTab', () => ({
|
vi.mock('@/app/(dashboard)/medicines/LibraryTab', () => ({
|
||||||
LibraryTab: ({ householdId }: { householdId: string }) => (
|
LibraryTab: ({ householdId }: { householdId: string }) => (
|
||||||
<div data-testid="library-tab">{householdId}</div>
|
<div data-testid="library-tab">{householdId}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,49 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useApi } from '@/lib/useApi';
|
import { useApi } from '@/lib/useApi';
|
||||||
import { LibraryTab } from '../LibraryTab';
|
import { LibraryTab } from '../LibraryTab';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { PageSkeleton, NoHousehold } from '../helpers';
|
||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
const { householdId, isLoading: sessionLoading } = useApi();
|
const { householdId, isLoading: sessionLoading } = useApi();
|
||||||
|
|
||||||
if (sessionLoading) {
|
if (sessionLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Medicine Library</h1>
|
<SetPageHeader
|
||||||
<div className="animate-pulse space-y-3">
|
title="Medicine Library"
|
||||||
<div className="h-10 w-64 rounded-lg bg-gray-200" />
|
subtitle="All known medicines"
|
||||||
<div className="h-20 rounded-xl bg-gray-200" />
|
crumbs={['Medicines', 'Library']}
|
||||||
<div className="h-20 rounded-xl bg-gray-200" />
|
/>
|
||||||
</div>
|
<PageSkeleton />
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!householdId) {
|
if (!householdId) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Medicine Library</h1>
|
<SetPageHeader
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
title="Medicine Library"
|
||||||
<p className="text-gray-500">
|
subtitle="All known medicines"
|
||||||
You need to{' '}
|
crumbs={['Medicines', 'Library']}
|
||||||
<Link href="/settings" className="text-primary-600 underline">
|
/>
|
||||||
create or join a household
|
<NoHousehold />
|
||||||
</Link>{' '}
|
</>
|
||||||
before managing medicines.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <LibraryTab householdId={householdId} />;
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader
|
||||||
|
title="Medicine Library"
|
||||||
|
subtitle="All known medicines"
|
||||||
|
crumbs={['Medicines', 'Library']}
|
||||||
|
/>
|
||||||
|
<div className="mt-page">
|
||||||
|
<LibraryTab householdId={householdId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||||
|
}));
|
||||||
vi.mock('@/app/(dashboard)/medicines/OrganizerTab', () => ({
|
vi.mock('@/app/(dashboard)/medicines/OrganizerTab', () => ({
|
||||||
OrganizerTab: ({ householdId }: { householdId: string }) => (
|
OrganizerTab: ({ householdId }: { householdId: string }) => (
|
||||||
<div data-testid="organizer-tab">{householdId}</div>
|
<div data-testid="organizer-tab">{householdId}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,49 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useApi } from '@/lib/useApi';
|
import { useApi } from '@/lib/useApi';
|
||||||
import { OrganizerTab } from '../OrganizerTab';
|
import { OrganizerTab } from '../OrganizerTab';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { PageSkeleton, NoHousehold } from '../helpers';
|
||||||
|
|
||||||
export default function OrganizerPage() {
|
export default function OrganizerPage() {
|
||||||
const { householdId, isLoading: sessionLoading } = useApi();
|
const { householdId, isLoading: sessionLoading } = useApi();
|
||||||
|
|
||||||
if (sessionLoading) {
|
if (sessionLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
|
<SetPageHeader
|
||||||
<div className="animate-pulse space-y-3">
|
title="Pill Organizer"
|
||||||
<div className="h-40 rounded-xl bg-gray-200" />
|
subtitle="Fill a week of pills at once"
|
||||||
<div className="h-40 rounded-xl bg-gray-200" />
|
crumbs={['Medicines', 'Organizer']}
|
||||||
</div>
|
/>
|
||||||
</div>
|
<PageSkeleton />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!householdId) {
|
if (!householdId) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
|
<SetPageHeader
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
title="Pill Organizer"
|
||||||
<p className="text-gray-500">
|
subtitle="Fill a week of pills at once"
|
||||||
You need to{' '}
|
crumbs={['Medicines', 'Organizer']}
|
||||||
<Link href="/settings" className="text-primary-600 underline">
|
/>
|
||||||
create or join a household
|
<NoHousehold />
|
||||||
</Link>{' '}
|
</>
|
||||||
before using the pill organizer.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<SetPageHeader
|
||||||
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
|
title="Pill Organizer"
|
||||||
Medicines
|
subtitle="Fill a week of pills at once"
|
||||||
</Link>
|
crumbs={['Medicines', 'Organizer']}
|
||||||
<span className="text-gray-400">/</span>
|
/>
|
||||||
<h1 className="text-2xl font-bold">Pill Organizer</h1>
|
<div className="mt-page">
|
||||||
|
<OrganizerTab householdId={householdId} />
|
||||||
</div>
|
</div>
|
||||||
<OrganizerTab householdId={householdId} />
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,82 +2,122 @@
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useApi } from '@/lib/useApi';
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { Icon } from '@/components/ui/Icon';
|
||||||
|
import type { IconName } from '@/components/ui/Icon';
|
||||||
|
|
||||||
export default function MedicinesPage() {
|
export default function MedicinesPage() {
|
||||||
const { householdId, isLoading: sessionLoading } = useApi();
|
const { householdId, isLoading: sessionLoading } = useApi();
|
||||||
|
|
||||||
if (sessionLoading) {
|
if (sessionLoading) {
|
||||||
return <PageSkeleton />;
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Medicines" subtitle="All known medicines" />
|
||||||
|
<PageSkeleton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!householdId) {
|
if (!householdId) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Medicines</h1>
|
<SetPageHeader title="Medicines" subtitle="All known medicines" />
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div style={{ padding: '28px 32px' }}>
|
||||||
<p className="text-gray-500">
|
<div
|
||||||
You need to{' '}
|
style={{
|
||||||
<Link href="/settings" className="text-primary-600 underline">
|
background: 'var(--bg-elev)',
|
||||||
create or join a household
|
border: '1px solid var(--border)',
|
||||||
</Link>{' '}
|
borderRadius: 'var(--r-md)',
|
||||||
before managing medicines.
|
padding: 24,
|
||||||
</p>
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
You need to{' '}
|
||||||
|
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
|
||||||
|
create or join a household
|
||||||
|
</Link>{' '}
|
||||||
|
before managing medicines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-6">Medicines</h1>
|
<SetPageHeader title="Medicines" subtitle="All known medicines" />
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div style={{ padding: '28px 32px 56px', maxWidth: 1400 }}>
|
||||||
<SectionCard
|
<div
|
||||||
title="Library"
|
style={{
|
||||||
description="Manage your medicines and their products"
|
display: 'grid',
|
||||||
href="/medicines/library"
|
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||||
/>
|
gap: 14,
|
||||||
<SectionCard
|
}}
|
||||||
title="Cabinet"
|
>
|
||||||
description="Track your medicine inventory, quantities and expiry dates"
|
<SectionCard
|
||||||
href="/medicines/cabinet"
|
title="Library"
|
||||||
/>
|
description="Manage your medicines and their products"
|
||||||
<SectionCard
|
href="/medicines/library"
|
||||||
title="Regimens"
|
icon="pill"
|
||||||
description="Define daily medication schedules and track dosage frequency"
|
/>
|
||||||
href="/medicines/regimens"
|
<SectionCard
|
||||||
/>
|
title="Cabinet"
|
||||||
<SectionCard
|
description="Track your medicine inventory, quantities and expiry dates"
|
||||||
title="Organizer"
|
href="/medicines/cabinet"
|
||||||
description="Fill your pill organizer and track cabinet usage"
|
icon="cabinet"
|
||||||
href="/medicines/organizer"
|
/>
|
||||||
/>
|
<SectionCard
|
||||||
<SectionCard
|
title="Schedule"
|
||||||
title="Activity"
|
description="Today's dose log and weekly overview"
|
||||||
description="View cabinet event history and spending summaries"
|
href="/medicines/schedule"
|
||||||
href="/medicines/activity"
|
icon="clock"
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Stores"
|
title="Regimens"
|
||||||
description="Manage pharmacies and stores for price tracking"
|
description="Define daily medication schedules"
|
||||||
href="/stores"
|
href="/medicines/regimens"
|
||||||
/>
|
icon="list"
|
||||||
<SectionCard
|
/>
|
||||||
title="Prices"
|
<SectionCard
|
||||||
description="Track and compare medicine prices across stores"
|
title="Organizer"
|
||||||
href="/medicine-prices"
|
description="Fill your pill organizer and track cabinet usage"
|
||||||
/>
|
href="/medicines/organizer"
|
||||||
<SectionCard
|
icon="calendar"
|
||||||
title="Refills"
|
/>
|
||||||
description="Get refill alerts and manage shopping lists"
|
<SectionCard
|
||||||
href="/refills"
|
title="Activity"
|
||||||
/>
|
description="View cabinet event history and spending summaries"
|
||||||
<SectionCard
|
href="/medicines/activity"
|
||||||
title="Purchases"
|
icon="trend"
|
||||||
description="Record medicine purchases and track online orders"
|
/>
|
||||||
href="/purchases"
|
<SectionCard
|
||||||
/>
|
title="Stores"
|
||||||
|
description="Manage pharmacies and stores for price tracking"
|
||||||
|
href="/stores"
|
||||||
|
icon="store"
|
||||||
|
/>
|
||||||
|
<SectionCard
|
||||||
|
title="Prices"
|
||||||
|
description="Track and compare medicine prices across stores"
|
||||||
|
href="/medicine-prices"
|
||||||
|
icon="tag"
|
||||||
|
/>
|
||||||
|
<SectionCard
|
||||||
|
title="Refills"
|
||||||
|
description="Get refill alerts and manage shopping lists"
|
||||||
|
href="/refills"
|
||||||
|
icon="refresh"
|
||||||
|
/>
|
||||||
|
<SectionCard
|
||||||
|
title="Purchases"
|
||||||
|
description="Record medicine purchases and track online orders"
|
||||||
|
href="/purchases"
|
||||||
|
icon="truck"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,29 +125,64 @@ function SectionCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
href,
|
href,
|
||||||
|
icon,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
href: string;
|
href: string;
|
||||||
|
icon: IconName;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className="rounded-xl border bg-white p-6 shadow-sm hover:shadow-md transition-shadow"
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 10,
|
||||||
|
padding: 18,
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
<div
|
||||||
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
background: 'var(--brand-soft)',
|
||||||
|
color: 'var(--brand)',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={icon} size={16} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-strong)' }}>{title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 2 }}>{description}</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageSkeleton() {
|
function PageSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ padding: '28px 32px' }}>
|
||||||
<h1 className="text-2xl font-bold mb-6">Medicines</h1>
|
<div
|
||||||
<div className="animate-pulse grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{Array.from({ length: 9 }).map((_, i) => (
|
{Array.from({ length: 9 }).map((_, i) => (
|
||||||
<div key={i} className="h-24 rounded-xl bg-gray-200" />
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ height: 96, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||||
|
}));
|
||||||
vi.mock('@/app/(dashboard)/medicines/RegimensTab', () => ({
|
vi.mock('@/app/(dashboard)/medicines/RegimensTab', () => ({
|
||||||
RegimensTab: ({ householdId }: { householdId: string }) => (
|
RegimensTab: ({ householdId }: { householdId: string }) => (
|
||||||
<div data-testid="regimens-tab">{householdId}</div>
|
<div data-testid="regimens-tab">{householdId}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,49 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useApi } from '@/lib/useApi';
|
import { useApi } from '@/lib/useApi';
|
||||||
import { RegimensTab } from '../RegimensTab';
|
import { RegimensTab } from '../RegimensTab';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { PageSkeleton, NoHousehold } from '../helpers';
|
||||||
|
|
||||||
export default function RegimensPage() {
|
export default function RegimensPage() {
|
||||||
const { householdId, isLoading: sessionLoading } = useApi();
|
const { householdId, isLoading: sessionLoading } = useApi();
|
||||||
|
|
||||||
if (sessionLoading) {
|
if (sessionLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
|
<SetPageHeader
|
||||||
<div className="animate-pulse space-y-3">
|
title="Regimens"
|
||||||
<div className="h-10 w-64 rounded-lg bg-gray-200" />
|
subtitle="Daily medication schedules"
|
||||||
<div className="h-24 rounded-xl bg-gray-200" />
|
crumbs={['Medicines', 'Regimens']}
|
||||||
<div className="h-24 rounded-xl bg-gray-200" />
|
/>
|
||||||
</div>
|
<PageSkeleton />
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!householdId) {
|
if (!householdId) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
|
<SetPageHeader
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
title="Regimens"
|
||||||
<p className="text-gray-500">
|
subtitle="Daily medication schedules"
|
||||||
You need to{' '}
|
crumbs={['Medicines', 'Regimens']}
|
||||||
<Link href="/settings" className="text-primary-600 underline">
|
/>
|
||||||
create or join a household
|
<NoHousehold />
|
||||||
</Link>{' '}
|
</>
|
||||||
before managing regimens.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<SetPageHeader
|
||||||
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
|
title="Regimens"
|
||||||
Medicines
|
subtitle="Daily medication schedules"
|
||||||
</Link>
|
crumbs={['Medicines', 'Regimens']}
|
||||||
<span className="text-gray-400">/</span>
|
/>
|
||||||
<h1 className="text-2xl font-bold">Regimens</h1>
|
<div className="mt-page">
|
||||||
|
<RegimensTab householdId={householdId} />
|
||||||
</div>
|
</div>
|
||||||
<RegimensTab householdId={householdId} />
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
297
packages/web/src/app/(dashboard)/medicines/schedule/page.tsx
Normal file
297
packages/web/src/app/(dashboard)/medicines/schedule/page.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { listRegimens } from '@/services/regimens';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { Card, CardHeader } from '@/components/ui/Card';
|
||||||
|
import { Icon } from '@/components/ui/Icon';
|
||||||
|
import { PageSkeleton, NoHousehold } from '../helpers';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { z } from 'zod/v4';
|
||||||
|
import type { RegimenResponseSchema } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
type Regimen = z.infer<typeof RegimenResponseSchema>;
|
||||||
|
type Medication = Regimen['medications'][number];
|
||||||
|
|
||||||
|
const TIME_SLOTS = [
|
||||||
|
{ key: 'morning', label: 'Morning', icon: 'sun' as const },
|
||||||
|
{ key: 'afternoon', label: 'Afternoon', icon: 'sun' as const },
|
||||||
|
{ key: 'evening', label: 'Evening', icon: 'moon' as const },
|
||||||
|
{ key: 'bedtime', label: 'Bedtime', icon: 'moon' as const },
|
||||||
|
{ key: 'any', label: 'Any time', icon: 'clock' as const },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const FREQUENCY_LABELS: Record<string, string> = {
|
||||||
|
daily: 'Once daily',
|
||||||
|
twice_daily: 'Twice daily',
|
||||||
|
three_times_daily: 'Three times daily',
|
||||||
|
weekly: 'Weekly',
|
||||||
|
every_other_day: 'Every other day',
|
||||||
|
as_needed: 'As needed',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
type SlotEntry = { regimen: Regimen; medication: Medication };
|
||||||
|
|
||||||
|
function groupByTimeSlot(regimens: Regimen[]): Record<string, SlotEntry[]> {
|
||||||
|
const groups: Record<string, SlotEntry[]> = {
|
||||||
|
morning: [],
|
||||||
|
afternoon: [],
|
||||||
|
evening: [],
|
||||||
|
bedtime: [],
|
||||||
|
any: [],
|
||||||
|
};
|
||||||
|
for (const regimen of regimens) {
|
||||||
|
for (const medication of regimen.medications) {
|
||||||
|
const slot = medication.timeOfDay ?? 'any';
|
||||||
|
if (slot in groups) {
|
||||||
|
groups[slot].push({ regimen, medication });
|
||||||
|
} else {
|
||||||
|
groups.any.push({ regimen, medication });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MedicationCard({ regimen, medication }: SlotEntry) {
|
||||||
|
const freqLabel =
|
||||||
|
medication.frequency === 'custom' && medication.customFrequencyPerDay
|
||||||
|
? `${medication.customFrequencyPerDay}x daily`
|
||||||
|
: (FREQUENCY_LABELS[medication.frequency] ?? medication.frequency);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
background: 'var(--brand-soft)',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
color: 'var(--brand)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="pill" size={18} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-strong)' }}>
|
||||||
|
{medication.medicineName}{' '}
|
||||||
|
<span style={{ fontWeight: 400, color: 'var(--ink-muted)' }}>
|
||||||
|
{medication.medicineStrength} {medication.medicineStrengthUnit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--ink-muted)', marginTop: 2 }}>
|
||||||
|
{medication.dosage} {medication.dosageUnit} — {freqLabel}
|
||||||
|
</div>
|
||||||
|
{medication.instructions && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--ink-faint)', marginTop: 2 }}>
|
||||||
|
{medication.instructions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--ink-faint)', marginTop: 4 }}>
|
||||||
|
<span className="mt-pill mt-pill--ghost">{regimen.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeSlotCard({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
entries,
|
||||||
|
}: {
|
||||||
|
slotKey: string;
|
||||||
|
label: string;
|
||||||
|
icon: 'sun' | 'moon' | 'clock';
|
||||||
|
entries: SlotEntry[];
|
||||||
|
}) {
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
<CardHeader
|
||||||
|
title={label}
|
||||||
|
subtitle={`${entries.length} dose${entries.length !== 1 ? 's' : ''}`}
|
||||||
|
action={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
background: 'var(--bg-inset)',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={icon} size={16} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
{entries.map(({ regimen, medication }, i) => (
|
||||||
|
<MedicationCard
|
||||||
|
key={`${regimen._id}-${medication.medicineId}-${i}`}
|
||||||
|
regimen={regimen}
|
||||||
|
medication={medication}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleContent({ householdId }: { householdId: string }) {
|
||||||
|
const [regimens, setRegimens] = useState<Regimen[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const allRegimens: Regimen[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
do {
|
||||||
|
const res = await listRegimens(householdId, {
|
||||||
|
isActive: true,
|
||||||
|
limit: 100,
|
||||||
|
...(cursor ? { cursor } : {}),
|
||||||
|
});
|
||||||
|
allRegimens.push(...res.data);
|
||||||
|
cursor = res.pagination.cursor;
|
||||||
|
} while (cursor);
|
||||||
|
if (!cancelled) setRegimens(allRegimens);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load regimens');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [householdId]);
|
||||||
|
|
||||||
|
if (loading) return <PageSkeleton />;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="mt-alert mt-alert--danger mb-4">{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regimens.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '48px 24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
background: 'var(--bg-inset)',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
color: 'var(--ink-faint)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="clock" size={24} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 15, color: 'var(--ink-muted)' }}>No active regimens found.</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--ink-faint)' }}>
|
||||||
|
<Link href="/medicines/regimens" className="mt-link">
|
||||||
|
Set up a regimen
|
||||||
|
</Link>{' '}
|
||||||
|
to start tracking your daily schedule.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupByTimeSlot(regimens);
|
||||||
|
const totalDoses = Object.values(groups).reduce((sum, g) => sum + g.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 16, fontSize: 13, color: 'var(--ink-muted)' }}>
|
||||||
|
{regimens.length} active regimen{regimens.length !== 1 ? 's' : ''} — {totalDoses} dose
|
||||||
|
{totalDoses !== 1 ? 's' : ''} per day
|
||||||
|
</div>
|
||||||
|
{TIME_SLOTS.map(({ key, label, icon }) => (
|
||||||
|
<TimeSlotCard
|
||||||
|
key={key}
|
||||||
|
slotKey={key}
|
||||||
|
label={label}
|
||||||
|
icon={icon}
|
||||||
|
entries={groups[key] ?? []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SchedulePage() {
|
||||||
|
const { householdId, isLoading: sessionLoading } = useApi();
|
||||||
|
|
||||||
|
if (sessionLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader
|
||||||
|
title="Schedule & Log"
|
||||||
|
subtitle="Today and this week"
|
||||||
|
crumbs={['Medicines', 'Schedule']}
|
||||||
|
/>
|
||||||
|
<PageSkeleton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!householdId) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader
|
||||||
|
title="Schedule & Log"
|
||||||
|
subtitle="Today and this week"
|
||||||
|
crumbs={['Medicines', 'Schedule']}
|
||||||
|
/>
|
||||||
|
<NoHousehold />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader
|
||||||
|
title="Schedule & Log"
|
||||||
|
subtitle="Today and this week"
|
||||||
|
crumbs={['Medicines', 'Schedule']}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: '28px 32px 56px', maxWidth: 900 }}>
|
||||||
|
<ScheduleContent householdId={householdId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
|
||||||
|
|
@ -30,7 +31,9 @@ vi.mock('@/services/medicines', () => ({
|
||||||
listMedicines: mockListMedicines,
|
listMedicines: mockListMedicines,
|
||||||
listMedicineProducts: mockListMedicineProducts,
|
listMedicineProducts: mockListMedicineProducts,
|
||||||
}));
|
}));
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||||
|
}));
|
||||||
|
|
||||||
import PurchasesPage from '../page';
|
import PurchasesPage from '../page';
|
||||||
|
|
||||||
|
|
@ -61,9 +64,7 @@ describe('PurchasesPage', () => {
|
||||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||||
render(<PurchasesPage />);
|
render(<PurchasesPage />);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText(/No purchases recorded yet/)).toBeInTheDocument());
|
||||||
expect(screen.getByText(/No purchases recorded yet/)).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Record Purchase button', async () => {
|
it('shows Record Purchase button', async () => {
|
||||||
|
|
@ -142,7 +143,15 @@ describe('PurchasesPage', () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||||
mockListStores.mockResolvedValue({
|
mockListStores.mockResolvedValue({
|
||||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'st-1',
|
||||||
|
name: 'Walgreens',
|
||||||
|
tags: [],
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -162,7 +171,15 @@ describe('PurchasesPage', () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||||
mockListStores.mockResolvedValue({
|
mockListStores.mockResolvedValue({
|
||||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'st-1',
|
||||||
|
name: 'Walgreens',
|
||||||
|
tags: [],
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -176,7 +193,9 @@ describe('PurchasesPage', () => {
|
||||||
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
|
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText('Add at least one item with a name and quantity.')).toBeInTheDocument(),
|
expect(
|
||||||
|
screen.getByText('Add at least one item with a name and quantity.'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -293,7 +312,15 @@ describe('PurchasesPage', () => {
|
||||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||||
mockListMedicineProducts.mockResolvedValue(emptyResponse);
|
mockListMedicineProducts.mockResolvedValue(emptyResponse);
|
||||||
mockListStores.mockResolvedValue({
|
mockListStores.mockResolvedValue({
|
||||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'st-1',
|
||||||
|
name: 'Walgreens',
|
||||||
|
tags: [],
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockCreatePurchase.mockResolvedValue({
|
mockCreatePurchase.mockResolvedValue({
|
||||||
|
|
@ -314,7 +341,9 @@ describe('PurchasesPage', () => {
|
||||||
|
|
||||||
await waitFor(() => screen.getByText('Save Purchase'));
|
await waitFor(() => screen.getByText('Save Purchase'));
|
||||||
fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } });
|
fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } });
|
||||||
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), { target: { value: 'Aspirin' } });
|
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), {
|
||||||
|
target: { value: 'Aspirin' },
|
||||||
|
});
|
||||||
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
|
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
|
||||||
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
|
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
|
||||||
|
|
||||||
|
|
@ -457,7 +486,9 @@ describe('PurchasesPage', () => {
|
||||||
await userEvent.click(screen.getByText('Record Purchase'));
|
await userEvent.click(screen.getByText('Record Purchase'));
|
||||||
|
|
||||||
await waitFor(() => screen.getByPlaceholderText('Brand / product name'));
|
await waitFor(() => screen.getByPlaceholderText('Brand / product name'));
|
||||||
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), { target: { value: 'Aspirin' } });
|
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), {
|
||||||
|
target: { value: 'Aspirin' },
|
||||||
|
});
|
||||||
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
|
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
|
||||||
fireEvent.change(screen.getByPlaceholderText('tablet'), { target: { value: 'capsule' } });
|
fireEvent.change(screen.getByPlaceholderText('tablet'), { target: { value: 'capsule' } });
|
||||||
|
|
||||||
|
|
@ -472,9 +503,7 @@ describe('PurchasesPage', () => {
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
mockListMedicineProducts.mockResolvedValue({
|
mockListMedicineProducts.mockResolvedValue({
|
||||||
data: [
|
data: [{ _id: 'prod-1', brand: 'Glucophage', packageSize: 60, packageUnit: 'tablet' }],
|
||||||
{ _id: 'prod-1', brand: 'Glucophage', packageSize: 60, packageUnit: 'tablet' },
|
|
||||||
],
|
|
||||||
pagination: { cursor: null, hasMore: false },
|
pagination: { cursor: null, hasMore: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useApi } from '@/lib/useApi';
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
import {
|
import {
|
||||||
listPurchases,
|
listPurchases,
|
||||||
createPurchase,
|
createPurchase,
|
||||||
|
|
@ -12,10 +13,7 @@ import {
|
||||||
import { listStores } from '@/services/stores';
|
import { listStores } from '@/services/stores';
|
||||||
import { listMedicines, listMedicineProducts } from '@/services/medicines';
|
import { listMedicines, listMedicineProducts } from '@/services/medicines';
|
||||||
import type { z } from 'zod/v4';
|
import type { z } from 'zod/v4';
|
||||||
import type {
|
import type { PurchaseResponseSchema } from '@meshitrack/shared';
|
||||||
PurchaseResponseSchema,
|
|
||||||
PurchaseListResponseSchema,
|
|
||||||
} from '@meshitrack/shared';
|
|
||||||
|
|
||||||
type PurchaseResponse = z.infer<typeof PurchaseResponseSchema>;
|
type PurchaseResponse = z.infer<typeof PurchaseResponseSchema>;
|
||||||
|
|
||||||
|
|
@ -75,13 +73,21 @@ function CreatePurchaseForm({
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listMedicines(householdId, { limit: 100 }).then((r) => setMedicines(r.data)).catch(() => {});
|
listMedicines(householdId, { limit: 100 })
|
||||||
|
.then((r) => setMedicines(r.data))
|
||||||
|
.catch(() => {});
|
||||||
}, [householdId]);
|
}, [householdId]);
|
||||||
|
|
||||||
async function handleMedicineChange(idx: number, medicineId: string) {
|
async function handleMedicineChange(idx: number, medicineId: string) {
|
||||||
const updated = items.map((item, i) =>
|
const updated = items.map((item, i) =>
|
||||||
i === idx
|
i === idx
|
||||||
? { ...item, medicineId, medicineProductId: '', products: [], productsLoading: !!medicineId }
|
? {
|
||||||
|
...item,
|
||||||
|
medicineId,
|
||||||
|
medicineProductId: '',
|
||||||
|
products: [],
|
||||||
|
productsLoading: !!medicineId,
|
||||||
|
}
|
||||||
: item,
|
: item,
|
||||||
);
|
);
|
||||||
setItems(updated);
|
setItems(updated);
|
||||||
|
|
@ -90,7 +96,9 @@ function CreatePurchaseForm({
|
||||||
const result = await listMedicineProducts(householdId, medicineId, { limit: 50 });
|
const result = await listMedicineProducts(householdId, medicineId, { limit: 50 });
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((item, i) =>
|
prev.map((item, i) =>
|
||||||
i === idx ? { ...item, products: result.data as ProductOption[], productsLoading: false } : item,
|
i === idx
|
||||||
|
? { ...item, products: result.data as ProductOption[], productsLoading: false }
|
||||||
|
: item,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -109,7 +117,11 @@ function CreatePurchaseForm({
|
||||||
...item,
|
...item,
|
||||||
medicineProductId: productId,
|
medicineProductId: productId,
|
||||||
...(product
|
...(product
|
||||||
? { quantity: String(product.packageSize), unit: product.packageUnit, name: product.brand ?? item.name }
|
? {
|
||||||
|
quantity: String(product.packageSize),
|
||||||
|
unit: product.packageUnit,
|
||||||
|
name: product.brand ?? item.name,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
@ -173,22 +185,18 @@ function CreatePurchaseForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card mb-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Record Purchase</h2>
|
<h2 className="text-lg font-semibold mb-4">Record Purchase</h2>
|
||||||
{error && (
|
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Store</label>
|
<label className="mt-field-label">Store</label>
|
||||||
<select
|
<select
|
||||||
value={storeId}
|
value={storeId}
|
||||||
onChange={(e) => setStoreId(e.target.value)}
|
onChange={(e) => setStoreId(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
<option value="">Select store</option>
|
<option value="">Select store</option>
|
||||||
{stores.map((s) => (
|
{stores.map((s) => (
|
||||||
|
|
@ -200,7 +208,7 @@ function CreatePurchaseForm({
|
||||||
{stores.length === 0 && (
|
{stores.length === 0 && (
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
No stores yet.{' '}
|
No stores yet.{' '}
|
||||||
<Link href="/stores" className="text-primary-600 underline">
|
<Link href="/stores" className="mt-link">
|
||||||
Add a store first
|
Add a store first
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -213,7 +221,7 @@ function CreatePurchaseForm({
|
||||||
id="isOnline"
|
id="isOnline"
|
||||||
checked={isOnline}
|
checked={isOnline}
|
||||||
onChange={(e) => setIsOnline(e.target.checked)}
|
onChange={(e) => setIsOnline(e.target.checked)}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="isOnline" className="text-sm font-medium text-gray-700">
|
<label htmlFor="isOnline" className="text-sm font-medium text-gray-700">
|
||||||
Online order (pending arrival)
|
Online order (pending arrival)
|
||||||
|
|
@ -222,26 +230,20 @@ function CreatePurchaseForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="mt-field-label">Notes (optional)</label>
|
||||||
Notes (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-sm font-semibold text-gray-700">Items</h3>
|
<h3 className="text-sm font-semibold text-gray-700">Items</h3>
|
||||||
<button
|
<button type="button" onClick={addItem} className="mt-btn mt-btn--ghost">
|
||||||
type="button"
|
|
||||||
onClick={addItem}
|
|
||||||
className="rounded-lg border px-3 py-1 text-xs font-medium hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Add item
|
Add item
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -255,7 +257,7 @@ function CreatePurchaseForm({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeItem(idx)}
|
onClick={() => removeItem(idx)}
|
||||||
className="text-xs text-red-500 hover:text-red-700"
|
className="mt-link text-xs"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -264,13 +266,11 @@ function CreatePurchaseForm({
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
<label className="mt-field-label">Medicine (optional)</label>
|
||||||
Medicine (optional)
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={item.medicineId}
|
value={item.medicineId}
|
||||||
onChange={(e) => handleMedicineChange(idx, e.target.value)}
|
onChange={(e) => handleMedicineChange(idx, e.target.value)}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
<option value="">Select medicine</option>
|
<option value="">Select medicine</option>
|
||||||
{medicines.map((m) => (
|
{medicines.map((m) => (
|
||||||
|
|
@ -282,9 +282,7 @@ function CreatePurchaseForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
<label className="mt-field-label">Product (optional)</label>
|
||||||
Product (optional)
|
|
||||||
</label>
|
|
||||||
{item.productsLoading ? (
|
{item.productsLoading ? (
|
||||||
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
|
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -292,7 +290,7 @@ function CreatePurchaseForm({
|
||||||
value={item.medicineProductId}
|
value={item.medicineProductId}
|
||||||
onChange={(e) => handleProductChange(idx, e.target.value)}
|
onChange={(e) => handleProductChange(idx, e.target.value)}
|
||||||
disabled={!item.medicineId}
|
disabled={!item.medicineId}
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
|
className="mt-field"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{item.medicineId ? 'Select product' : 'Select medicine first'}
|
{item.medicineId ? 'Select product' : 'Select medicine first'}
|
||||||
|
|
@ -309,9 +307,7 @@ function CreatePurchaseForm({
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
<label className="mt-field-label">Name</label>
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
|
@ -319,20 +315,16 @@ function CreatePurchaseForm({
|
||||||
value={item.name}
|
value={item.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((it, i) =>
|
prev.map((it, i) => (i === idx ? { ...it, name: e.target.value } : it)),
|
||||||
i === idx ? { ...it, name: e.target.value } : it,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="Brand / product name"
|
placeholder="Brand / product name"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
<label className="mt-field-label">Package size</label>
|
||||||
Package size
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
|
|
@ -347,36 +339,30 @@ function CreatePurchaseForm({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="90"
|
placeholder="90"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
<label className="mt-field-label">Unit</label>
|
||||||
Unit
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={item.unit}
|
value={item.unit}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((it, i) =>
|
prev.map((it, i) => (i === idx ? { ...it, unit: e.target.value } : it)),
|
||||||
i === idx ? { ...it, unit: e.target.value } : it,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="tablet"
|
placeholder="tablet"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
<label className="mt-field-label">Price (optional)</label>
|
||||||
Price (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
|
@ -390,13 +376,11 @@ function CreatePurchaseForm({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="9.99"
|
placeholder="9.99"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
<label className="mt-field-label">Currency</label>
|
||||||
Currency
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
|
|
@ -409,7 +393,7 @@ function CreatePurchaseForm({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="USD"
|
placeholder="USD"
|
||||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -419,18 +403,10 @@ function CreatePurchaseForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button
|
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{submitting ? 'Saving...' : 'Save Purchase'}
|
{submitting ? 'Saving...' : 'Save Purchase'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -453,19 +429,13 @@ function PurchaseCard({
|
||||||
const isOrdered = purchase.status === 'ordered';
|
const isOrdered = purchase.status === 'ordered';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
<div className={`mt-card ${!isOrdered ? '' : ''}`}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900">{purchase.storeName}</p>
|
<p className="font-semibold text-gray-900">{purchase.storeName}</p>
|
||||||
<p className="text-xs text-gray-400 mt-0.5">{formatDate(purchase.purchasedAt)}</p>
|
<p className="text-xs text-gray-400 mt-0.5">{formatDate(purchase.purchasedAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className={`mt-pill ${isOrdered ? 'mt-pill--warn' : 'mt-pill--ok'}`}>
|
||||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
|
||||||
isOrdered
|
|
||||||
? 'bg-amber-100 text-amber-700'
|
|
||||||
: 'bg-green-100 text-green-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isOrdered ? 'Pending' : 'Received'}
|
{isOrdered ? 'Pending' : 'Received'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -476,31 +446,24 @@ function PurchaseCard({
|
||||||
<span className="text-gray-700">{item.name}</span>
|
<span className="text-gray-700">{item.name}</span>
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
{item.quantity} {item.unit}
|
{item.quantity} {item.unit}
|
||||||
{item.actualPrice != null && ` — ${item.actualPrice.toFixed(2)} ${item.currency ?? ''}`}
|
{item.actualPrice != null &&
|
||||||
|
` — ${item.actualPrice.toFixed(2)} ${item.currency ?? ''}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{purchase.notes && (
|
{purchase.notes && <p className="mt-2 text-xs text-gray-400 italic">{purchase.notes}</p>}
|
||||||
<p className="mt-2 text-xs text-gray-400 italic">{purchase.notes}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isOrdered || onDelete) && (
|
{(isOrdered || onDelete) && (
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
{isOrdered && onReceive && (
|
{isOrdered && onReceive && (
|
||||||
<button
|
<button onClick={() => onReceive(purchase._id)} className="mt-btn mt-btn--primary">
|
||||||
onClick={() => onReceive(purchase._id)}
|
|
||||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 transition-colors"
|
|
||||||
>
|
|
||||||
Mark as received
|
Mark as received
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isOrdered && onDelete && (
|
{isOrdered && onDelete && (
|
||||||
<button
|
<button onClick={() => onDelete(purchase._id)} className="mt-btn mt-btn--ghost">
|
||||||
onClick={() => onDelete(purchase._id)}
|
|
||||||
className="rounded-lg border px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel order
|
Cancel order
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -522,7 +485,9 @@ function PurchasesContent({ householdId }: { householdId: string }) {
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listStores(householdId, { limit: 100 }).then((r) => setStores(r.data)).catch(() => {});
|
listStores(householdId, { limit: 100 })
|
||||||
|
.then((r) => setStores(r.data))
|
||||||
|
.catch(() => {});
|
||||||
}, [householdId]);
|
}, [householdId]);
|
||||||
|
|
||||||
const fetchPurchases = useCallback(
|
const fetchPurchases = useCallback(
|
||||||
|
|
@ -534,9 +499,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
|
||||||
cursor: append ? (cursor ?? undefined) : undefined,
|
cursor: append ? (cursor ?? undefined) : undefined,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
setPurchases((prev) =>
|
setPurchases((prev) => (append ? [...prev, ...result.data] : result.data));
|
||||||
append ? [...prev, ...result.data] : result.data,
|
|
||||||
);
|
|
||||||
setCursor(result.pagination.cursor);
|
setCursor(result.pagination.cursor);
|
||||||
setHasMore(result.pagination.hasMore);
|
setHasMore(result.pagination.hasMore);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -582,10 +545,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold">Purchases</h1>
|
<h1 className="text-2xl font-bold">Purchases</h1>
|
||||||
<button
|
<button onClick={() => setShowForm(!showForm)} className="mt-btn mt-btn--primary">
|
||||||
onClick={() => setShowForm(!showForm)}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
|
||||||
>
|
|
||||||
{showForm ? 'Cancel' : 'Record Purchase'}
|
{showForm ? 'Cancel' : 'Record Purchase'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -604,7 +564,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
<div className="mt-alert mt-alert--danger mb-4">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 underline">
|
<button onClick={() => setError('')} className="ml-2 underline">
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|
@ -648,7 +608,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{purchases.length === 0 && (
|
{purchases.length === 0 && (
|
||||||
<div className="rounded-xl border bg-white p-10 text-center shadow-sm">
|
<div className="mt-card text-center">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
No purchases recorded yet. Record your first purchase to get started.
|
No purchases recorded yet. Record your first purchase to get started.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -676,33 +636,54 @@ export default function PurchasesPage() {
|
||||||
|
|
||||||
if (sessionLoading) {
|
if (sessionLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Purchases</h1>
|
<SetPageHeader title="Purchases" subtitle="Order history and pending arrivals" />
|
||||||
<div className="animate-pulse space-y-4">
|
<div style={{ padding: '28px 32px' }}>
|
||||||
<div className="h-10 w-48 rounded-lg bg-gray-200" />
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div className="h-28 rounded-xl bg-gray-200" />
|
{[1, 2, 3].map((i) => (
|
||||||
<div className="h-28 rounded-xl bg-gray-200" />
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ height: 64, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!householdId) {
|
if (!householdId) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Purchases</h1>
|
<SetPageHeader title="Purchases" subtitle="Order history and pending arrivals" />
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div style={{ padding: '28px 32px' }}>
|
||||||
<p className="text-gray-500">
|
<div
|
||||||
You need to{' '}
|
style={{
|
||||||
<Link href="/settings" className="text-primary-600 underline">
|
background: 'var(--bg-elev)',
|
||||||
create or join a household
|
border: '1px solid var(--border)',
|
||||||
</Link>{' '}
|
borderRadius: 'var(--r-md)',
|
||||||
before recording purchases.
|
padding: 24,
|
||||||
</p>
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
You need to{' '}
|
||||||
|
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
|
||||||
|
create or join a household
|
||||||
|
</Link>{' '}
|
||||||
|
before recording purchases.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PurchasesContent householdId={householdId} />;
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Purchases" subtitle="Order history and pending arrivals" />
|
||||||
|
<div className="mt-page">
|
||||||
|
<PurchasesContent householdId={householdId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
|
||||||
|
|
@ -29,7 +30,9 @@ vi.mock('@/services/refills', () => ({
|
||||||
updateRefillListItem: mockUpdateRefillListItem,
|
updateRefillListItem: mockUpdateRefillListItem,
|
||||||
addToCabinet: mockAddToCabinet,
|
addToCabinet: mockAddToCabinet,
|
||||||
}));
|
}));
|
||||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
vi.mock('next/link', () => ({
|
||||||
|
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||||
|
}));
|
||||||
|
|
||||||
import RefillsPage from '../page';
|
import RefillsPage from '../page';
|
||||||
|
|
||||||
|
|
@ -65,17 +68,13 @@ describe('RefillsPage', () => {
|
||||||
it('shows empty state for alerts', async () => {
|
it('shows empty state for alerts', async () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
render(<RefillsPage />);
|
render(<RefillsPage />);
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText(/No medicines running low/)).toBeInTheDocument());
|
||||||
expect(screen.getByText(/No medicines running low/)).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows empty state for refill lists', async () => {
|
it('shows empty state for refill lists', async () => {
|
||||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
render(<RefillsPage />);
|
render(<RefillsPage />);
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText(/No refill lists yet/)).toBeInTheDocument());
|
||||||
expect(screen.getByText(/No refill lists yet/)).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when alerts fail', async () => {
|
it('shows error when alerts fail', async () => {
|
||||||
|
|
@ -116,7 +115,10 @@ describe('RefillsPage', () => {
|
||||||
fireEvent.submit(screen.getByPlaceholderText('List name').closest('form')!);
|
fireEvent.submit(screen.getByPlaceholderText('List name').closest('form')!);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mockCreateRefillList).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Test List' })),
|
expect(mockCreateRefillList).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ name: 'Test List' }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -274,7 +276,9 @@ describe('RefillsPage', () => {
|
||||||
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
|
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
|
||||||
target: { value: 'Auto Refills' },
|
target: { value: 'Auto Refills' },
|
||||||
});
|
});
|
||||||
fireEvent.submit(screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!);
|
fireEvent.submit(
|
||||||
|
screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!,
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mockCreateRefillList).toHaveBeenCalledWith(
|
expect(mockCreateRefillList).toHaveBeenCalledWith(
|
||||||
|
|
@ -293,7 +297,11 @@ describe('RefillsPage', () => {
|
||||||
await userEvent.click(screen.getByText('New List'));
|
await userEvent.click(screen.getByText('New List'));
|
||||||
|
|
||||||
await waitFor(() => screen.getByPlaceholderText('List name'));
|
await waitFor(() => screen.getByPlaceholderText('List name'));
|
||||||
await userEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[screen.getAllByRole('button', { name: 'Cancel' }).length - 1]!);
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Cancel' })[
|
||||||
|
screen.getAllByRole('button', { name: 'Cancel' }).length - 1
|
||||||
|
]!,
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument();
|
expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -382,7 +390,9 @@ describe('RefillsPage', () => {
|
||||||
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
|
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
|
||||||
target: { value: 'Auto Refills' },
|
target: { value: 'Auto Refills' },
|
||||||
});
|
});
|
||||||
fireEvent.submit(screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!);
|
fireEvent.submit(
|
||||||
|
screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!,
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Generate failed')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Generate failed')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
@ -451,7 +461,9 @@ describe('RefillsPage', () => {
|
||||||
await waitFor(() => screen.getByText('Aspirin'));
|
await waitFor(() => screen.getByText('Aspirin'));
|
||||||
await userEvent.click(screen.getByRole('checkbox'));
|
await userEvent.click(screen.getByRole('checkbox'));
|
||||||
|
|
||||||
expect(mockUpdateRefillListItem).toHaveBeenCalledWith('hh1', 'rl-1', 'item-1', { checked: true });
|
expect(mockUpdateRefillListItem).toHaveBeenCalledWith('hh1', 'rl-1', 'item-1', {
|
||||||
|
checked: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a shopping list as complete', async () => {
|
it('marks a shopping list as complete', async () => {
|
||||||
|
|
@ -623,9 +635,7 @@ describe('RefillsPage', () => {
|
||||||
await waitFor(() => screen.getByText('Aspirin'));
|
await waitFor(() => screen.getByText('Aspirin'));
|
||||||
await userEvent.click(screen.getAllByRole('checkbox')[0]!);
|
await userEvent.click(screen.getAllByRole('checkbox')[0]!);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText('Failed to update item')).toBeInTheDocument());
|
||||||
expect(screen.getByText('Failed to update item')).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows fallback error when non-Error thrown on update status', async () => {
|
it('shows fallback error when non-Error thrown on update status', async () => {
|
||||||
|
|
@ -653,9 +663,7 @@ describe('RefillsPage', () => {
|
||||||
await waitFor(() => screen.getByText('Start shopping'));
|
await waitFor(() => screen.getByText('Start shopping'));
|
||||||
await userEvent.click(screen.getByText('Start shopping'));
|
await userEvent.click(screen.getByText('Start shopping'));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByText('Failed to update status')).toBeInTheDocument());
|
||||||
expect(screen.getByText('Failed to update status')).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows refill alert when present', async () => {
|
it('shows refill alert when present', async () => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useApi } from '@/lib/useApi';
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
import {
|
import {
|
||||||
getRefillAlerts,
|
getRefillAlerts,
|
||||||
listRefillLists,
|
listRefillLists,
|
||||||
|
|
@ -26,11 +27,11 @@ const STATUS_LABELS: Record<string, string> = {
|
||||||
archived: 'Archived',
|
archived: 'Archived',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_PILL: Record<string, string> = {
|
||||||
active: 'bg-green-100 text-green-700',
|
active: 'mt-pill--ok',
|
||||||
shopping: 'bg-blue-100 text-blue-700',
|
shopping: 'mt-pill--info',
|
||||||
completed: 'bg-gray-100 text-gray-600',
|
completed: 'mt-pill--ghost',
|
||||||
archived: 'bg-gray-100 text-gray-400',
|
archived: 'mt-pill--ghost',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
|
|
@ -93,7 +94,7 @@ function AlertsPanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card">
|
||||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||||
<h2 className="text-lg font-semibold">Refill Alerts</h2>
|
<h2 className="text-lg font-semibold">Refill Alerts</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -102,7 +103,8 @@ function AlertsPanel({
|
||||||
<select
|
<select
|
||||||
value={thresholdDays}
|
value={thresholdDays}
|
||||||
onChange={(e) => setThresholdDays(Number(e.target.value))}
|
onChange={(e) => setThresholdDays(Number(e.target.value))}
|
||||||
className="rounded-lg border px-2 py-1 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
{[3, 5, 7, 10, 14, 30].map((d) => (
|
{[3, 5, 7, 10, 14, 30].map((d) => (
|
||||||
<option key={d} value={d}>
|
<option key={d} value={d}>
|
||||||
|
|
@ -114,7 +116,7 @@ function AlertsPanel({
|
||||||
{alerts.length > 0 && (
|
{alerts.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowGenerateForm(!showGenerateForm)}
|
onClick={() => setShowGenerateForm(!showGenerateForm)}
|
||||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
className="mt-btn mt-btn--primary"
|
||||||
>
|
>
|
||||||
Generate Refill List
|
Generate Refill List
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -131,19 +133,15 @@ function AlertsPanel({
|
||||||
value={listName}
|
value={listName}
|
||||||
onChange={(e) => setListName(e.target.value)}
|
onChange={(e) => setListName(e.target.value)}
|
||||||
placeholder="List name, e.g. Weekly refills"
|
placeholder="List name, e.g. Weekly refills"
|
||||||
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
<button
|
<button type="submit" disabled={generating} className="mt-btn mt-btn--primary">
|
||||||
type="submit"
|
|
||||||
disabled={generating}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{generating ? 'Creating...' : 'Create'}
|
{generating ? 'Creating...' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowGenerateForm(false)}
|
onClick={() => setShowGenerateForm(false)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
className="mt-btn mt-btn--ghost"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -151,7 +149,7 @@ function AlertsPanel({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
<div className="mt-alert mt-alert--danger mb-4">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 underline">
|
<button onClick={() => setError('')} className="ml-2 underline">
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|
@ -169,9 +167,7 @@ function AlertsPanel({
|
||||||
<div className="py-6 text-center text-sm text-gray-500">
|
<div className="py-6 text-center text-sm text-gray-500">
|
||||||
No medicines running low within {thresholdDays} days.
|
No medicines running low within {thresholdDays} days.
|
||||||
{thresholdDays < 30 && (
|
{thresholdDays < 30 && (
|
||||||
<span className="block mt-1 text-xs">
|
<span className="block mt-1 text-xs">Try increasing the threshold to see more.</span>
|
||||||
Try increasing the threshold to see more.
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -185,10 +181,7 @@ function AlertsPanel({
|
||||||
: 'text-yellow-600';
|
: 'text-yellow-600';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={alert.medicineId} className="rounded-lg border bg-gray-50 p-4">
|
||||||
key={alert.medicineId}
|
|
||||||
className="rounded-lg border bg-gray-50 p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900">
|
<h3 className="font-semibold text-gray-900">
|
||||||
|
|
@ -201,17 +194,14 @@ function AlertsPanel({
|
||||||
<span className={daysColor}>
|
<span className={daysColor}>
|
||||||
{alert.daysUntilEmpty} day{alert.daysUntilEmpty !== 1 ? 's' : ''} left
|
{alert.daysUntilEmpty} day{alert.daysUntilEmpty !== 1 ? 's' : ''} left
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">{alert.currentStock} in cabinet</span>
|
||||||
{alert.currentStock} in cabinet
|
<span className="text-gray-500">{alert.dailyConsumption.toFixed(2)}/day</span>
|
||||||
</span>
|
|
||||||
<span className="text-gray-500">
|
|
||||||
{alert.dailyConsumption.toFixed(2)}/day
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-sm">
|
<div className="text-right text-sm">
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Suggested: <span className="font-medium">{alert.suggestedQuantity} units</span>
|
Suggested:{' '}
|
||||||
|
<span className="font-medium">{alert.suggestedQuantity} units</span>
|
||||||
</p>
|
</p>
|
||||||
{alert.cheapestOption && (
|
{alert.cheapestOption && (
|
||||||
<p className="text-green-700 font-medium">
|
<p className="text-green-700 font-medium">
|
||||||
|
|
@ -260,9 +250,7 @@ function RefillListDetail({
|
||||||
const updated = await updateRefillListItem(householdId, list._id, item._id, {
|
const updated = await updateRefillListItem(householdId, list._id, item._id, {
|
||||||
checked: !item.checked,
|
checked: !item.checked,
|
||||||
actualPrice:
|
actualPrice:
|
||||||
!item.checked && actualPrices[item._id]
|
!item.checked && actualPrices[item._id] ? Number(actualPrices[item._id]) : undefined,
|
||||||
? Number(actualPrices[item._id])
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
setItems(updated.items);
|
setItems(updated.items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -287,7 +275,10 @@ function RefillListDetail({
|
||||||
setError('No checked items to add to cabinet.');
|
setError('No checked items to add to cabinet.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm(`Add ${checkedCount} checked item${checkedCount !== 1 ? 's' : ''} to your cabinet?`)) return;
|
if (
|
||||||
|
!confirm(`Add ${checkedCount} checked item${checkedCount !== 1 ? 's' : ''} to your cabinet?`)
|
||||||
|
)
|
||||||
|
return;
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
|
@ -295,7 +286,9 @@ function RefillListDetail({
|
||||||
onUpdated();
|
onUpdated();
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
if (result.addedCount > 0) {
|
if (result.addedCount > 0) {
|
||||||
alert(`Added ${result.addedCount} item${result.addedCount !== 1 ? 's' : ''} to your cabinet.`);
|
alert(
|
||||||
|
`Added ${result.addedCount} item${result.addedCount !== 1 ? 's' : ''} to your cabinet.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to add to cabinet');
|
setError(err instanceof Error ? err.message : 'Failed to add to cabinet');
|
||||||
|
|
@ -307,12 +300,12 @@ function RefillListDetail({
|
||||||
const totalChecked = items.filter((i) => i.checked).length;
|
const totalChecked = items.filter((i) => i.checked).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card">
|
||||||
<div className="flex items-start justify-between gap-4 mb-4">
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{list.name}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{list.name}</h2>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_COLORS[list.status] ?? STATUS_COLORS['active']}`}>
|
<span className={`mt-pill ${STATUS_PILL[list.status] ?? STATUS_PILL['active']}`}>
|
||||||
{STATUS_LABELS[list.status] ?? list.status}
|
{STATUS_LABELS[list.status] ?? list.status}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
|
|
@ -325,21 +318,24 @@ function RefillListDetail({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={onClose} className="mt-btn mt-btn--icon" title="Close">
|
||||||
onClick={onClose}
|
|
||||||
className="rounded p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
<div className="mt-alert mt-alert--danger mb-4">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 underline">Dismiss</button>
|
<button onClick={() => setError('')} className="ml-2 underline">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -357,24 +353,24 @@ function RefillListDetail({
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onChange={() => handleToggleItem(item)}
|
onChange={() => handleToggleItem(item)}
|
||||||
disabled={item.addedToCabinet}
|
disabled={item.addedToCabinet}
|
||||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className={`text-sm font-medium ${item.checked ? 'line-through text-gray-400' : 'text-gray-900'}`}>
|
<span
|
||||||
|
className={`text-sm font-medium ${item.checked ? 'line-through text-gray-400' : 'text-gray-900'}`}
|
||||||
|
>
|
||||||
{item.medicineName}
|
{item.medicineName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{item.quantity} {item.unit}
|
{item.quantity} {item.unit}
|
||||||
</span>
|
</span>
|
||||||
{item.estimatedPrice != null && (
|
{item.estimatedPrice != null && (
|
||||||
<span className="text-xs text-gray-400">est. {item.estimatedPrice.toFixed(2)}</span>
|
<span className="text-xs text-gray-400">
|
||||||
)}
|
est. {item.estimatedPrice.toFixed(2)}
|
||||||
{item.addedToCabinet && (
|
|
||||||
<span className="rounded-full bg-green-100 text-green-700 px-2 py-0.5 text-xs">
|
|
||||||
in cabinet
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{item.addedToCabinet && <span className="mt-pill mt-pill--ok">in cabinet</span>}
|
||||||
</div>
|
</div>
|
||||||
{item.notes && <p className="text-xs text-gray-400 mt-0.5">{item.notes}</p>}
|
{item.notes && <p className="text-xs text-gray-400 mt-0.5">{item.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -389,7 +385,8 @@ function RefillListDetail({
|
||||||
setActualPrices((prev) => ({ ...prev, [item._id]: e.target.value }))
|
setActualPrices((prev) => ({ ...prev, [item._id]: e.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="Actual price"
|
placeholder="Actual price"
|
||||||
className="w-28 rounded-lg border px-2 py-1 text-xs focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: '7rem', fontSize: '0.75rem' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -400,18 +397,14 @@ function RefillListDetail({
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
|
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
|
||||||
{checkedNotAdded > 0 && (
|
{checkedNotAdded > 0 && (
|
||||||
<button
|
<button onClick={handleAddToCabinet} disabled={adding} className="mt-btn mt-btn--primary">
|
||||||
onClick={handleAddToCabinet}
|
|
||||||
disabled={adding}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{adding ? 'Adding...' : `Add ${checkedNotAdded} to Cabinet`}
|
{adding ? 'Adding...' : `Add ${checkedNotAdded} to Cabinet`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{list.status === RefillListStatus.ACTIVE && (
|
{list.status === RefillListStatus.ACTIVE && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUpdateStatus(RefillListStatus.SHOPPING)}
|
onClick={() => handleUpdateStatus(RefillListStatus.SHOPPING)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
className="mt-btn mt-btn--ghost"
|
||||||
>
|
>
|
||||||
Start shopping
|
Start shopping
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -419,16 +412,15 @@ function RefillListDetail({
|
||||||
{list.status === RefillListStatus.SHOPPING && (
|
{list.status === RefillListStatus.SHOPPING && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUpdateStatus(RefillListStatus.COMPLETED)}
|
onClick={() => handleUpdateStatus(RefillListStatus.COMPLETED)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
className="mt-btn mt-btn--ghost"
|
||||||
>
|
>
|
||||||
Mark complete
|
Mark complete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(list.status === RefillListStatus.ACTIVE ||
|
{(list.status === RefillListStatus.ACTIVE || list.status === RefillListStatus.SHOPPING) && (
|
||||||
list.status === RefillListStatus.SHOPPING) && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUpdateStatus(RefillListStatus.ARCHIVED)}
|
onClick={() => handleUpdateStatus(RefillListStatus.ARCHIVED)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm text-gray-500 hover:bg-gray-50 transition-colors"
|
className="mt-btn mt-btn--ghost"
|
||||||
>
|
>
|
||||||
Archive
|
Archive
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -458,7 +450,11 @@ function CreateListForm({
|
||||||
setError('');
|
setError('');
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createRefillList(householdId, { name: name.trim(), fromAlerts: false, thresholdDays: 7 });
|
await createRefillList(householdId, {
|
||||||
|
name: name.trim(),
|
||||||
|
fromAlerts: false,
|
||||||
|
thresholdDays: 7,
|
||||||
|
});
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create list');
|
setError(err instanceof Error ? err.message : 'Failed to create list');
|
||||||
|
|
@ -468,13 +464,9 @@ function CreateListForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-xl border bg-white p-6 shadow-sm">
|
<div className="mt-card mb-4">
|
||||||
<h3 className="text-base font-semibold mb-3">New Refill List</h3>
|
<h3 className="text-base font-semibold mb-3">New Refill List</h3>
|
||||||
{error && (
|
{error && <div className="mt-alert mt-alert--danger mb-3">{error}</div>}
|
||||||
<div className="mb-3 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-3">
|
<form onSubmit={handleSubmit} className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -483,20 +475,12 @@ function CreateListForm({
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="List name"
|
placeholder="List name"
|
||||||
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
/>
|
/>
|
||||||
<button
|
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{submitting ? 'Creating...' : 'Create'}
|
{submitting ? 'Creating...' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -546,7 +530,8 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
|
||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
className="mt-field"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
>
|
>
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
{Object.values(RefillListStatus).map((s) => (
|
{Object.values(RefillListStatus).map((s) => (
|
||||||
|
|
@ -555,10 +540,7 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button onClick={() => setShowForm(!showForm)} className="mt-btn mt-btn--primary">
|
||||||
onClick={() => setShowForm(!showForm)}
|
|
||||||
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
|
||||||
>
|
|
||||||
{showForm ? 'Cancel' : 'New List'}
|
{showForm ? 'Cancel' : 'New List'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -591,9 +573,11 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
<div className="mt-alert mt-alert--danger mb-4">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 underline">Dismiss</button>
|
<button onClick={() => setError('')} className="ml-2 underline">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -604,8 +588,10 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : lists.length === 0 ? (
|
) : lists.length === 0 ? (
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-sm text-gray-500">
|
<div className="mt-card text-center" style={{ color: 'var(--ink-muted)' }}>
|
||||||
{filterStatus ? `No ${STATUS_LABELS[filterStatus] ?? filterStatus} lists.` : 'No refill lists yet. Create one above or generate from alerts.'}
|
{filterStatus
|
||||||
|
? `No ${STATUS_LABELS[filterStatus] ?? filterStatus} lists.`
|
||||||
|
: 'No refill lists yet. Create one above or generate from alerts.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -617,18 +603,16 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
|
||||||
<button
|
<button
|
||||||
key={list._id}
|
key={list._id}
|
||||||
onClick={() => handleSelectList(list)}
|
onClick={() => handleSelectList(list)}
|
||||||
className={`w-full rounded-xl border p-4 text-left transition-colors ${
|
className={`w-full mt-card text-left transition-colors ${
|
||||||
isSelected
|
isSelected ? 'outline outline-2 outline-[var(--brand)]' : ''
|
||||||
? 'bg-primary-50 border-primary-300'
|
}`}
|
||||||
: 'bg-white hover:bg-gray-50'
|
|
||||||
} shadow-sm`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
<span className="font-medium text-gray-900 truncate">{list.name}</span>
|
<span className="font-medium text-gray-900 truncate">{list.name}</span>
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_COLORS[list.status] ?? STATUS_COLORS['active']}`}
|
className={`mt-pill ${STATUS_PILL[list.status] ?? STATUS_PILL['active']}`}
|
||||||
>
|
>
|
||||||
{STATUS_LABELS[list.status] ?? list.status}
|
{STATUS_LABELS[list.status] ?? list.status}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -658,13 +642,9 @@ function RefillsContent({ householdId }: { householdId: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-6">Refills</h1>
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<AlertsPanel
|
<AlertsPanel householdId={householdId} onGenerateList={() => setListsKey((k) => k + 1)} />
|
||||||
householdId={householdId}
|
<div className="mt-card">
|
||||||
onGenerateList={() => setListsKey((k) => k + 1)}
|
|
||||||
/>
|
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
|
||||||
<RefillListsPanel key={listsKey} householdId={householdId} />
|
<RefillListsPanel key={listsKey} householdId={householdId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -677,32 +657,54 @@ export default function RefillsPage() {
|
||||||
|
|
||||||
if (sessionLoading) {
|
if (sessionLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Refills</h1>
|
<SetPageHeader title="Refills" subtitle="Running-low alerts and shopping lists" />
|
||||||
<div className="animate-pulse space-y-4">
|
<div style={{ padding: '28px 32px' }}>
|
||||||
<div className="h-40 rounded-xl bg-gray-200" />
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div className="h-60 rounded-xl bg-gray-200" />
|
{[1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ height: 96, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!householdId) {
|
if (!householdId) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-4">Refills</h1>
|
<SetPageHeader title="Refills" subtitle="Running-low alerts and shopping lists" />
|
||||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
<div style={{ padding: '28px 32px' }}>
|
||||||
<p className="text-gray-500">
|
<div
|
||||||
You need to{' '}
|
style={{
|
||||||
<Link href="/settings" className="text-primary-600 underline">
|
background: 'var(--bg-elev)',
|
||||||
create or join a household
|
border: '1px solid var(--border)',
|
||||||
</Link>{' '}
|
borderRadius: 'var(--r-md)',
|
||||||
before managing refills.
|
padding: 24,
|
||||||
</p>
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
You need to{' '}
|
||||||
|
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
|
||||||
|
create or join a household
|
||||||
|
</Link>{' '}
|
||||||
|
before managing refills.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RefillsContent householdId={householdId} />;
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Refills" subtitle="Running-low alerts and shopping lists" />
|
||||||
|
<div className="mt-page">
|
||||||
|
<RefillsContent householdId={householdId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,19 @@ const { mockUseApi } = vi.hoisted(() => ({
|
||||||
mockUseApi: vi.fn(),
|
mockUseApi: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { mockCreateHousehold, mockJoinHousehold, mockGetHousehold, mockUpdateHousehold, mockGenerateInviteCode } =
|
const {
|
||||||
vi.hoisted(() => ({
|
mockCreateHousehold,
|
||||||
mockCreateHousehold: vi.fn(),
|
mockJoinHousehold,
|
||||||
mockJoinHousehold: vi.fn(),
|
mockGetHousehold,
|
||||||
mockGetHousehold: vi.fn(),
|
mockUpdateHousehold,
|
||||||
mockUpdateHousehold: vi.fn(),
|
mockGenerateInviteCode,
|
||||||
mockGenerateInviteCode: vi.fn(),
|
} = vi.hoisted(() => ({
|
||||||
}));
|
mockCreateHousehold: vi.fn(),
|
||||||
|
mockJoinHousehold: vi.fn(),
|
||||||
|
mockGetHousehold: vi.fn(),
|
||||||
|
mockUpdateHousehold: vi.fn(),
|
||||||
|
mockGenerateInviteCode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
vi.mock('@/services/households', () => ({
|
vi.mock('@/services/households', () => ({
|
||||||
|
|
@ -137,7 +142,9 @@ describe('SettingsPage', () => {
|
||||||
await userEvent.type(input, 'Updated Home');
|
await userEvent.type(input, 'Updated Home');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
|
||||||
await waitFor(() => expect(mockUpdateHousehold).toHaveBeenCalledWith('hh1', { name: 'Updated Home' }));
|
await waitFor(() =>
|
||||||
|
expect(mockUpdateHousehold).toHaveBeenCalledWith('hh1', { name: 'Updated Home' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when name update fails', async () => {
|
it('shows error when name update fails', async () => {
|
||||||
|
|
@ -252,7 +259,9 @@ describe('SettingsPage', () => {
|
||||||
await waitFor(() => screen.getByText('My House'));
|
await waitFor(() => screen.getByText('My House'));
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Regenerate' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Regenerate' }));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Failed to regenerate invite code')).toBeInTheDocument());
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('Failed to regenerate invite code')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows validation error when saving empty name', async () => {
|
it('shows validation error when saving empty name', async () => {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue