หมวดหมู่ย่อยเนื้อหาแบบลำดับชั้น
ภาพรวม
หมวดหมู่ย่อยเนื้อหา (Content Subcategory) คือชั้นการจัดหมวดที่อยู่ใต้ Content Category และสามารถซ้อนกันเองได้หลายระดับ ผ่านการอ้างอิงตัวเอง (parentId ประกอบกับ level) โดยหน้าจอจำกัดความลึกสูงสุดไว้ที่ 4 ระดับ
ต่างจากหน้าจัดการข้อมูลทั่วไปที่เป็นตาราง หน้านี้แสดงผลเป็น โครงสร้างต้นไม้ (tree) เพื่อให้เห็นความสัมพันธ์แม่-ลูกได้ทันที รองรับการจัดลำดับด้วยการลากวาง และการลบที่ตรวจสอบผลกระทบก่อนเสมอ
เหมาะกับทีมคอนเทนต์ที่มีเนื้อหาปริมาณมากและต้องการจัดกลุ่มละเอียดกว่าระดับหมวดหมู่หลัก เช่น "โปรโมชัน > ลูกค้าใหม่ > เดือนมกราคม"
Business Flow
- เปิดหน้ารายการหมวดหมู่ย่อย ระบบจะโหลดรายการหมวดหมู่หลักมาเป็นตัวเลือกของฟิลเตอร์ ผู้ใช้อาจเข้ามาจากหน้าหมวดหมู่หลักพร้อมพารามิเตอร์ระบุหมวดหมู่ที่ต้องการดูก็ได้
- เมื่อเลือกหมวดหมู่หลัก ระบบจะแสดง tree ของหมวดหมู่ย่อยเฉพาะหมวดนั้น และกางทุกกิ่งให้อัตโนมัติ
- เมื่อไม่เลือกหมวดหมู่หลัก ระบบจะไล่โหลด tree ของทุกหมวดหมู่มาแสดงเป็นแผงพับ (Collapse) แยกตามหมวด โดยเปิดแผงแรกที่มีหมวดย่อยให้อัตโนมัติ
- แต่ละกิ่งใน tree มีเมนูคำสั่ง 3 อย่าง คือ แก้ไข, เพิ่มหมวดย่อยใต้กิ่งนี้ (ปรากฏเฉพาะเมื่อยังไม่ถึงระดับที่ 4) และลบ
- การจัดลำดับด้วยการลากวาง อนุญาตให้สลับตำแหน่งได้เฉพาะกิ่งที่เป็นพี่น้องระดับเดียวกัน (มีแม่เดียวกัน) เท่านั้น เมื่อวางสำเร็จระบบจะคำนวณลำดับใหม่ทั้งกลุ่มแล้วบันทึกในครั้งเดียว
- การลบทำเป็น 2 จังหวะ โดยระบบจะลองลบก่อน ถ้าฝั่งเซิร์ฟเวอร์พบว่ามีหมวดย่อยลูกหรือเนื้อหาผูกอยู่ จะส่งจำนวนที่ได้รับผลกระทบกลับมาแสดงในกล่องยืนยัน เมื่อผู้ใช้ยืนยันจึงลบแบบบังคับอีกครั้ง
- การสร้างและแก้ไข ทำที่หน้าฟอร์มแยก โดยเลือกหมวดหมู่หลัก (บังคับ และล็อกไว้ในโหมดแก้ไข) จากนั้นเลือกหมวดย่อยแม่ (ไม่บังคับ และล็อกไว้ในโหมดแก้ไขเช่นกัน) ระบบจะอ่านระดับของแม่มาแสดงเป็นคำใบ้ว่าหมวดใหม่จะอยู่ระดับใด และเตือนเมื่อระดับของแม่ถึงเพดานแล้ว
- ฟิลด์ที่เหลือประกอบด้วย ชื่อ (สร้าง slug ให้อัตโนมัติแบบเดียวกับหมวดหมู่หลัก), slug, คำอธิบาย, ลำดับ และสถานะ เมื่อบันทึกสำเร็จระบบจะพากลับไปหน้ารายการพร้อมกรองตามหมวดหมู่หลักเดิม
หน้าจอและองค์ประกอบหลัก
หน้ารายการ (src/app/content-subcategory/page.tsx) — เป็นหัวใจของฟีเจอร์ ประกอบด้วยตัวกรองหมวดหมู่หลัก, มุมมอง tree 2 โหมด, การลากวางจัดลำดับ และการลบแบบตรวจผลกระทบ ดึงข้อมูลจาก GET /content-subcategories/by-category/{categoryId}/tree และบันทึกลำดับผ่าน PATCH /content-subcategories/reorder/sort-order
หน้าฟอร์ม (src/app/content-subcategory/form/page.tsx) — ใช้ทั้งสร้างและแก้ไข รับพารามิเตอร์ id เพื่อเข้าโหมดแก้ไข และรับ categoryId กับ parentId เพื่อตั้งค่าเริ่มต้นตอนสร้าง ตัวเลือกหมวดย่อยแม่มาจาก endpoint แบบ hierarchical dropdown ที่แสดงลำดับชั้นให้เห็นในบรรทัดเดียว
เซอร์วิสกลาง (src/services/content-subcategory.service.ts) — รวบรวมทุกคำสั่งของโมดูล ทั้งการดึงแบบ tree, แบบ dropdown, การดึงเส้นทางของกิ่ง (path), การเพิ่ม-แก้-ลบ และการจัดลำดับ โดยคำสั่งลบรองรับพารามิเตอร์บังคับลบเพิ่มเติม
โครงสร้างข้อมูลสำคัญ — ระเบียนหมวดย่อยหนึ่งรายการเก็บ parentId, level, ข้อมูลหมวดหมู่หลักที่สังกัด, ข้อมูลแม่ และรายการลูก ส่วนผลลัพธ์ของการลบจะคืนสถานะว่าลบสำเร็จหรือไม่ พร้อมจำนวนลูกและจำนวนเนื้อหาที่จะได้รับผลกระทบ
จุดเชื่อมต่อกับฟีเจอร์อื่น
- สิทธิ์การเข้าถึง — ใช้ subject
content-subcategoryเป็นคีย์ของเมนูย่อย ซึ่งถูกปลดล็อกจากโมดูลline-oaฝั่งหลังบ้าน ส่วนการมองเห็นเมนูแม่ขึ้นกับสิทธิ์ของ Content Management - หมวดหมู่เนื้อหา (Content Category) — เป็นระดับแม่โดยตรง มีปุ่มลิงก์ข้ามหน้ามาที่นี่พร้อมส่งชื่อหมวดหมู่มาแสดงเป็นบริบท
- จัดการเนื้อหา (Content Management) — ใช้ตัวเลือกหมวดย่อยจากโมดูลนี้ตอนสร้างหรือแก้ไขเนื้อหา
- ลิงก์รวมเนื้อหา (Content Links) — ใช้หมวดย่อยเป็นหนึ่งในเงื่อนไขคัดเลือกเนื้อหา
- คอมโพเนนต์
CategoryCascader— แปลง tree เดียวกันนี้เป็นตัวเลือกแบบ cascader โดยถือว่ากิ่งที่ระดับ 4 เป็นปลายทางที่ไม่มีลูกต่อ - ค่าคงที่ร่วม — สถานะมาตรฐาน (
COMMON_STATUS), รายการ subject ของสิทธิ์ (MODULE_URL) และเพดานความลึก 4 ระดับ ซึ่งประกาศไว้ทั้งในหน้ารายการและหน้าฟอร์ม
รายละเอียดฝั่ง Backend (CMS API)
โมดูลนี้อยู่ที่ internal/modules/contentsubcategory/ ทุก endpoint ถูกลงทะเบียนไว้ใต้กลุ่ม /api/content-subcategories
สิทธิ์และการจำกัดขอบเขตข้อมูล
- ทุก request ต้องผ่าน
JwtAuthระดับ global ก่อน — ไม่มี endpoint สาธารณะในโมดูลนี้ - แต่ละ handler ประกาศ policy metadata ของโมดูล
line-oaไว้แล้ว (readAll/read/create/update/delete) แต่ ยังไม่ได้บังคับใช้จริง ในเวอร์ชันปัจจุบัน และโมดูลนี้ไม่มีModuleGateคุมอยู่ ผลคือผู้ใช้ที่ล็อกอินแล้วยังเข้าถึงได้แม้สิทธิ์ย่อยจะไม่ครบ — การซ่อนเมนูจึงเป็นการควบคุมที่ฝั่งหน้าจอเป็นหลัก - ขอบเขตข้อมูลอ่านจาก context ของ request (
lineOaId,organizationId) ไม่ได้รับจาก payload ทำให้ผู้ใช้เห็นเฉพาะหมวดย่อยของ OA ที่ตนกำลังทำงานอยู่
เพดานความลึกของต้นไม้
- backend เก็บเพดานไว้เป็นค่าคงที่
maxSubcategoryLevelและตรวจตอนPOST /api/content-subcategoriesถ้าระดับที่จะสร้างเกินเพดาน จะถูกปฏิเสธ ไม่ใช่แค่คำเตือนบนหน้าจอ - การนับระดับถือ Content Category เป็นชั้นที่ 1 แล้วซ้อนหมวดย่อยได้อีก 4 ชั้น จึงเป็นที่มาของตัวเลข "4 ระดับ" ที่หน้าจอแสดง
- เพราะเป็นการตรวจฝั่งเซิร์ฟเวอร์ การส่ง
parentIdที่อยู่ระดับสุดท้ายเข้ามาโดยตรง (เช่น เรียก API เอง หรือ deep link ที่ค้างอยู่) ก็จะถูกบล็อกเช่นกัน
endpoint ที่มีเฉพาะโมดูลนี้
เพราะเป็นโครงต้นไม้ โมดูลนี้จึงมี endpoint อ่านข้อมูลมากกว่าหมวดหมู่ปกติ แต่ละแบบมีจุดประสงค์ต่างกัน
GET /api/content-subcategories/by-category/:categoryId/tree— โครงต้นไม้เต็มของหมวดหนึ่ง ใช้กับมุมมอง treeGET /api/content-subcategories/by-category/:categoryId/hierarchical-dropdown— dropbox ที่เยื้องตามชั้น ใช้เป็นตัวเลือก "หมวดย่อยแม่" ในฟอร์มGET /api/content-subcategories/dropdownและ.../by-category/:categoryId/dropdown— dropdown แบน (ทั้งระบบ / เฉพาะหมวด)GET /api/content-subcategories/:id/path— คืนเส้นทางจากหมวดรากถึงโหนดนี้ ใช้ทำ breadcrumb และใช้ตรวจระดับของแม่
การจัดลำดับ
PATCH /api/content-subcategories/reorder/sort-order รับรายการคู่ id กับ sortOrder มาเป็นชุดเดียว แล้วอัปเดตทั้งกลุ่มในคำสั่งเดียว จึงตรงกับพฤติกรรมของหน้าจอที่คำนวณลำดับใหม่ทั้งกลุ่มพี่น้องก่อนบันทึก ข้อควรระวังคือ route นี้ต้องถูกลงทะเบียน ก่อน PATCH /api/content-subcategories/:id มิฉะนั้นคำว่า reorder จะถูกตีความเป็น :id
การลบและผลข้างเคียง
DELETE /api/content-subcategories/:idจะถูกปฏิเสธเมื่อโหนดนั้นยังมีหมวดย่อยลูก หรือยังมีคอนเทนต์ผูกอยู่ พร้อมคืนจำนวนที่ได้รับผลกระทบกลับไป — นี่คือที่มาของกล่องยืนยัน "2 จังหวะ" บนหน้าจอ- ส่ง query
forceเข้ามาเพื่อบังคับลบ - การลบเป็น soft delete แบบเขียนคอลัมน์
deleted_dateเอง ไม่ได้ใช้กลไก soft delete ของ ORM ผลตามมาที่ควรรู้:- ทุก query ต้องใส่เงื่อนไข
deleted_date IS NULLด้วยตัวเอง ถ้าลืมที่ใดที่หนึ่ง ข้อมูลที่ลบแล้วจะโผล่กลับมา - การลบเป็นคำสั่ง UPDATE ธรรมดา ระเบียนยังอยู่ในฐานข้อมูล จึงกู้คืนได้แต่ก็ยังกิน slug เดิมอยู่
- ทุก query ต้องใส่เงื่อนไข
ตารางที่เกี่ยวข้อง
content_subcategory เป็นตารางหลัก และอ่านประกอบกับ content_category, content_page (เพื่อนับคอนเทนต์ที่ผูกอยู่) และ line_oa