feat(users): add drag-and-drop reordering for PICs

Introduce pic_sort_order to persist custom user ordering
Replace data table with a custom draggable grid layout
Add API endpoint to handle bulk order updates
This commit is contained in:
2026-05-04 14:07:43 +08:00
parent 30753fdc61
commit 4e40bfd804
6 changed files with 355 additions and 91 deletions

View File

@@ -41,6 +41,29 @@ async function initializeDatabase() {
add column if not exists phone_number text
`
await sql`
alter table users
add column if not exists pic_sort_order integer not null default 0
`
await sql`
update users
set pic_sort_order = seed.sort_order
from (
select
id,
row_number() over (
order by
case when role = 'super_admin' then 0 else 1 end,
created_at asc,
full_name asc
) as sort_order
from users
) as seed
where users.id = seed.id
and users.pic_sort_order = 0
`
await sql`
create table if not exists user_passkeys (
id text primary key,

View File

@@ -15,6 +15,7 @@ type DbUserRow = {
full_name: string
phone_number: string | null
role: UserRole
pic_sort_order: number | string
password_hash: string
must_change_password: boolean
is_active: boolean
@@ -83,6 +84,7 @@ function mapAuthUser(row: DbUserRow): AuthUser {
fullName: row.full_name,
phoneNumber: row.phone_number ? normalizePhoneNumber(row.phone_number) : null,
role: row.role,
picSortOrder: parseInteger(row.pic_sort_order),
isActive: row.is_active,
mustChangePassword: row.must_change_password,
needsPasskeySetup: passkeyCount === 0,
@@ -147,6 +149,7 @@ export async function getUserById(userId: string): Promise<UserAuthRecord | null
users.full_name,
users.phone_number,
users.role,
users.pic_sort_order,
users.password_hash,
users.must_change_password,
users.is_active,
@@ -178,6 +181,7 @@ export async function getUserByUsername(username: string): Promise<UserAuthRecor
users.full_name,
users.phone_number,
users.role,
users.pic_sort_order,
users.password_hash,
users.must_change_password,
users.is_active,
@@ -209,6 +213,7 @@ export async function listUsers(): Promise<ManagedUser[]> {
users.full_name,
users.phone_number,
users.role,
users.pic_sort_order,
users.password_hash,
users.must_change_password,
users.is_active,
@@ -223,7 +228,7 @@ export async function listUsers(): Promise<ManagedUser[]> {
group by user_id
) as passkey_totals on passkey_totals.user_id = users.id
order by
case when users.role = 'super_admin' then 0 else 1 end,
users.pic_sort_order asc,
users.created_at asc
`
@@ -234,18 +239,19 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role' | 'pic_sort_order'>[]>`
select
users.id,
users.full_name,
users.phone_number,
users.role
users.role,
users.pic_sort_order
from users
where users.is_active = true
and users.phone_number is not null
and users.phone_number <> ''
order by
case when users.role = 'super_admin' then 0 else 1 end,
users.pic_sort_order asc,
users.full_name asc
`
@@ -253,7 +259,8 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
id: row.id,
fullName: row.full_name,
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
role: row.role
role: row.role,
picSortOrder: parseInteger(row.pic_sort_order)
}))
}
@@ -261,12 +268,13 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role' | 'pic_sort_order'>[]>`
select
users.id,
users.full_name,
users.phone_number,
users.role
users.role,
users.pic_sort_order
from users
where users.id = ${contactId}
and users.is_active = true
@@ -283,7 +291,8 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
id: row.id,
fullName: row.full_name,
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
role: row.role
role: row.role,
picSortOrder: parseInteger(row.pic_sort_order)
}
}
@@ -305,6 +314,7 @@ export async function createUser(input: {
full_name,
phone_number,
role,
pic_sort_order,
password_hash,
must_change_password,
is_active,
@@ -316,6 +326,7 @@ export async function createUser(input: {
${input.fullName},
${input.phoneNumber},
${input.role},
(select coalesce(max(pic_sort_order), 0) + 1 from users),
${input.passwordHash},
true,
true,
@@ -327,6 +338,7 @@ export async function createUser(input: {
full_name,
phone_number,
role,
pic_sort_order,
password_hash,
must_change_password,
is_active,
@@ -361,6 +373,21 @@ export async function updateUserProfile(input: {
return getUserById(input.userId)
}
export async function reorderUsers(userIds: string[]) {
await ensureDatabaseReady()
const sql = getSqlClient()
for (const [index, userId] of userIds.entries()) {
await sql`
update users
set
pic_sort_order = ${index + 1},
updated_at = now()
where id = ${userId}
`
}
}
export async function updateUserPassword(input: {
userId: string
passwordHash: string

View File

@@ -73,3 +73,19 @@ export function parseUserProfileInput(body: {
role
}
}
export function parseUserOrderInput(body: {
userIds?: unknown
}) {
assertBadRequest(Array.isArray(body.userIds), 'User ids must be an array')
assertBadRequest(body.userIds.every((value) => typeof value === 'string' && value.trim()), 'Every user id is required')
const userIds = body.userIds.map((value) => value.trim())
const uniqueUserIds = new Set(userIds)
assertBadRequest(uniqueUserIds.size === userIds.length, 'User ids must be unique')
return {
userIds
}
}