25 KiB
Fix User Training Filters — ประเภทการอบรมและสถานะไม่กรองข้อมูลจริง
เป้าหมาย
แก้ไขปัญหาในหน้าผู้ใช้งานที่แสดงรายการประวัติการอบรม โดยตัวกรองต่อไปนี้ไม่สามารถกรองข้อมูลได้จริง
- ประเภทการอบรม
- สถานะรายการ
หลังแก้ไขแล้ว เมื่อผู้ใช้งานเลือกค่า Filter ระบบต้องแสดงเฉพาะข้อมูลที่ตรงกับเงื่อนไขที่เลือก และต้องทำงานร่วมกับ Search, Pagination, Sorting และ Filter อื่นที่มีอยู่เดิมได้อย่างถูกต้อง
ปัญหาปัจจุบัน
ในหน้ารายการประวัติการอบรมของผู้ใช้งาน มี UI สำหรับเลือก Filter ดังนี้
- ประเภทการอบรม
- สถานะ
แต่เมื่อเลือกค่าแล้วพบว่า:
- ตารางยังแสดงข้อมูลทั้งหมด
- ข้อมูลในตารางไม่เปลี่ยนแปลง
- ไม่มีการ Refresh หรือ Refetch ข้อมูล
- Filter อาจถูกเปลี่ยนเฉพาะใน UI แต่ไม่ได้ส่งไปยัง API
- Backend อาจรับค่า Filter แต่ไม่ได้นำไปใช้ใน Query
- ค่าใน Select อาจไม่ตรงกับค่าที่เก็บในฐานข้อมูล
- Query Key หรือ URL Search Params อาจไม่มีค่า Filter อยู่ด้วย
ขอบเขตการตรวจสอบ
ตรวจสอบ Flow ทั้งหมดตั้งแต่ UI จนถึง Database โดยห้ามแก้เฉพาะหน้า UI หาก Backend ยังไม่รองรับการกรองจริง
Flow ที่ต้องตรวจสอบ:
Filter UI
→ Filter State
→ URL Search Params
→ API Request
→ API Validation
→ Service / Repository
→ Database Query
→ API Response
→ Table Rendering
1. ตรวจสอบหน้ารายการของผู้ใช้งาน
ค้นหาหน้ารายการประวัติการอบรมของผู้ใช้งาน เช่น:
/dashboard/training-records
/dashboard/my-training-records
/dashboard/user/training-records
หรือ Route ที่ระบบใช้งานจริง
ตรวจสอบ Component ที่เกี่ยวข้อง เช่น:
TrainingRecordList
TrainingRecordTable
TrainingRecordToolbar
TrainingRecordFilters
UserTrainingRecordTable
ต้องตรวจสอบว่าตัวกรองทั้งสองเชื่อมกับ State จริง ไม่ใช่แสดง UI อย่างเดียว
2. Filter ประเภทการอบรม
ตรวจสอบว่า Filter ประเภทการอบรมใช้ Field ใดในระบบจริง เช่น:
trainingType;
training_type;
type;
trainingMode;
training_mode;
ค่าที่เลือกจาก UI ต้องตรงกับค่าที่เก็บในฐานข้อมูล
ตัวอย่างค่าที่เป็นไปได้:
online;
offline;
หรือ:
internal;
external;
ห้ามสมมติค่าขึ้นใหม่ ให้ตรวจสอบจาก Schema, Enum, Zod Schema, TypeScript Type และข้อมูลใน Database ก่อน
ตัวอย่างโครงสร้าง Select:
<Select
value={trainingType}
onValueChange={(value) => {
setTrainingType(value);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="ประเภทการอบรม" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">ทุกประเภท</SelectItem>
<SelectItem value="online">ออนไลน์</SelectItem>
<SelectItem value="offline">ออฟไลน์</SelectItem>
</SelectContent>
</Select>
ค่า "all" ต้องใช้สำหรับล้างเงื่อนไขเท่านั้น และไม่ควรถูกส่งไปกรองใน Database
ตัวอย่างการสร้าง Filter:
const filters = {
page,
limit,
...(search && { search }),
...(trainingType &&
trainingType !== "all" && {
trainingType,
}),
};
3. Filter สถานะ
ตรวจสอบสถานะที่ระบบรองรับจริง เช่น:
draft;
pending;
approved;
rejected;
returned;
cancelled;
ห้ามกำหนดรายการสถานะจากการคาดเดา ให้ตรวจสอบจาก:
- Database Schema
- Enum
- Zod Schema
- TypeScript Type
- Status Badge
- Workflow ปัจจุบันของระบบ
- API Endpoint ที่เกี่ยวข้อง
ตัวอย่าง Select:
<Select
value={status}
onValueChange={(value) => {
setStatus(value);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="สถานะ" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">ทุกสถานะ</SelectItem>
<SelectItem value="draft">ฉบับร่าง</SelectItem>
<SelectItem value="pending">รอตรวจสอบ</SelectItem>
<SelectItem value="approved">อนุมัติแล้ว</SelectItem>
<SelectItem value="rejected">ไม่อนุมัติ</SelectItem>
</SelectContent>
</Select>
ตัวอย่างการสร้าง Filter:
const filters = {
page,
limit,
...(search && { search }),
...(status && status !== "all" && { status }),
};
4. เชื่อม Filter กับ URL Search Params
หากหน้าปัจจุบันใช้ URL Search Params ให้ใช้รูปแบบเดียวกับ Filter อื่นที่มีอยู่เดิม
ตัวอย่าง URL:
/dashboard/training-records?trainingType=online&status=pending&page=1
เมื่อเปลี่ยน Filter ต้อง:
- อัปเดต Search Params
- Reset หน้า Pagination กลับเป็นหน้า 1
- Refetch หรือ Reload ข้อมูลตามค่าปัจจุบัน
- ไม่ลบ Search Params อื่นที่ยังใช้งานอยู่
ตัวอย่าง:
const handleTrainingTypeChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value === "all") {
params.delete("trainingType");
} else {
params.set("trainingType", value);
}
params.set("page", "1");
router.replace(`${pathname}?${params.toString()}`);
};
ทำแนวทางเดียวกันกับ status
5. ตรวจสอบ Query Key และการ Refetch
หากใช้ React Query หรือ TanStack Query ต้องนำค่า Filter เข้าไปอยู่ใน queryKey
ตัวอย่างที่ไม่ถูกต้อง:
queryKey: ["training-records"];
ตัวอย่างที่ควรเป็น:
queryKey: [
"training-records",
{
page,
limit,
search,
trainingType,
status,
sort,
},
];
หากใช้ SWR ต้องนำ Query String หรือ Filter เข้าไปอยู่ใน Key เช่นกัน
const queryString = new URLSearchParams({
page: String(page),
limit: String(limit),
...(search && { search }),
...(trainingType &&
trainingType !== "all" && {
trainingType,
}),
...(status &&
status !== "all" && {
status,
}),
}).toString();
const { data } = useSWR(`/api/training-records?${queryString}`, fetcher);
เมื่อ Filter เปลี่ยน ระบบต้อง Refetch อัตโนมัติ โดยไม่ต้องกด Refresh หน้า Browser
6. ตรวจสอบ API Request
ตรวจสอบว่า Request ส่งค่า Filter ไปยัง API จริง
ตัวอย่าง:
GET /api/training-records?trainingType=online&status=pending&page=1&limit=10
ตรวจสอบผ่าน:
- Browser Network Tab
- Server Log
- API Handler
- Query Parser
ห้ามส่งค่า:
trainingType=all
status=all
หากเลือก “ทั้งหมด” ต้องไม่ส่ง Parameter นั้น หรือส่งเป็น undefined
7. ตรวจสอบ API Validation
เพิ่มหรือแก้ไข Schema สำหรับ Query Parameters ให้รองรับ Filter ทั้งสอง
ตัวอย่าง:
const trainingRecordQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(10),
search: z.string().trim().optional(),
trainingType: trainingTypeSchema.optional(),
status: trainingRecordStatusSchema.optional(),
sort: z.string().optional(),
});
ควรใช้ Enum หรือ Schema ที่มีอยู่เดิม ห้ามสร้างค่าซ้ำหลายจุด
ตัวอย่าง:
const trainingTypeSchema = z.enum(["online", "offline"]);
const trainingRecordStatusSchema = z.enum([
"draft",
"pending",
"approved",
"rejected",
]);
รายการจริงต้องอ้างอิงจากระบบปัจจุบัน
หากได้รับค่าที่ไม่ถูกต้อง ควรตอบกลับด้วย Validation Error ที่เหมาะสม ไม่ควร silently ignore Filter
8. ตรวจสอบ Service หรือ Repository
นำค่าที่ผ่าน Validation แล้วส่งต่อไปยัง Service หรือ Repository
ตัวอย่าง:
const result = await getTrainingRecords({
userId: currentUser.id,
page: query.page,
limit: query.limit,
search: query.search,
trainingType: query.trainingType,
status: query.status,
sort: query.sort,
});
ต้องรักษาเงื่อนไขสำคัญของหน้าผู้ใช้งาน คือผู้ใช้งานต้องเห็นเฉพาะข้อมูลของตนเอง
ตัวอย่าง:
where: {
employeeId: currentEmployee.id,
...(trainingType && {
trainingType,
}),
...(status && {
status,
}),
}
ห้ามแก้ Filter แล้วทำให้ผู้ใช้งานสามารถเห็นข้อมูลของพนักงานคนอื่นได้
9. ตรวจสอบ Database Query
หากใช้ Prisma ให้เพิ่มเงื่อนไข Filter ใน where
ตัวอย่าง:
const where: Prisma.TrainingRecordWhereInput = {
employeeId: currentEmployee.id,
...(search && {
OR: [
{
courseName: {
contains: search,
mode: "insensitive",
},
},
{
providerName: {
contains: search,
mode: "insensitive",
},
},
],
}),
...(trainingType && {
trainingType,
}),
...(status && {
status,
}),
};
จากนั้นใช้ where ชุดเดียวกันทั้ง Query ข้อมูลและ Query นับจำนวน
const [items, total] = await Promise.all([
prisma.trainingRecord.findMany({
where,
skip,
take: limit,
orderBy,
}),
prisma.trainingRecord.count({
where,
}),
]);
ต้องใช้เงื่อนไขเดียวกันทั้ง findMany และ count เพื่อไม่ให้ Pagination แสดงจำนวนผิด
หากใช้ SQL โดยตรง ต้องเพิ่ม Parameterized Query และห้ามต่อ SQL String แบบไม่ปลอดภัย
10. รองรับการใช้หลาย Filter พร้อมกัน
Filter ต้องทำงานแบบ AND
ตัวอย่าง:
ประเภทการอบรม = ออนไลน์
สถานะ = รอตรวจสอบ
ผลลัพธ์ต้องแสดงเฉพาะรายการที่:
trainingType = online
AND
status = pending
และยังต้องทำงานร่วมกับ Search เช่น:
search = React
AND
trainingType = online
AND
status = pending
11. Reset Pagination เมื่อ Filter เปลี่ยน
เมื่อผู้ใช้งานอยู่หน้า 3 แล้วเปลี่ยน Filter ระบบต้องกลับไปหน้า 1 เพื่อป้องกัน Empty State ที่เกิดจาก Page เดิมไม่มีข้อมูล
ตัวอย่าง:
onValueChange={(value) => {
setTrainingType(value);
setPagination((previous) => ({
...previous,
pageIndex: 0,
}));
}}
หากใช้ URL:
params.set("page", "1");
12. ปุ่มล้าง Filter
หากหน้าปัจจุบันมีปุ่ม Reset หรือ Clear Filter ต้องล้างค่าทั้งสองได้จริง
ค่าที่ต้องล้าง:
- Search
- ประเภทการอบรม
- สถานะ
- Filter อื่นที่อยู่ใน Toolbar ตามพฤติกรรมเดิม
- Pagination กลับไปหน้า 1
ตัวอย่าง:
const clearFilters = () => {
setSearch("");
setTrainingType("all");
setStatus("all");
setPage(1);
};
หากใช้ Search Params ต้องลบ Parameter ที่เกี่ยวข้องออกจาก URL
params.delete("trainingType");
params.delete("status");
params.set("page", "1");
13. Loading และ Empty State
เมื่อ Filter เปลี่ยน:
- แสดง Loading State ตามมาตรฐานเดิมของระบบ
- ป้องกันการกด Filter ซ้ำระหว่าง Pending หากจำเป็น
- ไม่แสดงข้อมูลเก่าที่ทำให้ผู้ใช้เข้าใจผิด
- หลังโหลดเสร็จต้องแสดงข้อมูลใหม่ทันที
เมื่อไม่มีข้อมูลตรงกับ Filter ให้แสดงข้อความ เช่น:
ไม่พบรายการประวัติการอบรมที่ตรงกับเงื่อนไข
ควรมีปุ่ม:
ล้างตัวกรอง
ไม่ควรใช้ข้อความทั่วไปว่า “ไม่มีข้อมูล” หากมี Filter ทำงานอยู่ เพราะผู้ใช้อาจเข้าใจว่าไม่มีประวัติการอบรมทั้งหมด
14. ตรวจสอบ Mapping ของ Label และ Value
ต้องแยกค่าที่แสดงกับค่าที่ส่งไป Backend อย่างชัดเจน
ตัวอย่าง:
const TRAINING_TYPE_OPTIONS = [
{
label: "ทั้งหมด",
value: "all",
},
{
label: "ออนไลน์",
value: "online",
},
{
label: "ออฟไลน์",
value: "offline",
},
];
ห้ามใช้ Label ภาษาไทยเป็น Database Value เว้นแต่ Schema ปัจจุบันเก็บค่าเป็นภาษาไทยจริง
ตัวอย่างที่ควรหลีกเลี่ยง:
<SelectItem value="ออนไลน์">ออนไลน์</SelectItem>
หาก Database เก็บค่าเป็น:
online
15. ใช้ Constant และ Type กลางของระบบ
หากมี Constant หรือ Enum อยู่แล้ว ให้ใช้ของเดิม เช่น:
TRAINING_TYPES;
TRAINING_RECORD_STATUSES;
TrainingType;
TrainingRecordStatus;
ห้ามประกาศรายการซ้ำแยกกันใน:
- Filter UI
- API Schema
- Status Badge
- Form
- Database Query
เพราะอาจทำให้ค่าไม่ตรงกันในอนาคต
ควรมี Source of Truth เดียวสำหรับ:
- Value
- Label
- Description
- Badge Variant
- Permission หรือ Workflow ที่เกี่ยวข้อง
16. ห้ามเปลี่ยน Business Rule เดิม
การแก้ไขครั้งนี้เป็นการแก้ Filter เท่านั้น
ห้ามเปลี่ยนโดยไม่จำเป็น:
- Workflow การอนุมัติ
- Permission
- สิทธิ์ของ Employee
- สิทธิ์ของ HRD
- การสร้างหรือแก้ไขรายการ
- Status Transition
- Sorting เริ่มต้น
- รูปแบบ Pagination
- Response Structure ของ API
- UI Style หลักของระบบ
หน้าผู้ใช้งานต้องยังคงเห็นเฉพาะรายการของตนเองตาม Permission เดิม
17. Test Cases ที่ต้องตรวจสอบ
Test Case 1: Filter ประเภทการอบรม
- เปิดหน้ารายการประวัติการอบรม
- เลือกประเภท “ออนไลน์”
- ตรวจสอบว่าทุกรายการเป็นประเภทออนไลน์
- เปลี่ยนเป็น “ออฟไลน์”
- ตรวจสอบว่าทุกรายการเป็นประเภทออฟไลน์
- เลือก “ทุกประเภท”
- ตรวจสอบว่าแสดงข้อมูลทุกประเภท
Test Case 2: Filter สถานะ
- เลือกสถานะ “รอตรวจสอบ”
- ตรวจสอบว่าทุกรายการมีสถานะรอตรวจสอบ
- เปลี่ยนเป็น “อนุมัติแล้ว”
- ตรวจสอบว่าทุกรายการมีสถานะอนุมัติแล้ว
- เลือก “ทุกสถานะ”
- ตรวจสอบว่าแสดงข้อมูลทุกสถานะที่ผู้ใช้มีสิทธิ์เห็น
Test Case 3: ใช้สอง Filter พร้อมกัน
- เลือกประเภทออนไลน์
- เลือกสถานะรอตรวจสอบ
- ตรวจสอบว่าทุกรายการตรงทั้งสองเงื่อนไข
Test Case 4: Filter ร่วมกับ Search
- กรอกคำค้นหา
- เลือกประเภทการอบรม
- เลือกสถานะ
- ตรวจสอบว่าข้อมูลตรงทุกเงื่อนไข
Test Case 5: Filter ร่วมกับ Pagination
- เลือก Filter ที่มีหลายหน้า
- เปลี่ยนหน้า Pagination
- ตรวจสอบว่า Filter ยังคงอยู่
- เปลี่ยน Filter
- ตรวจสอบว่าระบบกลับมาหน้า 1
Test Case 6: Refresh หน้า
หากระบบใช้ URL Search Params:
- เลือกประเภทและสถานะ
- Refresh Browser
- ตรวจสอบว่าค่า Filter ยังคงอยู่
- ข้อมูลต้องยังตรงกับเงื่อนไขเดิม
Test Case 7: Clear Filter
- เลือกหลาย Filter
- กดล้างตัวกรอง
- ตรวจสอบว่าค่าทั้งหมดถูก Reset
- ตารางกลับมาแสดงข้อมูลทั้งหมด
- Pagination กลับมาหน้า 1
Test Case 8: ไม่มีข้อมูลตรงกับ Filter
- เลือกเงื่อนไขที่ไม่มีข้อมูล
- ต้องแสดง Empty State ที่เหมาะสม
- ปุ่มล้างตัวกรองต้องใช้งานได้
Test Case 9: Permission
- Login ด้วย Employee
- ใช้ Filter ทุกประเภท
- ตรวจสอบว่าไม่เห็นรายการของพนักงานคนอื่น
Test Case 10: Count และ Pagination
- เลือก Filter
- ตรวจสอบจำนวนรายการทั้งหมด
- ตรวจสอบจำนวนหน้า
- จำนวนต้องสัมพันธ์กับข้อมูลหลังกรอง ไม่ใช่จำนวนทั้งหมดก่อนกรอง
Acceptance Criteria
งานถือว่าเสร็จสมบูรณ์เมื่อ:
- Filter ประเภทการอบรมกรองข้อมูลได้จริง
- Filter สถานะกรองข้อมูลได้จริง
- Filter ทั้งสองใช้งานพร้อมกันได้
- ทำงานร่วมกับ Search ได้
- ทำงานร่วมกับ Sorting ได้
- ทำงานร่วมกับ Pagination ได้
- เปลี่ยน Filter แล้วข้อมูล Refresh หรือ Refetch อัตโนมัติ
- เปลี่ยน Filter แล้ว Pagination กลับหน้า 1
- เลือก “ทั้งหมด” แล้วล้างเงื่อนไขได้จริง
- ปุ่มล้างตัวกรองทำงานถูกต้อง
- จำนวนรายการและจำนวนหน้าหลังกรองถูกต้อง
- Frontend และ Backend ใช้ค่า Enum ตรงกัน
- ไม่มีการส่งค่า
"all"ไปใช้ใน Database Query - ผู้ใช้งานยังเห็นเฉพาะข้อมูลของตนเอง
- ไม่มี Regression ต่อ Filter หรือ Workflow อื่น
- TypeScript, Lint และ Build ผ่าน
- มีการเพิ่มหรือปรับปรุง Test ตามรูปแบบที่ Repository ใช้อยู่
สิ่งที่ต้องส่งมอบ
หลังดำเนินการเสร็จ ให้สรุปผลดังนี้:
- สาเหตุที่ทำให้ Filter ไม่ทำงาน
- ไฟล์ที่แก้ไข
- Filter ใช้ Field และ Value ใด
- การเปลี่ยนแปลงฝั่ง Frontend
- การเปลี่ยนแปลงฝั่ง API และ Backend
- วิธี Reset Filter และ Pagination
- Test Cases ที่ดำเนินการ
- ผลการรัน Type Check, Lint, Test และ Build
- ประเด็นที่ยังไม่ได้แก้หรือข้อจำกัด หากมี
ห้ามรายงานว่าแก้ไขสำเร็จโดยตรวจสอบเฉพาะ UI ต้องยืนยันจาก API Request และผลลัพธ์ข้อมูลจริงด้วย