feat(d25): module display order (sort_order) + username login

- module.sort_order (both engines, migrate + pg 008); listModules orders by
  sort_order then code; createModule/updateModule + ModulePatch carry it, so
  the app-wide module order is owner-editable config (default 100 = trails).
- Login accepts a username: field is type=text (autocomplete username),
  relabeled 'Email or username' — a plain 'admin' login now works.
Module + web tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 60dcd823ff
commit 10b849a990

@ -40,9 +40,9 @@ export function Login() {
{/* A real <form> so Enter submits from either field (not just password). */}
<form className="login-card" onSubmit={(e) => { e.preventDefault(); void submit() }}>
<h2 style={{ marginTop: 0 }}>Sign in</h2>
<Field label="Email">
<Field label="Email or username">
<input
className="wf" type="email" value={email} autoFocus
className="wf" type="text" autoComplete="username" value={email} autoFocus
onChange={(e) => setEmail(e.target.value)}
/>
</Field>

@ -174,7 +174,10 @@ CREATE TABLE IF NOT EXISTS module (
-- FieldDef[] { key, label, type, options?, required?, help?, secret?, sort? }.
-- The per-client values live on client_module (field_values + secrets_enc),
-- validated + rendered against this a new module/field needs no code change.
field_spec TEXT NOT NULL DEFAULT '[]'
field_spec TEXT NOT NULL DEFAULT '[]',
-- D25: fixed display order across the app (config over code owner-editable).
-- Lower sorts first; ties broken by code. Default 100 so unseeded modules trail.
sort_order INTEGER NOT NULL DEFAULT 100
);
CREATE TABLE IF NOT EXISTS module_price_book (
id TEXT PRIMARY KEY, module_id TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard',
@ -440,6 +443,9 @@ function migrate(db: SqliteRaw): void {
if (!modCols2.some((c) => c.name === 'field_spec')) {
db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`)
}
if (!modCols2.some((c) => c.name === 'sort_order')) {
db.exec(`ALTER TABLE module ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 100`)
}
rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(db)
}

@ -281,4 +281,8 @@ CREATE INDEX IF NOT EXISTS ix_audit_entity ON audit_log (entity, entity_id);
CREATE INDEX IF NOT EXISTS ix_milestone_cm ON project_milestone (client_module_id);
`,
},
{
id: '008-module-sort-order',
sql: `ALTER TABLE module ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 100;`,
},
]

@ -57,12 +57,13 @@ export interface Module {
allowedKinds: Kind[]; multiSubscription: boolean; active: boolean
quoteContent: string[] // optional "what's included" lines that flow onto quotes
fieldSpec: FieldDef[] // D21: the operational fields this module declares
sortOrder: number // D25: fixed display order (lower first); owner-editable
}
interface ModuleRow {
id: string; code: string; name: string; sac: string
allowed_kinds: string; multi_subscription: number; active: number
quote_content: string; field_spec: string
quote_content: string; field_spec: string; sort_order: number
}
function toModule(r: ModuleRow): Module {
@ -73,13 +74,14 @@ function toModule(r: ModuleRow): Module {
active: r.active === 1,
quoteContent: JSON.parse(r.quote_content) as string[],
fieldSpec: JSON.parse(r.field_spec ?? '[]') as FieldDef[],
sortOrder: r.sort_order ?? 100,
}
}
export interface ModuleInput {
code: string; name: string; sac?: string
allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[]
fieldSpec?: FieldDef[]
fieldSpec?: FieldDef[]; sortOrder?: number
}
export async function getModule(db: DB, id: string): Promise<Module | null> {
@ -88,7 +90,8 @@ export async function getModule(db: DB, id: string): Promise<Module | null> {
}
export async function listModules(db: DB): Promise<Module[]> {
const rows = await db.all<ModuleRow>(`SELECT * FROM module ORDER BY code`)
// D25: fixed business display order (sort_order), code as the stable tiebreak.
const rows = await db.all<ModuleRow>(`SELECT * FROM module ORDER BY sort_order, code`)
return rows.map(toModule)
}
@ -100,11 +103,12 @@ export async function createModule(db: DB, userId: string, input: ModuleInput):
const id = uuidv7()
const fieldSpec = input.fieldSpec !== undefined ? validateFieldSpec(input.fieldSpec) : []
await db.run(
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content, field_spec)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content, field_spec, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, input.code, input.name, input.sac ?? '998313',
JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0,
JSON.stringify(input.quoteContent ?? []), JSON.stringify(fieldSpec),
input.sortOrder ?? 100,
)
const mod = (await getModule(db, id))!
await writeAudit(db, userId, 'create', 'module', id, undefined, mod)
@ -114,7 +118,7 @@ export async function createModule(db: DB, userId: string, input: ModuleInput):
export interface ModulePatch {
name?: string; sac?: string; allowedKinds?: Kind[]
multiSubscription?: boolean; active?: boolean; quoteContent?: string[]
fieldSpec?: FieldDef[]
fieldSpec?: FieldDef[]; sortOrder?: number
}
export async function updateModule(db: DB, userId: string, id: string, patch: ModulePatch): Promise<Module> {
@ -134,6 +138,7 @@ export async function updateModule(db: DB, userId: string, id: string, patch: Mo
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (patch.quoteContent !== undefined) { sets.push('quote_content=?'); args.push(JSON.stringify(patch.quoteContent)) }
if (patch.fieldSpec !== undefined) { sets.push('field_spec=?'); args.push(JSON.stringify(validateFieldSpec(patch.fieldSpec))) }
if (patch.sortOrder !== undefined) { sets.push('sort_order=?'); args.push(patch.sortOrder) }
if (sets.length > 0) {
args.push(id)
await db.run(`UPDATE module SET ${sets.join(', ')} WHERE id=?`, ...args)

Loading…
Cancel
Save