|
|
# Ideal-Web-App Pass Implementation Plan
|
|
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
|
|
**Goal:** Deliver the approved spec `docs/superpowers/specs/2026-07-17-ideal-web-app-design.md` — Pinterest-chrome dialogs, toasts, edit-everywhere, a real send-email modal, a `/settings` hub, favicon + titles.
|
|
|
|
|
|
**Architecture:** Generic chrome kit (`Dialog`, `ConfirmDialog`, `Toaster`, `FormGrid`) in `packages/ui`; two small additive backend slices (`/settings/reminders|schedules|sharing`); page refits in `apps/hq-web`. No schema changes (settings ride the `setting` table; cadence changes are new dated `reminder_schedule` rows).
|
|
|
|
|
|
**Tech Stack:** React 19, react-router-dom 7 (HashRouter), Vite 6, vitest 3 (Node env, no DOM — component behavior verified e2e), TypeScript strict + `noUncheckedIndexedAccess`, better-sqlite3 backend.
|
|
|
|
|
|
## Global Constraints
|
|
|
|
|
|
- Code style: no semicolons, single quotes, 2-space indent, `x !== undefined` guards.
|
|
|
- `@sims/ui` stays generic: no router, no lucide, no HQ types.
|
|
|
- Money renders via `formatINR` only; never compute money client-side.
|
|
|
- Backend: every mutation audits via `writeAudit` in the same transaction; dated config rows are append-only (a cadence change is a NEW `reminder_schedule` row — never UPDATE/DELETE); portable SQL only.
|
|
|
- Repo gates after EVERY task: `npm run typecheck` exits 0 and `npm test` has zero failures (suite currently 293 green; new tests only add).
|
|
|
- Several page files were touched by two workstreams this week — implementers must READ the current file and anchor edits on code text, not line numbers.
|
|
|
- `git add` only your task's files by explicit path. Commit after every task. All commit messages end with:
|
|
|
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
|
|
- z-order contract: drawer 40 < dialog 50 < palette 60 < toasts 70.
|
|
|
|
|
|
**Verify commands** (repo root `C:\SiMS\hq`): `npm run typecheck` · `npm test` · focused: `npm test -- <name>`.
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 1: Dialog + ConfirmDialog (+ pill buttons, Modal removed)
|
|
|
|
|
|
**Files:**
|
|
|
- Create: `packages/ui/src/dialog.tsx`
|
|
|
- Modify: `packages/ui/src/tokens.css` (append CSS + `button.wf.pill` rule)
|
|
|
- Modify: `packages/ui/src/components.tsx` (Button `pill` prop; delete `Modal`)
|
|
|
- Modify: `packages/ui/src/index.ts`
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Consumes: tokens (`--radius-lg`, `--shadow-lg`, `--bg-raised`, `--border`).
|
|
|
- Produces: `Dialog(props: { open: boolean; onClose: () => void; title: string; size?: 'sm' | 'md' | 'lg'; footer?: ReactNode; children: ReactNode })` and `ConfirmDialog(props: { open: boolean; onClose: () => void; onConfirm: () => void | Promise<void>; title: string; body?: ReactNode; confirmLabel?: string; tone?: 'danger' })`. `Button` gains `pill?: boolean`.
|
|
|
|
|
|
- [ ] **Step 1: Confirm Modal is dead code**
|
|
|
|
|
|
Run: `grep -rn "Modal" apps/hq-web/src --include=*.tsx` — expect zero component usages (only the word in comments, if any). If a call site exists, STOP and report BLOCKED.
|
|
|
|
|
|
- [ ] **Step 2: Create `packages/ui/src/dialog.tsx`**
|
|
|
|
|
|
```tsx
|
|
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
|
|
|
|
const WIDTH: Record<'sm' | 'md' | 'lg', number> = { sm: 420, md: 560, lg: 720 }
|
|
|
const FOCUSABLE = 'input, select, textarea, button, a[href], [tabindex]:not([tabindex="-1"])'
|
|
|
|
|
|
/** Pinterest-chrome modal: centered rounded card, scrim, focus trap + restore. */
|
|
|
export function Dialog(props: {
|
|
|
open: boolean
|
|
|
onClose: () => void
|
|
|
title: string
|
|
|
size?: 'sm' | 'md' | 'lg'
|
|
|
footer?: ReactNode
|
|
|
children: ReactNode
|
|
|
}) {
|
|
|
const prevFocus = useRef<HTMLElement | null>(null)
|
|
|
const cardRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (props.open) {
|
|
|
prevFocus.current = document.activeElement as HTMLElement | null
|
|
|
const first = cardRef.current?.querySelector<HTMLElement>(
|
|
|
'input, select, textarea, button:not(.wf-dialog-x)',
|
|
|
)
|
|
|
;(first ?? cardRef.current ?? undefined)?.focus()
|
|
|
}
|
|
|
}, [props.open])
|
|
|
useEffect(() => {
|
|
|
if (!props.open) prevFocus.current?.focus()
|
|
|
}, [props.open])
|
|
|
|
|
|
if (!props.open) return null
|
|
|
|
|
|
const onKey = (e: React.KeyboardEvent) => {
|
|
|
if (e.key === 'Escape') { e.preventDefault(); props.onClose(); return }
|
|
|
if (e.key !== 'Tab') return
|
|
|
const nodes = cardRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE)
|
|
|
const list = nodes === undefined ? [] : [...nodes].filter((n) => !n.hasAttribute('disabled'))
|
|
|
const first = list[0]
|
|
|
const last = list[list.length - 1]
|
|
|
if (first === undefined || last === undefined) { e.preventDefault(); return }
|
|
|
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
|
|
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
<div className="wf-dialog-back" onClick={props.onClose}>
|
|
|
<div
|
|
|
ref={cardRef}
|
|
|
className="wf-dialog"
|
|
|
style={{ maxWidth: WIDTH[props.size ?? 'md'] }}
|
|
|
role="dialog"
|
|
|
aria-modal="true"
|
|
|
aria-label={props.title}
|
|
|
tabIndex={-1}
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
onKeyDown={onKey}
|
|
|
>
|
|
|
<div className="wf-dialog-head">
|
|
|
<h2>{props.title}</h2>
|
|
|
<button type="button" className="wf-dialog-x" aria-label="Close" onClick={props.onClose}>✕</button>
|
|
|
</div>
|
|
|
<div className="wf-dialog-body">{props.children}</div>
|
|
|
{props.footer !== undefined && <div className="wf-dialog-foot">{props.footer}</div>}
|
|
|
</div>
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
|
|
|
/** Small confirm variant — kills window.confirm. Busy state while onConfirm resolves. */
|
|
|
export function ConfirmDialog(props: {
|
|
|
open: boolean
|
|
|
onClose: () => void
|
|
|
onConfirm: () => void | Promise<void>
|
|
|
title: string
|
|
|
body?: ReactNode
|
|
|
confirmLabel?: string
|
|
|
tone?: 'danger'
|
|
|
}) {
|
|
|
const [busy, setBusy] = useState(false)
|
|
|
const confirm = () => {
|
|
|
const out = props.onConfirm()
|
|
|
if (out instanceof Promise) {
|
|
|
setBusy(true)
|
|
|
out.finally(() => { setBusy(false); props.onClose() })
|
|
|
} else {
|
|
|
props.onClose()
|
|
|
}
|
|
|
}
|
|
|
return (
|
|
|
<Dialog
|
|
|
open={props.open}
|
|
|
onClose={props.onClose}
|
|
|
title={props.title}
|
|
|
size="sm"
|
|
|
footer={
|
|
|
<>
|
|
|
<button type="button" className="wf pill" onClick={props.onClose}>Cancel</button>
|
|
|
<button
|
|
|
type="button"
|
|
|
className={`wf pill ${props.tone === 'danger' ? 'danger' : 'primary'}`}
|
|
|
onClick={confirm}
|
|
|
disabled={busy}
|
|
|
>
|
|
|
{busy ? '…' : props.confirmLabel ?? 'Confirm'}
|
|
|
</button>
|
|
|
</>
|
|
|
}
|
|
|
>
|
|
|
{props.body !== undefined && <div style={{ fontSize: 13.5, color: 'var(--text-dim)' }}>{props.body}</div>}
|
|
|
</Dialog>
|
|
|
)
|
|
|
}
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 3: CSS — append to `packages/ui/src/tokens.css`**
|
|
|
|
|
|
```css
|
|
|
/* ================= dialogs (Pinterest chrome) ================= */
|
|
|
.wf-dialog-back {
|
|
|
position: fixed; inset: 0; background: rgba(15, 14, 11, 0.45);
|
|
|
backdrop-filter: blur(4px); z-index: 50;
|
|
|
display: flex; align-items: center; justify-content: center; padding: 20px;
|
|
|
}
|
|
|
.wf-dialog {
|
|
|
background: var(--bg-raised); border: 1px solid var(--border);
|
|
|
border-radius: 16px; box-shadow: var(--shadow-lg);
|
|
|
width: 100%; max-height: 86vh; display: flex; flex-direction: column;
|
|
|
}
|
|
|
.wf-dialog-head { display: flex; align-items: center; gap: 10px; padding: 18px 20px 0; }
|
|
|
.wf-dialog-head h2 { margin: 0; font-size: 17px; flex: 1; }
|
|
|
.wf-dialog-x {
|
|
|
border: none; background: var(--bg-inset); color: var(--text-dim);
|
|
|
width: 30px; height: 30px; border-radius: 50%; cursor: pointer; font-size: 13px;
|
|
|
}
|
|
|
.wf-dialog-x:hover { background: var(--bg-hover); color: var(--text); }
|
|
|
.wf-dialog-body { padding: 14px 20px; overflow-y: auto; }
|
|
|
.wf-dialog-foot {
|
|
|
display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 18px;
|
|
|
}
|
|
|
button.wf.pill { border-radius: 999px; padding: 8px 18px; }
|
|
|
```
|
|
|
|
|
|
Also delete the now-unused `.wf-modal-back` / `.wf-modal` rules.
|
|
|
|
|
|
- [ ] **Step 4: components.tsx — pill prop, Modal deleted**
|
|
|
|
|
|
In `Button`, extend props with `pill?: boolean` and the class computation to append `' pill'` when set:
|
|
|
|
|
|
```tsx
|
|
|
const cls = `wf${props.tone === 'primary' ? ' primary' : props.tone === 'danger' ? ' danger' : ''}${props.pill === true ? ' pill' : ''}`
|
|
|
```
|
|
|
|
|
|
Delete the entire `Modal` function. Export `Dialog`/`ConfirmDialog` via `packages/ui/src/index.ts`:
|
|
|
|
|
|
```ts
|
|
|
export * from './dialog'
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 5: Verify** — `npm run typecheck` exits 0 (proves Modal truly had no call sites); `npm test` zero failures.
|
|
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add packages/ui/src/dialog.tsx packages/ui/src/tokens.css packages/ui/src/components.tsx packages/ui/src/index.ts
|
|
|
git commit -m "feat(ui): Dialog + ConfirmDialog (Pinterest chrome), pill buttons; Modal removed"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 2: Toaster (TDD) + FormGrid
|
|
|
|
|
|
**Files:**
|
|
|
- Create: `packages/ui/src/toast-store.ts`, `packages/ui/src/toast.tsx`, `packages/ui/src/formgrid.tsx`
|
|
|
- Test: `packages/ui/test/toast-store.test.ts`
|
|
|
- Modify: `packages/ui/src/tokens.css` (toast + formgrid CSS), `packages/ui/src/index.ts`, `apps/hq-web/src/main.tsx` (wrap `ToastProvider`)
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Produces: `ToastProvider({ children })`, `useToast(): { ok: (msg: string) => void; err: (msg: string) => void }`; pure store: `Toast { id: number; tone: 'ok' | 'err'; message: string }`, `ToastState { nextId: number; toasts: Toast[] }`, `initialToasts`, `TOAST_LIMIT = 3`, `pushToast(s, tone, message): ToastState`, `dismissToast(s, id): ToastState`. `FormGrid({ children })`, `FormField({ label, wide?, children })`.
|
|
|
|
|
|
- [ ] **Step 1: Write the failing test** — `packages/ui/test/toast-store.test.ts`:
|
|
|
|
|
|
```ts
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
import { dismissToast, initialToasts, pushToast, TOAST_LIMIT } from '../src/toast-store'
|
|
|
|
|
|
describe('toast store', () => {
|
|
|
it('pushes with increasing ids', () => {
|
|
|
let s = pushToast(initialToasts, 'ok', 'saved')
|
|
|
s = pushToast(s, 'err', 'failed')
|
|
|
expect(s.toasts.map((t) => t.id)).toEqual([1, 2])
|
|
|
expect(s.toasts[1]).toMatchObject({ tone: 'err', message: 'failed' })
|
|
|
})
|
|
|
it('keeps only the newest TOAST_LIMIT toasts', () => {
|
|
|
let s = initialToasts
|
|
|
for (let i = 0; i < TOAST_LIMIT + 2; i++) s = pushToast(s, 'ok', `m${i}`)
|
|
|
expect(s.toasts).toHaveLength(TOAST_LIMIT)
|
|
|
expect(s.toasts[0]!.message).toBe('m2')
|
|
|
})
|
|
|
it('dismisses by id and ignores unknown ids', () => {
|
|
|
let s = pushToast(initialToasts, 'ok', 'a')
|
|
|
s = pushToast(s, 'ok', 'b')
|
|
|
s = dismissToast(s, 1)
|
|
|
expect(s.toasts.map((t) => t.message)).toEqual(['b'])
|
|
|
expect(dismissToast(s, 99).toasts).toHaveLength(1)
|
|
|
})
|
|
|
})
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 2: Run — expect FAIL** — `npm test -- toast-store` → cannot find module.
|
|
|
|
|
|
- [ ] **Step 3: Implement `packages/ui/src/toast-store.ts`**
|
|
|
|
|
|
```ts
|
|
|
/** Pure toast state — testable without a DOM. */
|
|
|
|
|
|
export interface Toast { id: number; tone: 'ok' | 'err'; message: string }
|
|
|
export interface ToastState { nextId: number; toasts: Toast[] }
|
|
|
|
|
|
export const TOAST_LIMIT = 3
|
|
|
export const initialToasts: ToastState = { nextId: 1, toasts: [] }
|
|
|
|
|
|
export function pushToast(s: ToastState, tone: Toast['tone'], message: string): ToastState {
|
|
|
const toast: Toast = { id: s.nextId, tone, message }
|
|
|
const toasts = [...s.toasts, toast].slice(-TOAST_LIMIT)
|
|
|
return { nextId: s.nextId + 1, toasts }
|
|
|
}
|
|
|
|
|
|
export function dismissToast(s: ToastState, id: number): ToastState {
|
|
|
return { ...s, toasts: s.toasts.filter((t) => t.id !== id) }
|
|
|
}
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 4: Run — expect PASS** — `npm test -- toast-store` → 3 pass.
|
|
|
|
|
|
- [ ] **Step 5: Create `packages/ui/src/toast.tsx`**
|
|
|
|
|
|
```tsx
|
|
|
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
|
import { dismissToast, initialToasts, pushToast, type Toast, type ToastState } from './toast-store'
|
|
|
|
|
|
const AUTO_DISMISS_MS = 4000
|
|
|
|
|
|
interface ToastApi { ok: (msg: string) => void; err: (msg: string) => void }
|
|
|
const Ctx = createContext<ToastApi | null>(null)
|
|
|
|
|
|
/** Mount once at app root; renders the bottom-right stack. */
|
|
|
export function ToastProvider(props: { children: ReactNode }) {
|
|
|
const [state, setState] = useState<ToastState>(initialToasts)
|
|
|
const stateRef = useRef(state)
|
|
|
stateRef.current = state
|
|
|
|
|
|
const push = useCallback((tone: Toast['tone'], message: string) => {
|
|
|
const id = stateRef.current.nextId
|
|
|
setState((s) => pushToast(s, tone, message))
|
|
|
window.setTimeout(() => setState((s) => dismissToast(s, id)), AUTO_DISMISS_MS)
|
|
|
}, [])
|
|
|
|
|
|
const api = useMemo<ToastApi>(() => ({
|
|
|
ok: (msg) => push('ok', msg),
|
|
|
err: (msg) => push('err', msg),
|
|
|
}), [push])
|
|
|
|
|
|
return (
|
|
|
<Ctx.Provider value={api}>
|
|
|
{props.children}
|
|
|
<div className="wf-toasts" aria-live="polite">
|
|
|
{state.toasts.map((t) => (
|
|
|
<div key={t.id} className={`wf-toast ${t.tone}`}>
|
|
|
<span className="msg">{t.message}</span>
|
|
|
<button type="button" aria-label="Dismiss" onClick={() => setState((s) => dismissToast(s, t.id))}>✕</button>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</Ctx.Provider>
|
|
|
)
|
|
|
}
|
|
|
|
|
|
export function useToast(): ToastApi {
|
|
|
const api = useContext(Ctx)
|
|
|
if (api === null) throw new Error('useToast requires <ToastProvider> at the app root')
|
|
|
return api
|
|
|
}
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 6: Create `packages/ui/src/formgrid.tsx`**
|
|
|
|
|
|
```tsx
|
|
|
import type { ReactNode } from 'react'
|
|
|
import { Field } from './components'
|
|
|
|
|
|
/** Two-column field grid for dialog bodies; single column under 560px. */
|
|
|
export function FormGrid(props: { children: ReactNode }) {
|
|
|
return <div className="wf-formgrid">{props.children}</div>
|
|
|
}
|
|
|
|
|
|
export function FormField(props: { label: string; wide?: boolean; children: ReactNode }) {
|
|
|
return (
|
|
|
<div className={props.wide === true ? 'wide' : undefined}>
|
|
|
<Field label={props.label}>{props.children}</Field>
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 7: CSS — append to tokens.css**
|
|
|
|
|
|
```css
|
|
|
/* ================= toasts ================= */
|
|
|
.wf-toasts {
|
|
|
position: fixed; right: 16px; bottom: 16px; z-index: 70;
|
|
|
display: flex; flex-direction: column; gap: 8px; max-width: 360px;
|
|
|
}
|
|
|
.wf-toast {
|
|
|
display: flex; align-items: center; gap: 10px;
|
|
|
background: var(--bg-raised); border: 1px solid var(--border);
|
|
|
border-radius: var(--radius); box-shadow: var(--shadow-lg);
|
|
|
padding: 10px 14px; font-size: 13px;
|
|
|
}
|
|
|
.wf-toast .msg { flex: 1; }
|
|
|
.wf-toast.ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
|
|
|
.wf-toast.err { border-color: color-mix(in srgb, var(--err) 45%, transparent); background: var(--err-soft); }
|
|
|
.wf-toast button {
|
|
|
border: none; background: none; color: var(--text-dim); cursor: pointer; font-size: 11px; padding: 2px;
|
|
|
}
|
|
|
|
|
|
/* ================= form grid (dialog bodies) ================= */
|
|
|
.wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; }
|
|
|
.wf-formgrid .wide { grid-column: 1 / -1; }
|
|
|
@media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } }
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 8: Export + mount** — `index.ts` gains `export * from './toast'` and `export * from './formgrid'` (NOT toast-store — internal). In `apps/hq-web/src/main.tsx` import `ToastProvider` from `@sims/ui` and wrap: `<ToastProvider><HashRouter>…</HashRouter></ToastProvider>` inside `App`.
|
|
|
|
|
|
- [ ] **Step 9: Verify** — typecheck 0; `npm test` zero failures (3 new).
|
|
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add packages/ui/src/toast-store.ts packages/ui/src/toast.tsx packages/ui/src/formgrid.tsx packages/ui/test/toast-store.test.ts packages/ui/src/tokens.css packages/ui/src/index.ts apps/hq-web/src/main.tsx
|
|
|
git commit -m "feat(ui): Toaster with pure store (TDD) + FormGrid; provider mounted at root"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 3: Backend — reminder settings + schedule endpoints (TDD)
|
|
|
|
|
|
**Files:**
|
|
|
- Modify: `apps/hq/src/repos-reminders.ts` (export `parseDayOffsets`; add `listSchedules`, `insertSchedule`)
|
|
|
- Modify: `apps/hq/src/api.ts` (three routes beside the existing `/settings/company` block)
|
|
|
- Test: `apps/hq/test/settings-reminders.test.ts`
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Consumes: existing `SCHEDULE_DEFAULTS`, `resolveSchedule`, `setSetting`, `writeAudit`, `requireOwner`; seeded setting keys `reminders.overdue_days` (default '7') / `reminders.renewal_days` (default '15').
|
|
|
- Produces: `ScheduleRow { id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null; dayOffsets: string; subject: string | null; body: string | null }`; `listSchedules(db): ScheduleRow[]`; `insertSchedule(db, userId, input: { ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string }): ScheduleRow`. Routes: `GET /settings/reminders` → `{ ok, overdueDays: number, renewalDays: number, schedules: ScheduleRow[], total: number }` (owner); `PUT /settings/reminders` body `{ overdueDays?, renewalDays? }` (ints ≥ 1, owner, audited via `setSetting`); `POST /settings/schedules` → `{ ok, schedule }` (owner, audited, append-only).
|
|
|
|
|
|
- [ ] **Step 1: Write the failing test** — `apps/hq/test/settings-reminders.test.ts`. Mirror the setup style of `apps/hq/test/reminder-schedule.test.ts` (in-memory `openDb(':memory:')` + `seedIfEmpty`). Test cases:
|
|
|
|
|
|
```ts
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
import { openDb } from '../src/db'
|
|
|
import { seedIfEmpty } from '../src/seed'
|
|
|
import { insertSchedule, listSchedules, resolveSchedule } from '../src/repos-reminders'
|
|
|
|
|
|
const fresh = () => { const db = openDb(':memory:'); seedIfEmpty(db); return db }
|
|
|
|
|
|
describe('schedule settings', () => {
|
|
|
it('lists the two seeded open-ended rows', () => {
|
|
|
const rows = listSchedules(fresh())
|
|
|
expect(rows.map((r) => r.ruleKind).sort()).toEqual(['invoice_overdue', 'quote_followup'])
|
|
|
})
|
|
|
it('insertSchedule appends a dated row that resolveSchedule picks from its date', () => {
|
|
|
const db = fresh()
|
|
|
const row = insertSchedule(db, 'u1', {
|
|
|
ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: ' 2, 5,5, 9 ',
|
|
|
})
|
|
|
expect(row.dayOffsets).toBe('2,5,9') // normalized: trimmed, deduped, sorted
|
|
|
expect(resolveSchedule(db, 'quote_followup', '2026-08-02').dayOffsets).toEqual([2, 5, 9])
|
|
|
expect(resolveSchedule(db, 'quote_followup', '2026-07-20').dayOffsets).toEqual([3, 7, 14])
|
|
|
expect(listSchedules(db)).toHaveLength(3) // append-only: old row still there
|
|
|
})
|
|
|
it('rejects bad input', () => {
|
|
|
const db = fresh()
|
|
|
expect(() => insertSchedule(db, 'u1', { ruleKind: 'nope', effectiveFrom: '2026-08-01', dayOffsets: '3' })).toThrow()
|
|
|
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '01-08-2026', dayOffsets: '3' })).toThrow()
|
|
|
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: 'x,-2' })).toThrow()
|
|
|
})
|
|
|
it('audits the insert', () => {
|
|
|
const db = fresh()
|
|
|
const row = insertSchedule(db, 'u1', { ruleKind: 'invoice_overdue', effectiveFrom: '2026-09-01', dayOffsets: '10,20' })
|
|
|
const audit = db.prepare(`SELECT * FROM audit_log WHERE entity='reminder_schedule' AND entity_id=?`).get(row.id)
|
|
|
expect(audit).toBeDefined()
|
|
|
})
|
|
|
})
|
|
|
```
|
|
|
|
|
|
Adjust the audit-table column names to whatever `apps/hq/test/` siblings assert against (read one first — e.g. `clients.test.ts`); `resolveSchedule`'s exact signature likewise (read `repos-reminders.ts:246+`). Keep assertions semantically identical.
|
|
|
|
|
|
- [ ] **Step 2: Run — expect FAIL** — `npm test -- settings-reminders` → `insertSchedule`/`listSchedules` not exported.
|
|
|
|
|
|
- [ ] **Step 3: Implement in `repos-reminders.ts`**
|
|
|
|
|
|
Export the existing private `parseDayOffsets`. Then append:
|
|
|
|
|
|
```ts
|
|
|
export interface ScheduleRow {
|
|
|
id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null
|
|
|
dayOffsets: string; subject: string | null; body: string | null
|
|
|
}
|
|
|
|
|
|
const SCHEDULE_KINDS = Object.keys(SCHEDULE_DEFAULTS)
|
|
|
|
|
|
/** All dated schedule rows, newest first within each rule kind (bounded set). */
|
|
|
export function listSchedules(db: DB): ScheduleRow[] {
|
|
|
const rows = db.prepare(
|
|
|
`SELECT id, rule_kind, effective_from, effective_to, day_offsets, subject, body
|
|
|
FROM reminder_schedule ORDER BY rule_kind, effective_from DESC`,
|
|
|
).all() as { id: string; rule_kind: string; effective_from: string; effective_to: string | null; day_offsets: string; subject: string | null; body: string | null }[]
|
|
|
return rows.map((r) => ({
|
|
|
id: r.id, ruleKind: r.rule_kind, effectiveFrom: r.effective_from, effectiveTo: r.effective_to,
|
|
|
dayOffsets: r.day_offsets, subject: r.subject, body: r.body,
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
/** Append a NEW dated cadence row (rule 3: config change = new row, never an edit). */
|
|
|
export function insertSchedule(db: DB, userId: string, input: {
|
|
|
ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string
|
|
|
}): ScheduleRow {
|
|
|
if (!SCHEDULE_KINDS.includes(input.ruleKind)) throw new Error(`Unknown rule kind: ${input.ruleKind}`)
|
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.effectiveFrom)) throw new Error('effectiveFrom must be YYYY-MM-DD')
|
|
|
const offsets = parseDayOffsets(input.dayOffsets)
|
|
|
if (offsets.length === 0) throw new Error('dayOffsets must contain at least one positive integer')
|
|
|
const id = uuidv7()
|
|
|
const csv = offsets.join(',')
|
|
|
db.transaction(() => {
|
|
|
db.prepare(
|
|
|
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
|
|
|
VALUES (?, ?, ?, NULL, ?, ?, ?)`,
|
|
|
).run(id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null)
|
|
|
writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, {
|
|
|
ruleKind: input.ruleKind, effectiveFrom: input.effectiveFrom, dayOffsets: csv,
|
|
|
})
|
|
|
})()
|
|
|
return listSchedules(db).find((r) => r.id === id)!
|
|
|
}
|
|
|
```
|
|
|
|
|
|
(Match the file's existing imports — `uuidv7`, `writeAudit`, `DB` are already imported there.)
|
|
|
|
|
|
- [ ] **Step 4: Run — expect PASS** — `npm test -- settings-reminders`.
|
|
|
|
|
|
- [ ] **Step 5: Routes in `apps/hq/src/api.ts`** — add beside the `/settings/company` block. Read how the scan reads `reminders.overdue_days` (grep it) and reuse that helper for GET:
|
|
|
|
|
|
```ts
|
|
|
// ---------- reminder cadences (owner; dated rows are append-only — rule 3) ----------
|
|
|
r.get('/settings/reminders', requireAuth, requireOwner, (_req, res) => {
|
|
|
const schedules = listSchedules(db)
|
|
|
res.json({
|
|
|
ok: true,
|
|
|
overdueDays: readIntSetting('reminders.overdue_days', 7),
|
|
|
renewalDays: readIntSetting('reminders.renewal_days', 15),
|
|
|
schedules, total: schedules.length,
|
|
|
})
|
|
|
})
|
|
|
r.put('/settings/reminders', requireAuth, requireOwner, (req, res) => {
|
|
|
const body = req.body as { overdueDays?: unknown; renewalDays?: unknown }
|
|
|
try {
|
|
|
for (const [field, key] of [['overdueDays', 'reminders.overdue_days'], ['renewalDays', 'reminders.renewal_days']] as const) {
|
|
|
const v = body[field]
|
|
|
if (v === undefined) continue
|
|
|
if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) throw new Error(`${field} must be a positive integer`)
|
|
|
setSetting(db, staffId(res), key, String(v))
|
|
|
}
|
|
|
res.json({ ok: true })
|
|
|
} catch (err) {
|
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
|
}
|
|
|
})
|
|
|
r.post('/settings/schedules', requireAuth, requireOwner, (req, res) => {
|
|
|
const body = req.body as { ruleKind?: string; effectiveFrom?: string; dayOffsets?: string; subject?: string; body?: string }
|
|
|
try {
|
|
|
const schedule = insertSchedule(db, staffId(res), {
|
|
|
ruleKind: body.ruleKind ?? '', effectiveFrom: body.effectiveFrom ?? '', dayOffsets: body.dayOffsets ?? '',
|
|
|
...(body.subject !== undefined && body.subject !== '' ? { subject: body.subject } : {}),
|
|
|
...(body.body !== undefined && body.body !== '' ? { body: body.body } : {}),
|
|
|
})
|
|
|
res.json({ ok: true, schedule })
|
|
|
} catch (err) {
|
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
|
}
|
|
|
})
|
|
|
```
|
|
|
|
|
|
`readIntSetting` — write it local to api.ts if no equivalent exists (grep first): reads `setting` value, `Number()` it, fallback on NaN. `staffId(res)` — use whatever the neighboring routes use to get the acting user id.
|
|
|
|
|
|
- [ ] **Step 6: Verify** — typecheck 0; full `npm test` zero failures.
|
|
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq/src/repos-reminders.ts apps/hq/src/api.ts apps/hq/test/settings-reminders.test.ts
|
|
|
git commit -m "feat(settings): reminder day-settings + append-only dated schedule endpoints (owner, audited)"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 4: Backend — sharing default (TDD)
|
|
|
|
|
|
**Files:**
|
|
|
- Modify: `apps/hq/src/repos-shares.ts` (`mintShare` default from setting)
|
|
|
- Modify: `apps/hq/src/api.ts` (GET/PUT `/settings/sharing`)
|
|
|
- Test: `apps/hq/test/settings-sharing.test.ts`
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Produces: setting row `share.default_expiry_days` (`'never'` or a positive-int string; absent → 30). `GET /settings/sharing` → `{ ok, defaultExpiryDays: number | null }` (null = never; owner). `PUT /settings/sharing` body `{ defaultExpiryDays: number | null }` (owner, audited via `setSetting`). `mintShare` unchanged signature; explicit `opts.expiresDays` always wins.
|
|
|
|
|
|
- [ ] **Step 1: Failing test** — `apps/hq/test/settings-sharing.test.ts` (mirror `shares.test.ts` setup — it creates a client + document to mint against; copy that arrange block):
|
|
|
|
|
|
```ts
|
|
|
// cases (exact arrange code copied from shares.test.ts):
|
|
|
// 1. no setting row → mintShare default expiry ≈ now+30d (assert expiresAt date prefix)
|
|
|
// 2. setting '7' → expiry ≈ now+7d
|
|
|
// 3. setting 'never' → expiresAt null
|
|
|
// 4. explicit opts.expiresDays: 3 wins over setting '7'; explicit null wins too
|
|
|
// 5. setting garbage 'abc' → falls back to 30
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 2: FAIL** — `npm test -- settings-sharing`.
|
|
|
|
|
|
- [ ] **Step 3: Implement** — in `repos-shares.ts`, above `mintShare`:
|
|
|
|
|
|
```ts
|
|
|
/** Owner-set default link lifetime; 'never' → null, absent/garbage → 30. */
|
|
|
function defaultExpiryDays(db: DB): number | null {
|
|
|
const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`)
|
|
|
.get() as { value: string } | undefined
|
|
|
if (row === undefined) return 30
|
|
|
if (row.value === 'never') return null
|
|
|
const n = Number(row.value)
|
|
|
return Number.isInteger(n) && n > 0 ? n : 30
|
|
|
}
|
|
|
```
|
|
|
|
|
|
and in `mintShare` change the default line to:
|
|
|
|
|
|
```ts
|
|
|
const expiresDays = opts.expiresDays === undefined ? defaultExpiryDays(db) : opts.expiresDays
|
|
|
```
|
|
|
|
|
|
Routes in api.ts (beside the other settings routes):
|
|
|
|
|
|
```ts
|
|
|
// ---------- sharing defaults (owner) ----------
|
|
|
r.get('/settings/sharing', requireAuth, requireOwner, (_req, res) => {
|
|
|
const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`)
|
|
|
.get() as { value: string } | undefined
|
|
|
const days = row === undefined ? 30 : row.value === 'never' ? null : Number(row.value)
|
|
|
res.json({ ok: true, defaultExpiryDays: days !== null && (!Number.isInteger(days) || days < 1) ? 30 : days })
|
|
|
})
|
|
|
r.put('/settings/sharing', requireAuth, requireOwner, (req, res) => {
|
|
|
const v = (req.body as { defaultExpiryDays?: unknown }).defaultExpiryDays
|
|
|
if (v !== null && (typeof v !== 'number' || !Number.isInteger(v) || v < 1)) {
|
|
|
res.status(400).json({ ok: false, error: 'defaultExpiryDays must be a positive integer or null (never)' })
|
|
|
return
|
|
|
}
|
|
|
setSetting(db, staffId(res), 'share.default_expiry_days', v === null ? 'never' : String(v))
|
|
|
res.json({ ok: true, defaultExpiryDays: v })
|
|
|
})
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 4: PASS** — focused, then full suite + typecheck.
|
|
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq/src/repos-shares.ts apps/hq/src/api.ts apps/hq/test/settings-sharing.test.ts
|
|
|
git commit -m "feat(settings): configurable default share-link expiry (owner, audited)"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 5: Send-email modal + document ConfirmDialogs
|
|
|
|
|
|
**Files:**
|
|
|
- Create: `apps/hq-web/src/components/SendDialog.tsx`
|
|
|
- Modify: `apps/hq-web/src/pages/DocumentView.tsx`
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Consumes: `Dialog`, `ConfirmDialog`, `useToast`, `Field` from `@sims/ui`; `getEmailStatus`, `documentAction`, types from `../api`.
|
|
|
- Produces: `SendDialog(props: { open: boolean; onClose: () => void; doc: Doc; client: Client | undefined; onSend: (to: string, subject: string) => void })`.
|
|
|
|
|
|
**READ THE CURRENT FILE FIRST** — DocumentView was modified by two workstreams (it now also has convert-and-send + supersede confirms). Enumerate every popup with `grep -n "window.prompt\|window.confirm" apps/hq-web/src/pages/DocumentView.tsx` and convert ALL of them.
|
|
|
|
|
|
- [ ] **Step 1: Create `SendDialog.tsx`**
|
|
|
|
|
|
```tsx
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
import { Button, Dialog, Field, Notice } from '@sims/ui'
|
|
|
import { getEmailStatus, type Client, type Doc, type EmailStatus } from '../api'
|
|
|
|
|
|
const OTHER = '__other__'
|
|
|
|
|
|
/** Real send composer — To from the client's contacts, Subject prefilled, Gmail state inline. */
|
|
|
export function SendDialog(props: {
|
|
|
open: boolean
|
|
|
onClose: () => void
|
|
|
doc: Doc
|
|
|
client: Client | undefined
|
|
|
onSend: (to: string, subject: string) => void
|
|
|
}) {
|
|
|
const emails = useMemo(
|
|
|
() => (props.client?.contacts ?? []).map((c) => c.email).filter((e): e is string => e !== undefined && e !== ''),
|
|
|
[props.client],
|
|
|
)
|
|
|
const [pick, setPick] = useState('')
|
|
|
const [other, setOther] = useState('')
|
|
|
const [subject, setSubject] = useState('')
|
|
|
const [gmail, setGmail] = useState<EmailStatus | undefined>()
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (!props.open) return
|
|
|
setPick(emails[0] ?? OTHER)
|
|
|
setOther('')
|
|
|
setSubject(`${props.doc.docType} ${props.doc.docNo ?? ''}`.trim())
|
|
|
getEmailStatus().then(setGmail).catch(() => setGmail(undefined))
|
|
|
}, [props.open, emails, props.doc])
|
|
|
|
|
|
const to = pick === OTHER ? other.trim() : pick
|
|
|
const down = gmail !== undefined && (!gmail.connected || gmail.dead)
|
|
|
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to) && subject.trim() !== '' && !down
|
|
|
|
|
|
return (
|
|
|
<Dialog
|
|
|
open={props.open}
|
|
|
onClose={props.onClose}
|
|
|
title="Send by email"
|
|
|
footer={
|
|
|
<>
|
|
|
<Button pill onClick={props.onClose}>Cancel</Button>
|
|
|
<Button pill tone="primary" onClick={() => { if (valid) { props.onSend(to, subject.trim()); props.onClose() } }}>
|
|
|
Send
|
|
|
</Button>
|
|
|
</>
|
|
|
}
|
|
|
>
|
|
|
{down && (
|
|
|
<Notice tone="warn">Gmail is disconnected — run gmail-connect on the server, then retry.</Notice>
|
|
|
)}
|
|
|
<Field label="To">
|
|
|
<select className="wf" value={pick} onChange={(e) => setPick(e.target.value)}>
|
|
|
{emails.map((e) => <option key={e} value={e}>{e}</option>)}
|
|
|
<option value={OTHER}>Other…</option>
|
|
|
</select>
|
|
|
</Field>
|
|
|
{pick === OTHER && (
|
|
|
<Field label="Email address">
|
|
|
<input className="wf" type="email" autoFocus value={other} onChange={(e) => setOther(e.target.value)} />
|
|
|
</Field>
|
|
|
)}
|
|
|
<Field label="Subject">
|
|
|
<input className="wf" value={subject} onChange={(e) => setSubject(e.target.value)} />
|
|
|
</Field>
|
|
|
{gmail?.address !== undefined && (
|
|
|
<p style={{ fontSize: 12, color: 'var(--text-dim)', margin: 0 }}>Sends from {gmail.address}.</p>
|
|
|
)}
|
|
|
</Dialog>
|
|
|
)
|
|
|
}
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 2: Refit DocumentView**
|
|
|
|
|
|
1. Imports: add `ConfirmDialog, useToast` to the `@sims/ui` import; `import { SendDialog } from '../components/SendDialog'`.
|
|
|
2. State: `const toast = useToast()`, `const [sending, setSending] = useState(false)`, and one confirm slot: `const [confirm, setConfirm] = useState<{ title: string; body: string; label: string; tone?: 'danger'; run: () => void } | undefined>()`.
|
|
|
3. Replace the whole `send()` prompt function: the Send button does `setSending(true)`; render `<SendDialog open={sending} onClose={() => setSending(false)} doc={doc} client={client.data} onSend={(to, subject) => act('send', { to, subject })} />`.
|
|
|
4. Replace EVERY `window.confirm(...)` site with `setConfirm({ title, body, label, tone, run: () => act(...) })` using each site's existing copy (cancel + supersede get `tone: 'danger'`); render once near the end:
|
|
|
|
|
|
```tsx
|
|
|
{confirm !== undefined && (
|
|
|
<ConfirmDialog
|
|
|
open
|
|
|
onClose={() => setConfirm(undefined)}
|
|
|
onConfirm={confirm.run}
|
|
|
title={confirm.title}
|
|
|
body={confirm.body}
|
|
|
confirmLabel={confirm.label}
|
|
|
{...(confirm.tone !== undefined ? { tone: confirm.tone } : {})}
|
|
|
/>
|
|
|
)}
|
|
|
```
|
|
|
|
|
|
5. Toasts in `act()`: on success `toast.ok(...)` with a friendly label per action (`issue` → 'Number issued', `send` → \`Sent to ${to}\` — pass a message argument through act or toast at call sites); on failure replace `setActionErr(friendly(...))` with `toast.err(friendly(...))` and delete the `actionErr` state + Notice.
|
|
|
6. Keep ShareActions untouched except: its revoke button gets a ConfirmDialog too (same slot pattern, inside ShareActions with its own local state) — optional if time-boxed; if skipped, note it in the report.
|
|
|
|
|
|
- [ ] **Step 3: Verify** — typecheck; full suite; `grep -c "window\." apps/hq-web/src/pages/DocumentView.tsx` → 0.
|
|
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq-web/src/components/SendDialog.tsx apps/hq-web/src/pages/DocumentView.tsx
|
|
|
git commit -m "feat(hq-web): send-email composer dialog + styled confirms — window.prompt/confirm eliminated"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 6: Client edit + New-client dialog
|
|
|
|
|
|
**Files:**
|
|
|
- Create: `apps/hq-web/src/components/ClientFormDialog.tsx`
|
|
|
- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` (Edit button), `apps/hq-web/src/pages/Clients.tsx` (replace inline NewClientForm)
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Consumes: `Dialog`, `FormGrid`, `FormField`, `Button`, `Notice`, `useToast`; `createClient`, `patchClient`, `type Client`, `type ClientContact` from `../api`.
|
|
|
- Produces: `ClientFormDialog(props: { open: boolean; onClose: () => void; initial?: Client; onSaved: (c: Client) => void })` — create mode when `initial` is undefined.
|
|
|
|
|
|
- [ ] **Step 1: Create `ClientFormDialog.tsx`**
|
|
|
|
|
|
```tsx
|
|
|
import { useEffect, useState } from 'react'
|
|
|
import { Button, Dialog, FormField, FormGrid, Notice, useToast } from '@sims/ui'
|
|
|
import { createClient, patchClient, type Client, type ClientContact } from '../api'
|
|
|
|
|
|
interface ContactDraft { name: string; email: string; phone: string; role: string }
|
|
|
const EMPTY_CONTACT: ContactDraft = { name: '', email: '', phone: '', role: '' }
|
|
|
|
|
|
/** Create/edit a client — identity, address, notes, and the contacts repeater. */
|
|
|
export function ClientFormDialog(props: {
|
|
|
open: boolean
|
|
|
onClose: () => void
|
|
|
initial?: Client
|
|
|
onSaved: (c: Client) => void
|
|
|
}) {
|
|
|
const toast = useToast()
|
|
|
const editing = props.initial !== undefined
|
|
|
const [f, setF] = useState({ name: '', gstin: '', stateCode: '32', address: '', notes: '' })
|
|
|
const [contacts, setContacts] = useState<ContactDraft[]>([])
|
|
|
const [error, setError] = useState<string | undefined>()
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (!props.open) return
|
|
|
const c = props.initial
|
|
|
setError(undefined)
|
|
|
setF({
|
|
|
name: c?.name ?? '', gstin: c?.gstin ?? '', stateCode: c?.stateCode ?? '32',
|
|
|
address: c?.address ?? '', notes: c?.notes ?? '',
|
|
|
})
|
|
|
setContacts((c?.contacts ?? []).map((ct) => ({
|
|
|
name: ct.name, email: ct.email ?? '', phone: ct.phone ?? '', role: ct.role ?? '',
|
|
|
})))
|
|
|
}, [props.open, props.initial])
|
|
|
|
|
|
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
|
|
|
setF((p) => ({ ...p, [k]: e.target.value }))
|
|
|
const setContact = (i: number, k: keyof ContactDraft) => (e: { target: { value: string } }) =>
|
|
|
setContacts((prev) => prev.map((c, j) => (j === i ? { ...c, [k]: e.target.value } : c)))
|
|
|
|
|
|
const save = () => {
|
|
|
if (f.name.trim() === '') { setError('Name is required'); return }
|
|
|
if (!/^\d{2}$/.test(f.stateCode.trim())) { setError('State code must be two digits'); return }
|
|
|
const cleaned: ClientContact[] = contacts
|
|
|
.filter((c) => c.name.trim() !== '' || c.email.trim() !== '' || c.phone.trim() !== '')
|
|
|
.map((c) => ({
|
|
|
name: c.name.trim() !== '' ? c.name.trim() : (c.email.trim() !== '' ? c.email.trim() : c.phone.trim()),
|
|
|
...(c.email.trim() !== '' ? { email: c.email.trim() } : {}),
|
|
|
...(c.phone.trim() !== '' ? { phone: c.phone.trim() } : {}),
|
|
|
...(c.role.trim() !== '' ? { role: c.role.trim() } : {}),
|
|
|
}))
|
|
|
const body = {
|
|
|
name: f.name.trim(), stateCode: f.stateCode.trim(),
|
|
|
gstin: f.gstin.trim().toUpperCase(), address: f.address, notes: f.notes, contacts: cleaned,
|
|
|
}
|
|
|
setError(undefined)
|
|
|
setSaving(true)
|
|
|
const call = editing ? patchClient(props.initial!.id, body) : createClient(body)
|
|
|
call
|
|
|
.then((c) => { toast.ok(editing ? 'Client saved' : `Client ${c.code} created`); props.onSaved(c); props.onClose() })
|
|
|
.catch((e: Error) => setError(e.message))
|
|
|
.finally(() => setSaving(false))
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
<Dialog
|
|
|
open={props.open}
|
|
|
onClose={props.onClose}
|
|
|
title={editing ? `Edit ${props.initial!.name}` : 'New client'}
|
|
|
size="lg"
|
|
|
footer={
|
|
|
<>
|
|
|
<Button pill onClick={props.onClose}>Cancel</Button>
|
|
|
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'Create client'}</Button>
|
|
|
</>
|
|
|
}
|
|
|
>
|
|
|
<FormGrid>
|
|
|
<FormField label="Name" wide><input className="wf" autoFocus value={f.name} onChange={set('name')} /></FormField>
|
|
|
<FormField label="GSTIN (optional)"><input className="wf mono" value={f.gstin} onChange={set('gstin')} /></FormField>
|
|
|
<FormField label="State code"><input className="wf" style={{ width: 80 }} value={f.stateCode} onChange={set('stateCode')} /></FormField>
|
|
|
<FormField label="Address" wide><textarea className="wf" rows={2} value={f.address} onChange={set('address')} /></FormField>
|
|
|
<FormField label="Notes" wide><textarea className="wf" rows={2} value={f.notes} onChange={set('notes')} /></FormField>
|
|
|
</FormGrid>
|
|
|
<h3 style={{ margin: '14px 0 6px', fontSize: 13 }}>Contacts</h3>
|
|
|
{contacts.map((c, i) => (
|
|
|
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
|
|
<input className="wf" placeholder="Name" value={c.name} onChange={setContact(i, 'name')} />
|
|
|
<input className="wf" placeholder="Email" type="email" value={c.email} onChange={setContact(i, 'email')} />
|
|
|
<input className="wf" placeholder="Phone" value={c.phone} onChange={setContact(i, 'phone')} />
|
|
|
<input className="wf" placeholder="Role" style={{ maxWidth: 110 }} value={c.role} onChange={setContact(i, 'role')} />
|
|
|
<Button onClick={() => setContacts((prev) => prev.filter((_x, j) => j !== i))}>✕</Button>
|
|
|
</div>
|
|
|
))}
|
|
|
<Button onClick={() => setContacts((prev) => [...prev, { ...EMPTY_CONTACT }])}>+ Add contact</Button>
|
|
|
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
|
|
</Dialog>
|
|
|
)
|
|
|
}
|
|
|
```
|
|
|
|
|
|
Note: `patchClient` body includes `gstin: ''` when cleared — verify the server accepts empty-string gstin (repos-clients `assertGstin` only runs when non-null/non-undefined; `''` may fail — if it does, send `gstin: f.gstin.trim() === '' ? null : f.gstin.trim().toUpperCase()` and loosen the api.ts client type accordingly; test against the running dev server or the repo tests).
|
|
|
|
|
|
- [ ] **Step 2: ClientDetail** — add `const [editing, setEditing] = useState(false)`, an `Edit` button FIRST in the RecordHeader actions cluster, and render `<ClientFormDialog open={editing} onClose={() => setEditing(false)} initial={c} onSaved={() => client.reload()} />`.
|
|
|
|
|
|
- [ ] **Step 3: Clients** — delete the inline `NewClientForm` function entirely; `creating` state now controls `<ClientFormDialog open={creating} onClose={() => setCreating(false)} onSaved={(c) => { reload(); nav(`/clients/${c.id}`) }} />`. The `?new=1` effect stays as-is. Remove now-unused imports (`Field`, possibly `Notice`).
|
|
|
|
|
|
- [ ] **Step 4: Verify** — typecheck; suite; manual sanity deferred to Task 10.
|
|
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq-web/src/components/ClientFormDialog.tsx apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/pages/Clients.tsx
|
|
|
git commit -m "feat(hq-web): client create/edit dialog with contacts repeater — clients finally editable"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 7: Remaining edit/create dialogs (AMC, plans, interactions, employees, modules)
|
|
|
|
|
|
**Files:**
|
|
|
- Modify: `apps/hq-web/src/pages/client-forms.tsx` (forms become dialog-hosted; keep exports)
|
|
|
- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` (buttons open dialogs; Edit per AMC/plan/interaction row; deactivate confirms)
|
|
|
- Modify: `apps/hq-web/src/pages/Employees.tsx`, `apps/hq-web/src/pages/Modules.tsx`
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Consumes: kit + existing api calls — `updateAmc`, `updateRecurringPlan`, `updateInteraction` (all exist, currently unused for full edits), `patchEmployee`, `resetEmployeePassword`, `createModule`, `addPrice`.
|
|
|
- Produces (client-forms.tsx, replacing the inline variants): `AmcDialog({ open, onClose, clientId, initial?: AmcContract, onDone })`, `PlanDialog({ open, onClose, clientId, options, initial?: RecurringPlan, onDone })`, `InteractionDialog({ open, onClose, clientId, types, initial?: Interaction, onDone })`. Employees/Modules keep their forms in-file but dialog-hosted. `PaymentForm` stays an inline card (fast entry — spec §3 excludes it).
|
|
|
|
|
|
- [ ] **Step 1: client-forms.tsx** — convert `NewAmcForm` → `AmcDialog`, `NewRecurringForm` → `PlanDialog`, `LogInteractionForm` → `InteractionDialog`. Pattern per dialog (same as ClientFormDialog): `initial` prefills an edit; save calls create or update endpoint; `useToast().ok('AMC saved' | 'Plan saved' | 'Interaction saved')`; `FormGrid` layout; footer pills. Field sets and validation copy IDENTICAL to today's inline forms (they move, not change). Delete the replaced inline forms; keep `PaymentForm`, `AssignModuleForm`, `AccountOwnerSelect`, `ClientModuleStatusSelect`, `RowDate` unchanged.
|
|
|
|
|
|
- [ ] **Step 2: ClientDetail wiring** — per tab:
|
|
|
- AMC: toolbar `New AMC` button (isOwner) + per-row `Edit` button (isOwner) → `AmcDialog`; `Deactivate` → ConfirmDialog (danger). One dialog-state slot per kind: `const [amcDialog, setAmcDialog] = useState<{ initial?: AmcContract } | undefined>()`.
|
|
|
- Payments & plans: same for `PlanDialog` (isOwner).
|
|
|
- Interactions: `Log interaction` button + per-row `Edit` → `InteractionDialog` (all roles). The inline follow-up date input in the table stays (quick edit).
|
|
|
- Reuse one `ConfirmDialog` slot in the page (same pattern as DocumentView Task 5).
|
|
|
- [ ] **Step 3: Employees.tsx** — the three inline forms (`AddEmployeeForm`, `EditEmployeeForm`, `ResetPasswordForm`) become Dialog-hosted (keep their internals; wrap render in `Dialog` with pill footer, single open-state slot). `Deactivate` → ConfirmDialog (danger, body names the employee). Toasts on all four mutations.
|
|
|
- [ ] **Step 4: Modules.tsx** — `NewModuleForm` → dialog; price-book add-row toolbar → `Add price` button opening a small dialog (kind/edition/₹/effective-from — same fields). Quote-content editor stays inline (live preview). Toasts on module create / price add / quote-content save.
|
|
|
- [ ] **Step 5: Verify** — typecheck; full suite; `grep -rn "window.confirm" apps/hq-web/src` → 0.
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq-web/src/pages/client-forms.tsx apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/pages/Employees.tsx apps/hq-web/src/pages/Modules.tsx
|
|
|
git commit -m "feat(hq-web): AMC/plan/interaction/employee/module dialogs — edit everywhere, confirmed deactivations"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 8: Settings hub
|
|
|
|
|
|
**Files:**
|
|
|
- Modify: `apps/hq-web/src/api.ts` (new settings calls)
|
|
|
- Create: `apps/hq-web/src/pages/Settings.tsx`
|
|
|
- Modify: `apps/hq-web/src/pages/DocumentTemplate.tsx` (export the editor as `TemplatePanel`; page shell removed)
|
|
|
- Modify: `apps/hq-web/src/main.tsx` (routes), `apps/hq-web/src/nav.ts` (Settings item), `apps/hq-web/src/Layout.tsx` (palette static items)
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Consumes: Tasks 3–4 endpoints; existing `getCompanyProfile/putCompanyProfile`, `getEmailStatus`, `getAwsRanking`; `ACCENTS/getAccent/setAccent/getThemeMode/setThemeMode` from `@sims/ui`.
|
|
|
- Produces (api.ts):
|
|
|
|
|
|
```ts
|
|
|
export interface ScheduleRow {
|
|
|
id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null
|
|
|
dayOffsets: string; subject: string | null; body: string | null
|
|
|
}
|
|
|
export interface ReminderSettings { overdueDays: number; renewalDays: number; schedules: ScheduleRow[]; total: number }
|
|
|
export const getReminderSettings = (): Promise<ReminderSettings> => apiFetch('/settings/reminders')
|
|
|
export const putReminderSettings = (body: { overdueDays?: number; renewalDays?: number }): Promise<void> =>
|
|
|
apiFetch('/settings/reminders', { method: 'PUT', body: JSON.stringify(body) })
|
|
|
export const createSchedule = (body: { ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string }): Promise<ScheduleRow> =>
|
|
|
apiFetch<{ schedule: ScheduleRow }>('/settings/schedules', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.schedule)
|
|
|
export const getSharingSettings = (): Promise<{ defaultExpiryDays: number | null }> => apiFetch('/settings/sharing')
|
|
|
export const putSharingSettings = (defaultExpiryDays: number | null): Promise<void> =>
|
|
|
apiFetch('/settings/sharing', { method: 'PUT', body: JSON.stringify({ defaultExpiryDays }) })
|
|
|
```
|
|
|
|
|
|
- Produces (Settings page): route `/settings`, section in `?s=` (`company | template | reminders | sharing | integrations | appearance`), Pinterest left rail. Owner sees all; staff/manager see only Appearance (rail filtered by `role()`), default section = first visible.
|
|
|
|
|
|
- [ ] **Step 1: api.ts additions** (code above, placed near the other settings calls).
|
|
|
- [ ] **Step 2: DocumentTemplate.tsx** — rename the default page export: extract everything inside the current page (both panes: form column + live preview) into `export function TemplatePanel()`; keep a thin `export function DocumentTemplate()` that just returns `<TemplatePanel />` (compat; removed from routes next step). Drop its owner-redirect (`role() !== 'owner'`) — Settings gates it.
|
|
|
- [ ] **Step 3: Settings.tsx** — new page. Structure (full sections, ~250 lines):
|
|
|
|
|
|
```tsx
|
|
|
// Rail: <aside className="set-rail"> buttons; active from useSearchParams ?s=
|
|
|
// Sections (each a function component in-file):
|
|
|
// CompanyPanel — form over getCompanyProfile/putCompanyProfile (same field list as
|
|
|
// COMPANY_FIELDS in DocumentTemplate.tsx), FormGrid, Save pill, toast.
|
|
|
// TemplatePanel — imported from './DocumentTemplate'.
|
|
|
// RemindersPanel — useData(getReminderSettings); two int inputs + Save (putReminderSettings,
|
|
|
// toast); DataTable of schedules (Rule · From · To · Day offsets(mono) ·
|
|
|
// Subject) + 'New dated row' Button → dialog (ruleKind select
|
|
|
// quote_followup|invoice_overdue, effectiveFrom date, dayOffsets text with
|
|
|
// hint '3,7,14', subject/body optional textareas) → createSchedule → reload
|
|
|
// + toast. Note under the table: 'Rows are never edited — a cadence change
|
|
|
// is a new dated row.'
|
|
|
// SharingPanel — radio: expire after [n] days / never; Save → putSharingSettings, toast.
|
|
|
// IntegrationsPanel — Gmail card (getEmailStatus → ok pill / warn pill + reconnect hint);
|
|
|
// AWS card (getAwsRanking() → 'Last pull month X · ₹total' or EmptyState).
|
|
|
// AppearancePanel — mode toggle + accent swatch row (reuse ThemeSwitcher internals:
|
|
|
// ACCENTS map with labels, setAccent/setThemeMode).
|
|
|
// Owner-only: all but Appearance. Rail hides gated sections for non-owners.
|
|
|
```
|
|
|
|
|
|
CSS (append to `app.css`): `.set-wrap { display:flex; gap:24px } .set-rail { flex:0 0 190px; display:flex; flex-direction:column; gap:2px } .set-rail button { text-align:left; border:none; background:none; padding:8px 12px; border-radius:8px; font:500 13.5px var(--font); color:var(--text-dim); cursor:pointer } .set-rail button:hover { background:var(--bg-hover); color:var(--text) } .set-rail button.active { background:var(--accent-soft); color:var(--accent-strong); font-weight:600 } .set-panel { flex:1; min-width:0; max-width:860px } @media (max-width:900px){ .set-wrap{flex-direction:column} .set-rail{flex-direction:row; flex-wrap:wrap} }`.
|
|
|
|
|
|
- [ ] **Step 4: Routes + nav** — main.tsx: `<Route path="/settings" element={<Settings />} />` and `/settings/template` → `<Navigate to="/settings?s=template" replace />` (import Settings; DocumentTemplate import becomes unused — remove). nav.ts ADMIN: replace the Document Template item with `{ to: '/settings', label: 'Settings', icon: SettingsIcon }` — import `Settings as SettingsIcon` from lucide-react; **no `ownerOnly`** (all roles reach Appearance). Employees keeps `ownerOnly`. Layout.tsx: palette static items — nothing hardcodes Document Template (they derive from NAV_GROUPS) — verify by grep and adjust if needed.
|
|
|
- [ ] **Step 5: Verify** — typecheck; suite.
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq-web/src/api.ts apps/hq-web/src/pages/Settings.tsx apps/hq-web/src/pages/DocumentTemplate.tsx apps/hq-web/src/main.tsx apps/hq-web/src/nav.ts apps/hq-web/src/Layout.tsx apps/hq-web/src/app.css
|
|
|
git commit -m "feat(hq-web): /settings hub — company, template, dated reminder cadences, sharing, integrations, appearance"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 9: Favicon, tab titles, toast sweep
|
|
|
|
|
|
**Files:**
|
|
|
- Create: `apps/hq-web/public/favicon.svg`
|
|
|
- Modify: `apps/hq-web/index.html`, `apps/hq-web/src/Layout.tsx`
|
|
|
- Modify: remaining silent-save sites (grep-driven)
|
|
|
|
|
|
- [ ] **Step 1: Favicon** — `apps/hq-web/public/favicon.svg`:
|
|
|
|
|
|
```svg
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
|
<rect width="32" height="32" rx="7" fill="#115e59"/>
|
|
|
<text x="16" y="21.5" font-family="Segoe UI, Arial, sans-serif" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">HQ</text>
|
|
|
</svg>
|
|
|
```
|
|
|
|
|
|
index.html `<head>` gains `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />` and `<meta name="theme-color" content="#115e59" />`.
|
|
|
|
|
|
- [ ] **Step 2: Tab titles** — in Layout.tsx add:
|
|
|
|
|
|
```tsx
|
|
|
useEffect(() => {
|
|
|
const item = NAV_GROUPS.flatMap((g) => g.items).find((i) =>
|
|
|
i.to === '/' ? location.pathname === '/' : location.pathname.startsWith(i.to))
|
|
|
document.title = item !== undefined && item.to !== '/' ? `${item.label} · SiMS HQ` : 'SiMS HQ'
|
|
|
}, [location.pathname])
|
|
|
```
|
|
|
|
|
|
(Record routes `/clients/:id`, `/documents/:id` resolve to their section title — acceptable.)
|
|
|
|
|
|
- [ ] **Step 3: Toast sweep** — grep for remaining mutations that succeed silently and add `toast.ok`: ClientDetail owner-select change ('Owner updated'), client-status select ('Status updated'), module status/date row edits (skip — too chatty on every keystroke; leave), Modules quote-content save (replace the `saved` Badge with toast), DocumentTemplate/Settings saves (done in Task 8), Dashboard reminder Send/Dismiss ('Reminder sent' / 'Dismissed'). Judgment rule: toast once per explicit user action; never on blur-autosave rows.
|
|
|
- [ ] **Step 4: Verify** — typecheck; suite; dev-server spot check: favicon in tab, title changes per page.
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq-web/public/favicon.svg apps/hq-web/index.html apps/hq-web/src/Layout.tsx apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/pages/Modules.tsx apps/hq-web/src/pages/Dashboard.tsx
|
|
|
git commit -m "feat(hq-web): favicon, per-route tab titles, success-toast sweep"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 10: Full verification pass
|
|
|
|
|
|
**Files:** none new (fix-forward + docs).
|
|
|
|
|
|
- [ ] **Step 1: Gates** — `npm run typecheck` exit 0; `npm test` zero failures (293 + ~10 new).
|
|
|
- [ ] **Step 2: E2E walk** (server from repo root + Vite; owner login):
|
|
|
1. Client 360 → Edit → change name, add a contact with email, save → toast, header updates.
|
|
|
2. Clients → New client (button AND Ctrl-K action) → dialog, create → lands on 360.
|
|
|
3. Document → Send → composer shows contact email prefilled; with Gmail disconnected the send is disabled with hint; Cancel/Esc restores focus.
|
|
|
4. Cancel/credit-note/convert/supersede → styled confirms; zero browser popups anywhere.
|
|
|
5. AMC/plan/interaction: create + edit via dialogs; deactivate confirm; toasts.
|
|
|
6. Employees + Modules dialogs; price add.
|
|
|
7. /settings: every section round-trips (company save, template preview, new dated schedule row appears + old rows untouched, sharing default honored by a fresh share link, integrations status, accent change from Appearance); staff login sees only Appearance; `/settings/template` redirects.
|
|
|
8. Favicon + tab titles; dark mode + 420px pass over dialogs/toasts/settings rail.
|
|
|
- [ ] **Step 3: Docs** — STATUS.md (screens table: Settings row replaces Document Template row; test count), README (Settings + editing bullet touch-up), CLAUDE.md (pages list + test count), spec gets a DELIVERED stamp.
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add -A
|
|
|
git commit -m "docs: record ideal-web-app pass verification"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## Self-review (done at plan-writing time)
|
|
|
|
|
|
- **Spec coverage:** §2 kit → Tasks 1–2; §3 edit-everywhere → 6–7; §4 send/confirms → 5; §5 hub + backend → 3, 4, 8; §6 polish → 9; §8 testing → TDD in 2–4 + Task 10. Payments stay inline per spec §3.
|
|
|
- **Type consistency:** `ScheduleRow` identical in repos (Task 3) and api client (Task 8); `ClientFormDialog.onSaved(c: Client)` matches both call sites; `ConfirmDialog` slot pattern identical in Tasks 5/7.
|
|
|
- **No placeholders:** every code step carries real code or a grep-anchored edit instruction; the two READ-FIRST warnings mark the dual-workstream files.
|