From 4e40bfd804c5a9dd042582585184354cebc7b828 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Mon, 4 May 2026 14:07:43 +0800 Subject: [PATCH] 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 --- app/pages/management/users/index.vue | 338 +++++++++++++++++++------- server/api/admin/users/order.patch.ts | 24 ++ server/utils/db-init.ts | 23 ++ server/utils/user-repository.ts | 43 +++- server/utils/users.ts | 16 ++ shared/auth.ts | 2 + 6 files changed, 355 insertions(+), 91 deletions(-) create mode 100644 server/api/admin/users/order.patch.ts diff --git a/app/pages/management/users/index.vue b/app/pages/management/users/index.vue index 009838f..56d309c 100644 --- a/app/pages/management/users/index.vue +++ b/app/pages/management/users/index.vue @@ -49,92 +49,121 @@
- - - + + {{ user.phoneNumber || 'Not set' }} + - - - + - - - - +
+ {{ searchQuery.trim() ? 'No matching users found.' : 'No users available.' }} +
+
@@ -233,12 +262,17 @@ const auth = useAuth() const users = ref([]) const loadingUsers = ref(false) const savingUser = ref(false) +const savingPicOrder = ref(false) const resettingUserId = ref(null) const issuedPasswordMessage = ref('') const searchQuery = ref('') const editorOpen = ref(false) const editorMode = ref<'create' | 'edit'>('create') const editingUserId = ref(null) +const draggedUserId = ref(null) +const dragOverUserId = ref(null) +const dragSaveStarted = ref(false) +let usersBeforeDrag: ManagedUser[] = [] const userForm = reactive({ fullName: '', @@ -252,19 +286,13 @@ const roleOptions = [ { label: 'Super Admin', value: 'super_admin' } ] -const columns = [ - { accessorKey: 'fullName', header: 'Display Name' }, - { accessorKey: 'phoneNumber', header: 'PIC Phone' }, - { accessorKey: 'role', header: 'Role' }, - { id: 'status', header: 'Status' }, - { accessorKey: 'lastLoginAt', header: 'Last Login' }, - { id: 'actions', header: 'Actions' } -] - const isEditMode = computed(() => editorMode.value === 'edit') const isEditingCurrentUser = computed(() => { return isEditMode.value && editingUserId.value === auth.user.value?.id }) +const canReorderUsers = computed(() => { + return !searchQuery.value.trim() && !loadingUsers.value && !savingPicOrder.value +}) const filteredUsers = computed(() => { const keyword = searchQuery.value.trim().toLowerCase() @@ -358,6 +386,131 @@ async function refreshUsers() { } } +function moveUserNearTarget(sourceUserId: string, targetUserId: string, insertAfterTarget: boolean) { + if (sourceUserId === targetUserId) { + return + } + + const sourceIndex = users.value.findIndex((user) => user.id === sourceUserId) + const currentUserIds = users.value.map((user) => user.id) + + if (sourceIndex === -1 || !currentUserIds.includes(targetUserId)) { + return + } + + const reorderedUsers = [...users.value] + const [movedUser] = reorderedUsers.splice(sourceIndex, 1) + const targetIndex = reorderedUsers.findIndex((user) => user.id === targetUserId) + + if (targetIndex === -1) { + return + } + + reorderedUsers.splice(insertAfterTarget ? targetIndex + 1 : targetIndex, 0, movedUser) + + const nextUserIds = reorderedUsers.map((user) => user.id) + + if (nextUserIds.join('|') === currentUserIds.join('|')) { + return + } + + users.value = reorderedUsers +} + +function resetDragState() { + draggedUserId.value = null + dragOverUserId.value = null + dragSaveStarted.value = false + usersBeforeDrag = [] +} + +function startUserDrag(event: DragEvent, user: ManagedUser) { + if (!canReorderUsers.value) { + event.preventDefault() + return + } + + draggedUserId.value = user.id + dragOverUserId.value = user.id + dragSaveStarted.value = false + usersBeforeDrag = [...users.value] + event.dataTransfer?.setData('text/plain', user.id) + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + } +} + +function overUserDrag(event: DragEvent, user: ManagedUser) { + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move' + } + + if (!canReorderUsers.value || !draggedUserId.value || draggedUserId.value === user.id) { + return + } + + const target = event.currentTarget + + if (!(target instanceof HTMLElement)) { + return + } + + const rect = target.getBoundingClientRect() + const insertAfterTarget = event.clientY > rect.top + rect.height / 2 + + dragOverUserId.value = user.id + moveUserNearTarget(draggedUserId.value, user.id, insertAfterTarget) +} + +async function dropUserDrag() { + if (!draggedUserId.value || savingPicOrder.value) { + return + } + + dragSaveStarted.value = true + savingPicOrder.value = true + + try { + const response = await apiClient<{ users: ManagedUser[] }>('/api/admin/users/order', { + method: 'PATCH', + body: { + userIds: users.value.map((user) => user.id) + } + }) + + users.value = response.users + toast.add({ + title: 'PIC order saved', + color: 'success', + icon: 'i-lucide-check-circle-2' + }) + } catch (error: any) { + users.value = usersBeforeDrag + toast.add({ + title: 'PIC order could not be saved', + description: getErrorMessage(error, 'Please try again in a moment.'), + color: 'error', + icon: 'i-lucide-circle-alert' + }) + } finally { + savingPicOrder.value = false + resetDragState() + } +} + +function endUserDrag() { + if (dragSaveStarted.value) { + return + } + + if (draggedUserId.value && usersBeforeDrag.length) { + users.value = usersBeforeDrag + } + + resetDragState() +} + async function saveUser(event: FormSubmitEvent) { event.preventDefault() @@ -466,3 +619,22 @@ async function resetPassword(user: ManagedUser) { } } + + diff --git a/server/api/admin/users/order.patch.ts b/server/api/admin/users/order.patch.ts new file mode 100644 index 0000000..5d8cac6 --- /dev/null +++ b/server/api/admin/users/order.patch.ts @@ -0,0 +1,24 @@ +import { requireRole } from '../../../utils/auth' +import { assertBadRequest } from '../../../utils/http' +import { listUsers, reorderUsers } from '../../../utils/user-repository' +import { parseUserOrderInput } from '../../../utils/users' + +export default defineEventHandler(async (event) => { + await requireRole(event, 'super_admin') + + const body = await readBody<{ + userIds?: unknown + }>(event) + const { userIds } = parseUserOrderInput(body) + const users = await listUsers() + const existingIds = new Set(users.map((user) => user.id)) + + assertBadRequest(userIds.length === users.length, 'User order must include every user') + assertBadRequest(userIds.every((userId) => existingIds.has(userId)), 'User order contains an unknown user') + + await reorderUsers(userIds) + + return { + users: await listUsers() + } +}) diff --git a/server/utils/db-init.ts b/server/utils/db-init.ts index cadf478..076986e 100644 --- a/server/utils/db-init.ts +++ b/server/utils/db-init.ts @@ -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, diff --git a/server/utils/user-repository.ts b/server/utils/user-repository.ts index 8a1f7c3..d67447f 100644 --- a/server/utils/user-repository.ts +++ b/server/utils/user-repository.ts @@ -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 { 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 { 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 { await ensureDatabaseReady() const sql = getSqlClient() - const rows = await sql[]>` + const rows = await sql[]>` 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 { 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[]>` + const [row] = await sql[]>` 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 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 + } +} diff --git a/shared/auth.ts b/shared/auth.ts index 58b7fb3..a01fde7 100644 --- a/shared/auth.ts +++ b/shared/auth.ts @@ -64,6 +64,7 @@ export interface AuthUser { fullName: string phoneNumber: string | null role: UserRole + picSortOrder: number isActive: boolean mustChangePassword: boolean needsPasskeySetup: boolean @@ -81,6 +82,7 @@ export interface PublicContact { fullName: string phoneNumber: string role: UserRole + picSortOrder: number } export interface PasskeySummary {