Skip to main content

หมวดหมู่ย่อยเนื้อหาแบบลำดับชั้น

ภาพรวม

หมวดหมู่ย่อยเนื้อหา (Content Subcategory) คือชั้นการจัดหมวดที่อยู่ใต้ Content Category และสามารถซ้อนกันเองได้หลายระดับ ผ่านการอ้างอิงตัวเอง (parentId ประกอบกับ level) โดยหน้าจอจำกัดความลึกสูงสุดไว้ที่ 4 ระดับ

ต่างจากหน้าจัดการข้อมูลทั่วไปที่เป็นตาราง หน้านี้แสดงผลเป็น โครงสร้างต้นไม้ (tree) เพื่อให้เห็นความสัมพันธ์แม่-ลูกได้ทันที รองรับการจัดลำดับด้วยการลากวาง และการลบที่ตรวจสอบผลกระทบก่อนเสมอ

เหมาะกับทีมคอนเทนต์ที่มีเนื้อหาปริมาณมากและต้องการจัดกลุ่มละเอียดกว่าระดับหมวดหมู่หลัก เช่น "โปรโมชัน > ลูกค้าใหม่ > เดือนมกราคม"

Business Flow

  1. เปิดหน้ารายการหมวดหมู่ย่อย ระบบจะโหลดรายการหมวดหมู่หลักมาเป็นตัวเลือกของฟิลเตอร์ ผู้ใช้อาจเข้ามาจากหน้าหมวดหมู่หลักพร้อมพารามิเตอร์ระบุหมวดหมู่ที่ต้องการดูก็ได้
  2. เมื่อเลือกหมวดหมู่หลัก ระบบจะแสดง tree ของหมวดหมู่ย่อยเฉพาะหมวดนั้น และกางทุกกิ่งให้อัตโนมัติ
  3. เมื่อไม่เลือกหมวดหมู่หลัก ระบบจะไล่โหลด tree ของทุกหมวดหมู่มาแสดงเป็นแผงพับ (Collapse) แยกตามหมวด โดยเปิดแผงแรกที่มีหมวดย่อยให้อัตโนมัติ
  4. แต่ละกิ่งใน tree มีเมนูคำสั่ง 3 อย่าง คือ แก้ไข, เพิ่มหมวดย่อยใต้กิ่งนี้ (ปรากฏเฉพาะเมื่อยังไม่ถึงระดับที่ 4) และลบ
  5. การจัดลำดับด้วยการลากวาง อนุญาตให้สลับตำแหน่งได้เฉพาะกิ่งที่เป็นพี่น้องระดับเดียวกัน (มีแม่เดียวกัน) เท่านั้น เมื่อวางสำเร็จระบบจะคำนวณลำดับใหม่ทั้งกลุ่มแล้วบันทึกในครั้งเดียว
  6. การลบทำเป็น 2 จังหวะ โดยระบบจะลองลบก่อน ถ้าฝั่งเซิร์ฟเวอร์พบว่ามีหมวดย่อยลูกหรือเนื้อหาผูกอยู่ จะส่งจำนวนที่ได้รับผลกระทบกลับมาแสดงในกล่องยืนยัน เมื่อผู้ใช้ยืนยันจึงลบแบบบังคับอีกครั้ง
  7. การสร้างและแก้ไข ทำที่หน้าฟอร์มแยก โดยเลือกหมวดหมู่หลัก (บังคับ และล็อกไว้ในโหมดแก้ไข) จากนั้นเลือกหมวดย่อยแม่ (ไม่บังคับ และล็อกไว้ในโหมดแก้ไขเช่นกัน) ระบบจะอ่านระดับของแม่มาแสดงเป็นคำใบ้ว่าหมวดใหม่จะอยู่ระดับใด และเตือนเมื่อระดับของแม่ถึงเพดานแล้ว
  8. ฟิลด์ที่เหลือประกอบด้วย ชื่อ (สร้าง 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 — โครงต้นไม้เต็มของหมวดหนึ่ง ใช้กับมุมมอง tree
  • GET /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 เดิมอยู่

ตารางที่เกี่ยวข้อง

content_subcategory เป็นตารางหลัก และอ่านประกอบกับ content_category, content_page (เพื่อนับคอนเทนต์ที่ผูกอยู่) และ line_oa