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

@ -174,7 +174,10 @@ CREATE TABLE IF NOT EXISTS module (
-- FieldDef[] { key, label, type, options?, required?, help?, secret?, sort? }. -- FieldDef[] { key, label, type, options?, required?, help?, secret?, sort? }.
-- The per-client values live on client_module (field_values + secrets_enc), -- The per-client values live on client_module (field_values + secrets_enc),
-- validated + rendered against this a new module/field needs no code change. -- 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 ( CREATE TABLE IF NOT EXISTS module_price_book (
id TEXT PRIMARY KEY, module_id TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', 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')) { if (!modCols2.some((c) => c.name === 'field_spec')) {
db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`) 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) rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(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); 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 allowedKinds: Kind[]; multiSubscription: boolean; active: boolean
quoteContent: string[] // optional "what's included" lines that flow onto quotes quoteContent: string[] // optional "what's included" lines that flow onto quotes
fieldSpec: FieldDef[] // D21: the operational fields this module declares fieldSpec: FieldDef[] // D21: the operational fields this module declares
sortOrder: number // D25: fixed display order (lower first); owner-editable
} }
interface ModuleRow { interface ModuleRow {
id: string; code: string; name: string; sac: string id: string; code: string; name: string; sac: string
allowed_kinds: string; multi_subscription: number; active: number 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 { function toModule(r: ModuleRow): Module {
@ -73,13 +74,14 @@ function toModule(r: ModuleRow): Module {
active: r.active === 1, active: r.active === 1,
quoteContent: JSON.parse(r.quote_content) as string[], quoteContent: JSON.parse(r.quote_content) as string[],
fieldSpec: JSON.parse(r.field_spec ?? '[]') as FieldDef[], fieldSpec: JSON.parse(r.field_spec ?? '[]') as FieldDef[],
sortOrder: r.sort_order ?? 100,
} }
} }
export interface ModuleInput { export interface ModuleInput {
code: string; name: string; sac?: string code: string; name: string; sac?: string
allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[] allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[]
fieldSpec?: FieldDef[] fieldSpec?: FieldDef[]; sortOrder?: number
} }
export async function getModule(db: DB, id: string): Promise<Module | null> { 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[]> { 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) return rows.map(toModule)
} }
@ -100,11 +103,12 @@ export async function createModule(db: DB, userId: string, input: ModuleInput):
const id = uuidv7() const id = uuidv7()
const fieldSpec = input.fieldSpec !== undefined ? validateFieldSpec(input.fieldSpec) : [] const fieldSpec = input.fieldSpec !== undefined ? validateFieldSpec(input.fieldSpec) : []
await db.run( await db.run(
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content, field_spec) `INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content, field_spec, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, input.code, input.name, input.sac ?? '998313', id, input.code, input.name, input.sac ?? '998313',
JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0, JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0,
JSON.stringify(input.quoteContent ?? []), JSON.stringify(fieldSpec), JSON.stringify(input.quoteContent ?? []), JSON.stringify(fieldSpec),
input.sortOrder ?? 100,
) )
const mod = (await getModule(db, id))! const mod = (await getModule(db, id))!
await writeAudit(db, userId, 'create', 'module', id, undefined, mod) 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 { export interface ModulePatch {
name?: string; sac?: string; allowedKinds?: Kind[] name?: string; sac?: string; allowedKinds?: Kind[]
multiSubscription?: boolean; active?: boolean; quoteContent?: string[] 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> { 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.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.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.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) { if (sets.length > 0) {
args.push(id) args.push(id)
await db.run(`UPDATE module SET ${sets.join(', ')} WHERE id=?`, ...args) await db.run(`UPDATE module SET ${sets.join(', ')} WHERE id=?`, ...args)

Loading…
Cancel
Save