Skip to content

Commit 161ba63

Browse files
committed
feat(lab): create page lab management for admin
1 parent 3f153b5 commit 161ba63

9 files changed

Lines changed: 379 additions & 13 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { LabManagedByAdmin } from "./schema";
2+
import type { AugmentedColumnDef } from "~/components/common/DataTable/column";
3+
import { Icon, Button } from "#components";
4+
5+
const formatTimetable = (timetable: Record<string, string[]>) => {
6+
const days: Record<string, string> = {
7+
"2": "Thứ 2",
8+
"3": "Thứ 3",
9+
"4": "Thứ 4",
10+
"5": "Thứ 5",
11+
"6": "Thứ 6",
12+
"7": "Thứ 7",
13+
"8": "Chủ nhật",
14+
};
15+
16+
return Object.entries(timetable || {}).map(([day, slots]) => {
17+
return h("div", { class: "mb-1" }, [
18+
h("span", { class: "font-medium" }, `${days[day]}: `),
19+
h("span", {}, slots.join(", ")),
20+
]);
21+
});
22+
};
23+
24+
export const columns: AugmentedColumnDef<LabManagedByAdmin>[] = [
25+
{
26+
id: "name",
27+
title: "Tên phòng",
28+
cell: ({ row }) =>
29+
h(
30+
"div",
31+
{
32+
class: "flex items-center gap-2",
33+
},
34+
[
35+
h(Icon, {
36+
name: "i-heroicons-beaker",
37+
class: "w-5 h-5 text-blue-500",
38+
}),
39+
h("span", { class: "text-gray-700 font-medium" }, row.original.name),
40+
],
41+
),
42+
enableSorting: true,
43+
},
44+
{
45+
id: "room",
46+
title: "Phòng",
47+
cell: ({ row }) => h("span", { class: "text-gray-600" }, row.original.room),
48+
enableSorting: true,
49+
},
50+
{
51+
id: "faculty",
52+
title: "Khoa",
53+
cell: ({ row }) =>
54+
h("span", { class: "text-gray-600" }, row.original.faculty),
55+
enableSorting: true,
56+
},
57+
{
58+
id: "branch",
59+
title: "Cơ sở",
60+
cell: ({ row }) =>
61+
h("span", { class: "text-gray-600" }, row.original.branch),
62+
enableSorting: true,
63+
},
64+
{
65+
id: "timetable",
66+
title: "Lịch hoạt động",
67+
cell: ({ row }) =>
68+
h(
69+
"div",
70+
{ class: "text-sm text-gray-600" },
71+
row.original.timetable
72+
? formatTimetable(row.original.timetable)
73+
: "Chưa có lịch",
74+
),
75+
},
76+
{
77+
id: "actions",
78+
title: "Thao tác",
79+
cell: ({ row }) =>
80+
h("div", { class: "flex items-center gap-2" }, [
81+
h(
82+
Button,
83+
{
84+
variant: "ghost",
85+
size: "icon",
86+
onClick: () => console.log("Edit:", row.original),
87+
class: "hover:text-blue-500",
88+
},
89+
[
90+
h(Icon, {
91+
name: "i-heroicons-pencil-square",
92+
class: "w-5 h-5",
93+
}),
94+
],
95+
),
96+
h(
97+
Button,
98+
{
99+
variant: "ghost",
100+
size: "icon",
101+
onClick: () => console.log("Delete:", row.original),
102+
class: "hover:text-red-500",
103+
},
104+
[
105+
h(Icon, {
106+
name: "i-heroicons-trash",
107+
class: "w-5 h-5",
108+
}),
109+
],
110+
),
111+
]),
112+
},
113+
];
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script setup lang="ts">
2+
import { laboratoryService } from '~/services';
3+
import { columns } from './column';
4+
import type { AugmentedColumnDef } from '~/components/common/DataTable/column';
5+
6+
async function fetchData(offset: number, length: number, options: { desc?: boolean, sortField?: string, searchText?: string, searchFields?: string[] }): Promise<{ data: unknown[], totalPages: number }> {
7+
const res = await laboratoryService.getLabsManagedByAdmin(offset, length, { searchText: options.searchText, searchFields: ['lab_name', 'location'], sortField: options.sortField as any, desc: options.desc });
8+
return {
9+
data: res.labs,
10+
totalPages: res.totalPages,
11+
};
12+
}
13+
</script>
14+
15+
<template>
16+
<DataTable :selectable="true" :searchable="true" :qrable="true" :fetch-fn="fetchData"
17+
:columns="columns as AugmentedColumnDef<unknown>[]" />
18+
</template>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Type } from "@sinclair/typebox";
2+
import type { Static } from "@sinclair/typebox";
3+
4+
export const LabManagedByAdmin = Type.Object({
5+
id: Type.String(),
6+
name: Type.String(),
7+
room: Type.String(),
8+
faculty: Type.String(),
9+
branch: Type.String(),
10+
timetable: Type.Record(Type.String(), Type.Array(Type.String())),
11+
});
12+
13+
export type LabManagedByAdmin = Static<typeof LabManagedByAdmin>;

composables/useVirtualKeyboardDetection.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export function useVirtualKeyboardDetection (
2-
onDetect: (input: string, type?: 'userId' | 'device') => void,
2+
onDetect: (input: string, type?: 'userId' | 'device') => Promise<void> | void,
33
options: {
44
userId?: {
55
length?: number;
@@ -14,7 +14,7 @@ export function useVirtualKeyboardDetection (
1414
let currentInput = '';
1515
let startTime: number = 0;
1616
let timeoutId: NodeJS.Timeout | null = null;
17-
17+
let isProcessing = false;
1818
const defaultDeviceRegex =
1919
/^https?:\/\/[^/]+\/devices\/\d{8}\?id=[a-fA-F0-9]+$/;
2020

@@ -39,7 +39,22 @@ export function useVirtualKeyboardDetection (
3939
maxInputTimeMs: options.maxInputTimeMs ?? defaultOptions.maxInputTimeMs,
4040
};
4141

42+
const handleDetection = async (input: string, type: 'userId' | 'device') => {
43+
if (isProcessing) return;
44+
45+
try {
46+
isProcessing = true;
47+
await onDetect(input, type);
48+
} catch (error) {
49+
throw new Error(`Error in virtual keyboard detection callback: ${error}`);
50+
} finally {
51+
isProcessing = false;
52+
}
53+
};
54+
4255
const handleKeyDown = (e: KeyboardEvent): void => {
56+
if (isProcessing) return;
57+
4358
if (currentInput.length === 0) {
4459
startTime = new Date().getTime();
4560
}
@@ -61,7 +76,7 @@ export function useVirtualKeyboardDetection (
6176
}
6277

6378
if (mergedOptions.device.pattern.test(currentInput)) {
64-
onDetect(currentInput, 'device');
79+
handleDetection(currentInput, 'device');
6580
resetDetection();
6681
return;
6782
}
@@ -70,7 +85,7 @@ export function useVirtualKeyboardDetection (
7085
currentInput.length === mergedOptions.userId.length &&
7186
/^\d+$/.test(currentInput)
7287
) {
73-
onDetect(currentInput, 'userId');
88+
handleDetection(currentInput, 'userId');
7489
resetDetection();
7590
return;
7691
}
@@ -96,7 +111,6 @@ export function useVirtualKeyboardDetection (
96111
document.removeEventListener('keydown', handleKeyDown);
97112
resetDetection();
98113
};
99-
100114
onMounted(setupListeners);
101115
onUnmounted(cleanupListeners);
102116

lib/api_schema/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,19 @@ export const DeviceCheckerResourceDto = Type.Object({
220220
});
221221

222222
export type DeviceCheckerResourceDto = Static<typeof DeviceCheckerResourceDto>;
223+
224+
export const AdminManagedLabsDto = Type.Object({
225+
labs: Type.Array(
226+
Type.Object({
227+
id: Type.String(),
228+
branch: Type.String(),
229+
timetable: Type.Record(Type.String(), Type.Array(Type.String())),
230+
name: Type.String(),
231+
room: Type.String(),
232+
}),
233+
),
234+
totalPages: Type.Number(),
235+
currentPage: Type.Number(),
236+
});
237+
238+
export type AdminManagedLabsDto = Static<typeof AdminManagedLabsDto>;

pages/admin/labs/index.vue

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<template>
2+
<div class="mx-6 sm:mx-16 my-10">
3+
<Breadcrumb>
4+
<BreadcrumbList>
5+
<BreadcrumbItem>
6+
<NuxtLink href="/" class="flex justify-center items-center text-lg">
7+
<Icon aria-hidden name="i-heroicons-home" />
8+
</NuxtLink>
9+
</BreadcrumbItem>
10+
<BreadcrumbSeparator>
11+
<p class="font-semibold">/</p>
12+
</BreadcrumbSeparator>
13+
<BreadcrumbItem>
14+
<NuxtLink class="text-normal font-bold underline text-black" href="/admin/devices">Danh sách loại thiết bị
15+
</NuxtLink>
16+
</BreadcrumbItem>
17+
</BreadcrumbList>
18+
</Breadcrumb>
19+
20+
<main class="my-10">
21+
<section class="bg-white mt-8 p-4 py-8 pb-8">
22+
<h2 class="font-bold text-xl mb-8"> Tất cả loại thiết bị </h2>
23+
<LabAdminTable />
24+
</section>
25+
</main>
26+
</div>
27+
</template>

pages/index.vue

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script lang="ts" setup>
2+
import { deviceService } from '@/services';
3+
24
definePageMeta({
35
middleware: ['permission'],
46
permission: 'home:own',
@@ -8,13 +10,14 @@ const showDialog = ref(false);
810
const userId = ref('');
911
const deviceId = ref('');
1012
11-
const handleVirtualKeyboardDetection = (input: string, type?: 'userId' | 'device'): void => {
13+
const handleVirtualKeyboardDetection = async (input: string, type?: 'userId' | 'device') => {
1214
if (type === 'userId') {
1315
showDialog.value = true;
1416
userId.value = input;
1517
} else if (type === 'device') {
1618
deviceId.value = input;
17-
navigateTo(`/device/${input}`);
19+
const { id } = await deviceService.checkDevice(input.match(/[?&]id=([a-fA-F0-9]+)/)![1]);
20+
navigateTo(`/device/${id}`);
1821
}
1922
};
2023
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { type Static, Type } from '@sinclair/typebox';
2+
import { Value } from '@sinclair/typebox/value';
3+
import * as db from 'zapatos/db';
4+
import { BAD_REQUEST_CODE, INTERNAL_SERVER_ERROR_CODE } from '~/constants';
5+
import { AdminManagedLabsDto } from '~/lib/api_schema';
6+
import { dbPool } from '~/server/db';
7+
import { getToken } from '#auth';
8+
9+
const QueryDto = Type.Object({
10+
offset: Type.Number(),
11+
length: Type.Number(),
12+
search_text: Type.Optional(Type.String()),
13+
search_fields: Type.Optional(
14+
Type.Array(
15+
Type.Union([Type.Literal('location'), Type.Literal('lab_name')]),
16+
),
17+
),
18+
sort_field: Type.Optional(Type.Union([Type.Literal('lab_name')])),
19+
desc: Type.Optional(Type.Boolean()),
20+
});
21+
22+
type QueryDto = Static<typeof QueryDto>;
23+
24+
export default defineEventHandler<
25+
{ query: QueryDto },
26+
Promise<AdminManagedLabsDto>
27+
>(async (event) => {
28+
const query = Value.Convert(QueryDto, getQuery(event));
29+
const token = await getToken({ event });
30+
const userId = token?.id;
31+
if (!Value.Check(QueryDto, query)) {
32+
throw createError({
33+
status: BAD_REQUEST_CODE,
34+
message: 'Bad request: Invalid query',
35+
});
36+
}
37+
38+
const {
39+
search_fields: searchFields,
40+
offset,
41+
length,
42+
sort_field: sortField,
43+
desc,
44+
} = query;
45+
const searchText = query.search_text
46+
?.replaceAll('\'', '')
47+
.replaceAll('%', '')
48+
.replaceAll('?', '');
49+
50+
if (searchText !== undefined && !searchFields) {
51+
throw createError({
52+
statusCode: BAD_REQUEST_CODE,
53+
message:
54+
'Bad request: Expect search_fields to be present when search_text is specified',
55+
});
56+
}
57+
58+
const labs = await db.sql`
59+
SELECT ${'labs'}.${'id'}, ${'labs'}.${'branch'}, ${'labs'}.${'room'}, ${'labs'}.${'timetable'}, ${'labs'}.${'name'}
60+
FROM ${'labs'}
61+
WHERE
62+
${'labs'}.${'deleted_at'} IS NULL
63+
AND ${'labs'}.${'admin_id'} = ${db.param(userId)}
64+
${
65+
searchText !== undefined
66+
? db.raw(`AND (
67+
(${searchFields?.includes('location') || false} AND strip_vietnamese_accents(labs.room || ', ' || labs.branch) ILIKE strip_vietnamese_accents('%${searchText}%')) OR
68+
(${searchFields?.includes('lab_name') || false} AND strip_vietnamese_accents(labs.name) ILIKE strip_vietnamese_accents('%${searchText}%'))
69+
)`)
70+
: db.raw('')
71+
}
72+
ORDER BY ${sortField ? db.raw(`${sortField} ${desc ? 'DESC' : 'ASC'}, `) : db.raw('')} ${'labs'}.${'name'} ASC
73+
LIMIT ${db.param(length)}
74+
OFFSET ${db.param(offset)}
75+
`.run(dbPool);
76+
77+
const [{ quantity: totalRecords }] = await db.sql`
78+
SELECT COUNT (*) as quantity
79+
FROM ${'labs'}
80+
WHERE
81+
${'labs'}.${'deleted_at'} IS NULL
82+
AND ${'labs'}.${'admin_id'} = ${db.param(userId)}
83+
${
84+
searchText !== undefined
85+
? db.raw(`AND (
86+
(${searchFields?.includes('location') || false} AND strip_vietnamese_accents(labs.room || ', ' || labs.branch) ILIKE strip_vietnamese_accents('%${searchText}%')) OR
87+
(${searchFields?.includes('lab_name') || false} AND strip_vietnamese_accents(labs.name) ILIKE strip_vietnamese_accents('%${searchText}%'))
88+
)`)
89+
: db.raw('')
90+
}
91+
`.run(dbPool);
92+
93+
const totalPages = Math.ceil(totalRecords / length);
94+
const currentPage = Math.floor(offset / length);
95+
96+
const output = {
97+
labs,
98+
totalPages,
99+
currentPage,
100+
};
101+
if (!Value.Check(AdminManagedLabsDto, output)) {
102+
throw createError({
103+
statusCode: INTERNAL_SERVER_ERROR_CODE,
104+
message:
105+
'Internal server error: the returned output does not conform to the schema',
106+
});
107+
}
108+
return output;
109+
});

0 commit comments

Comments
 (0)