Skip to content

4.3 · State, styling, locale (พ.ศ.)

บทนี้อธิบายว่า vtrc-rc-backoffice จัดการ state อย่างไร (4 ระบบผสม), CSS กี่ชั้น และจัดการ locale พ.ศ. (Buddhist Era) อย่างไร เนื้อหาทั้งหมดอ้างอิงกับ source จริงใน repo ไม่ใช่ speculation


ภาพรวมสถาปัตยกรรม

vtrc-rc-backoffice เป็น React 16 single-page app ที่เกิดจากการ migration หลายยุค ทำให้ state, styling, และ locale มีหลาย pattern อยู่ใน codebase เดียวกัน:

ด้านPattern ที่ใช้จริงจำนวน
Stateclass this.state + hook useState + Recoil + localStorage4 ระบบผสม
Global state atomsrecoil/states/ folder-per-entity212 atoms, 57 folders
CSS layersAntD default + custom override + main + theme + module-scoped + inline7 ชั้น
Localemoment.js + moment/locale/th + manual +543ไม่มี i18n library
Theme colorshardcoded ใน CSS + inlineไม่มี theme switching

State management — 4 ระบบผสม

vtrc-rc-backoffice/src/index.js:1-33 เป็นจุดเข้าของแอป ครอบทั้ง ApolloProvider และ RecoilRoot ไว้ด้วยกัน:

javascript
class Root extends Component {
  render() {
    return (
      <ApolloProvider client={apolloClient} >
        <RecoilRoot>
          <App />
        </RecoilRoot>
      </ApolloProvider>
    );
  }
}

การมี RecoilRoot ที่ root ทำให้ทุก component ใน tree เรียก useRecoilState / useRecoilValue ได้ แต่ module เก่าที่เขียนในรูปแบบ class component ก็ยังใช้ this.state อยู่ ส่งผลให้ codebase มี state pattern ผสมกัน 4 แบบ:

1. Local component state (class — this.state)

Module เก่า (Leave, ManageUser, WelfareSetting, SicknessName) ส่วนใหญ่ใช้ pattern นี้:

javascript
class MyComponent extends React.Component {
  state = {
    users: [],
    loading: false,
    modalVisible: false,
  };

  componentDidMount() {
    this.setState({ loading: true });
    // fetch ...
  }

  // อัปเดตโดยใช้ค่าเดิม
  this.setState((prev) => ({ count: prev.count + 1 }));
}

ข้อดี: เรียบง่าย, แยกจาก component อื่นโดยสมบูรณ์, ไม่ต้อง setup library

ข้อเสีย: ไม่ share ข้าม component — ถ้า 2 component ต้องการ state เดียวกัน ต้องยกขึ้น parent (prop drilling) หรือเปลี่ยนไปใช้ Recoil; refresh browser แล้ว state หาย

2. Local component state (hook — useState)

Module ใหม่ที่ไม่ต้อง share state ข้าม component (เช่น form เล็ก ๆ) ใช้ useState:

javascript
const MyComponent = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [modalVisible, setModalVisible] = useState(false);

  setLoading(true);
  setUsers([...users, newUser]);
};

3. Global state (Recoil)

Module WorkForce และ module ใหม่ ๆ ใช้ Recoil เป็น global store ตัวอย่างจาก vtrc-rc-backoffice/src/recoil/states/Employee/index.js:1-13:

javascript
import { atom } from 'recoil';

const currentEmployeeIdState = atom({
  key: 'currentEmployeeIdState',
  default: ''
})

const employeeDataState = atom({
  key: 'employeeDataState',
  default: ''
})

export { currentEmployeeIdState, employeeDataState }

การใช้งานใน component:

javascript
const [employees, setEmployees] = useRecoilState(employeeDataState);
const employees = useRecoilValue(employeeDataState);       // อ่านอย่างเดียว
const setEmployees = useSetRecoilState(employeeDataState); // เขียนอย่างเดียว

4. Persisted state (localStorage + sessionStorage)

ใช้สำหรับ token, refresh token, และค่า WorkForce ที่ต้องอยู่ข้าม refresh ดูรายละเอียดใน section "Persisted state" ด้านล่าง


Recoil atom organization

vtrc-rc-backoffice/src/recoil/states/ มีโครงสร้าง folder-per-entity (57 folders, 212 atoms รวม):

src/recoil/states/
├── Employee/index.js          ← 2 atoms
├── Position/index.js          ← 12 atoms
├── Candidate/index.js         ← 5 atoms
├── ManpowerManage/index.js    ← 4 atoms
├── EvaluationPeriod/index.js  ← 4 atoms
├── default.js                 ← ~22 atoms (shared UI state)
├── ... (52 folders อื่น ๆ)

Pattern 1 — entity-centric atoms

แต่ละ entity มี atom แยกตามหน้าที่ ดังตัวอย่าง vtrc-rc-backoffice/src/recoil/states/Candidate/index.js:1-29:

javascript
const currentCandidateIdState = atom({ key: 'currentCandidateIdState', default: '' })
const candidateDataState      = atom({ key: 'candidateDataState',      default: [] })
const profileState            = atom({ key: 'profileState',            default: [] })
const isShowInfoCandidateState = atom({ key: 'isShowInfoCandidateState', default: false })
const isShowListModalState    = atom({ key: 'isShowListModalState',    default: false })

ชื่อ atom มักตาม convention ต่อไปนี้:

Atom namingหน้าที่
current<Entity>IdStateid ที่ user เลือก (row click)
<entity>DataStateข้อมูลที่ fetch มาแสดงใน form/info
isShow...Stateflag เปิด/ปิด modal หรือ panel
dropdown...Stateตัวเลือกใน AntD Select
ddl...State(บาง entity) dropdown list ที่ใช้ซ้ำ

ตัวอย่าง dropdown-heavy atom จาก vtrc-rc-backoffice/src/recoil/states/Position/index.js:1-62:

javascript
const dropdownOrgUnitDataState         = atom({ key: 'dropdownOrgUnitDataState',         default: '' })
const dropdownPositionManageDataState  = atom({ key: 'dropdownPositionManageDataState',  default: '' })
const dropdownPositionCareerDataState  = atom({ key: 'dropdownPositionCareerDataState',  default: '' })
const dropdownEmployeeLevelState       = atom({ key: 'dropdownEmployeeLevelState',       default: '' })
const dropdownEmployeeTypeState        = atom({ key: 'dropdownEmployeeTypeState',        default: '' })
const dropdownPositionSuperVisorDataState = atom({ key: 'dropdownPositionSuperVisorDataState', default: '' })

Pattern 2 — shared UI state (default.js)

vtrc-rc-backoffice/src/recoil/states/default.js:1-180 เก็บ atom ที่ใช้ข้ามหลาย module เช่น mode flag, pagination, list cache, month names:

javascript
const isShowListState       = atom({ key: 'isShowListState',       default: true })
const isCanCreateState      = atom({ key: 'isCanCreateState',      default: false })
const isCanEditState        = atom({ key: 'isCanEditState',        default: false })
const isCanDeleteState      = atom({ key: 'isCanDeleteState',      default: false })
const isNewModeState        = atom({ key: 'isNewModeState',        default: true })
const isViewOnlyModeState   = atom({ key: 'isViewOnlyModeState',   default: true })
const isLoadingState        = atom({ key: 'isLoadingState',        default: false })
const currentMenuNameState  = atom({ key: 'currentMenuNameState',  default: null })
const currentTabNameState   = atom({ key: 'currentTabNameState',   default: null })

datasListState เก็บโครงสร้าง list + pagination + preload flag รวมกัน:

javascript
const datasListState = atom({
  key: 'datasListState',
  default: {
    datas: [],
    pageData: { offset: 0, limit: 10 },
    pagination: { total: 0, current: 1, showSizeChanger: false },
    preLoadTable: false,
  },
});

ยังมี atom ที่เก็บ reference data ภาษาไทยฝังใน default โดยตรง เช่นรายชื่อเดือนไทยและ quarter grouping จาก vtrc-rc-backoffice/src/recoil/states/default.js:94-128:

javascript
const allMonthNameState = atom({
  key: 'allMonthNameState',
  default: [
    { id: "1",  monthName: "มกราคม" },
    { id: "2",  monthName: "กุมภาพันธ์" },
    { id: "3",  monthName: "มีนาคม" },
    // ...
    { id: "12", monthName: "ธันวาคม" },
  ],
})

const allMonthQuarterNameState = atom({
  key: 'allMonthQuarterNameState',
  default: [
    { id: "10", monthName: "ตุลาคม",   quarter: "1" },
    { id: "11", monthName: "พฤศจิกายน", quarter: "1" },
    { id: "12", monthName: "ธันวาคม",   quarter: "1" },
    { id: "1",  monthName: "มกราคม",    quarter: "2" },
    // ...
  ],
})

Pattern 3 — selector (commented out)

vtrc-rc-backoffice/src/recoil/states/default.js:1 import selector ไว้แล้วแต่ block ที่ใช้จริงถูก comment เอาไว้ตามที่เห็นใน vtrc-rc-backoffice/src/recoil/states/default.js:144-150:

javascript
// const charCountState = selector({
//     key: 'charCountState',
//     get: ({ get }) => {
//         const text = get(approveCode);
//         return text.length;
//     },
// });

สรุป: codebase นี้ ไม่ได้ใช้ selector จริง ในระดับ production — derived state ทำใน component ด้วย useMemo หรือคำนวณตอน render ตรง ๆ ไม่มี atomFamily หรือ selectorFamily เช่นกัน

Atom key uniqueness

Recoil บังคับว่า key ต้อง unique ทั้ง app เพราะใช้ string เป็น identity ใน codebase นี้ทีมใช้ convention คือ key เหมือนชื่อตัวแปรทุกประการ (เช่น currentEmployeeIdState) ทำให้ grep หาได้ง่าย แต่ถ้ามี atom ชื่อชนกันใน folder คนละ entity Recoil จะ throw ตอน runtime ซึ่งเคยเป็น source ของ bug

ข้อดีของการใช้ Recoil ใน codebase นี้

  • share ข้าม component โดยไม่ต้อง prop drilling
  • debug ง่ายใน React DevTools (Recoil มี panel เฉพาะ)
  • ทำงานกับ React 16 เป็นธรรมชาติ (recoil@0.7.5 ตาม vtrc-rc-backoffice/package.json:65)

ข้อเสีย

  • state ไม่ persist — refresh แล้วหาย (ต้องเสริมด้วย localStorage)
  • เพิ่ม dependency อีกตัว (ตอนนี้คือ recoil@0.7.5 ซึ่งเลิกพัฒนาแล้ว)
  • atom key ไม่ type-check — ชนกันได้และ runtime ถึงจะรู้

Persisted state (localStorage + sessionStorage)

vtrc-rc-backoffice ใช้ localStorage และ sessionStorage แบ่งตาม isRemember flag จากหน้า Login:

Keyที่เก็บที่ใช้อ่าน
tokenLogin, PathLogingetToken() / isLoggedIn() ใน auth.js
refreshTokenLogin, PathLogingetToken()
isRememberLoginสลับ localStorage vs sessionStorage
current<Entity>IdWorkForce row clickgetStorageCurrentId_wf()
viewOnly_wftoggle mode ใน WorkForcegetStorageViewOnly_wf()
empUserGroupuser group ที่ loginsetStorageEmpUserGroup()
current_<key>_searchcache ผลค้นหา listgetStorageSearchList()

auth.js — token helper

vtrc-rc-backoffice/src/utils/auth.js:1-54 จัดการ token โดยสลับ storage ตาม isRemember:

javascript
export const setToken = ({ accessToken, refreshToken }, isRemember) => {
  if (isRemember === undefined) {
    isRemember = localStorage.getItem('isRemember')
  }
  if (isRemember) {
    localStorage.setItem('token', accessToken)
    if (refreshToken) {
      localStorage.setItem('refreshToken', refreshToken)
    }
    localStorage.setItem('isRemember', 'true')
  } else {
    sessionStorage.setItem('token', accessToken)
    if (refreshToken) {
      sessionStorage.setItem('refreshToken', refreshToken)
    }
  }
}

isLoggedIn() อ่านคู่กันเพื่อเช็คว่า token + refreshToken มีอยู่จริงใน storage ที่เลือก:

javascript
export const isLoggedIn = () => {
  if (localStorage.getItem('isRemember')) {
    return !!localStorage.getItem('token') && !!localStorage.getItem('refreshToken')
  } else {
    return !!sessionStorage.getItem('token') && !!sessionStorage.getItem('refreshToken')
  }
}

work-force.service.js — WorkForce-specific helpers with TTL

vtrc-rc-backoffice/src/utils/work-force.service.js:26-57 ใช้รูปแบบ value + expiry ทุกครั้ง TTL default = 1 ชั่วโมง:

javascript
export const getStorageCurrentId_wf = async (key) => {
  const itemStr = localStorage.getItem(key)
  if (!itemStr) return null
  const item = JSON.parse(itemStr)
  const now = new Date()
  if (now.getTime() > item.expiry) {
    localStorage.removeItem(key)
    return null
  }
  return item.value
}

export const setStorageCurrentId_wf = async (key, value, h = 1) => {
  if (value) {
    const now = new Date()
    const item = {
      value: value,
      expiry: now.getTime() + (h * 60 * 60 * 1000),
    }
    localStorage.setItem(key, JSON.stringify(item))
  } else {
    await removeStorage_wf(key)
  }
}

vtrc-rc-backoffice/src/utils/work-force.service.js:96-110 มี helper ล้างค่าทุก key ที่ขึ้นต้นด้วย current และลงท้ายด้วย Id (เช่น currentEmployeeId, currentPositionId):

javascript
export const removeAllStorageCurrentId_wf = async () => {
  const arr = [];
  for (let i = 0; i < localStorage.length; i++) {
    if ((localStorage.key(i).startsWith('current') && localStorage.key(i).endsWith('Id'))
        || (localStorage.key(i) === 'empUserGroup')) {
      arr.push(localStorage.key(i));
    }
  }
  for (let i = 0; i < arr.length; i++) {
    await removeStorage_wf(arr[i])
  }
}

vtrc-rc-backoffice/src/utils/work-force.service.js:160-188 มี helper คล้ายกันสำหรับล้าง search cache (current_*_search) และ candidate list (rc_candidate_list_data_*)

storage.js — generic TTL helper

vtrc-rc-backoffice/src/utils/storage.js:1-30 เป็น version สามัญที่ไม่ได้ใช้เฉพาะ WorkForce รับ ttl เป็น millisecond ตรง ๆ (ต่างจาก work-force.service.js ที่รับเป็นชั่วโมง):

javascript
export const setWithExpiry = (key, value, ttl) => {
  const now = new Date()
  const item = {
    value: value,
    expiry: now.getTime() + ttl,
  }
  localStorage.setItem(key, JSON.stringify(item))
}

export const getWithExpiry = key => {
  const itemStr = localStorage.getItem(key)
  if (!itemStr) return null
  const item = JSON.parse(itemStr)
  const now = new Date()
  if (now.getTime() > item.expiry) {
    localStorage.removeItem(key)
    return null
  }
  return item.value
}

ข้อระวังเรื่อง localStorage

  1. XSS สามารถขโมยได้ — JavaScript ใด ๆ ที่รันในแอปสามารถอ่าน localStorage ได้ ถ้ามี XSS ในหน้า token จะหลุด
  2. ไม่ sync ข้าม tab — ถ้าผู้ใช้เปิด 2 tab อาจได้ state ต่างกัน (localStorage trigger storage event แต่ codebase ไม่ได้ subscribe)
  3. TTL 1 ชม. อาจสั้นเกิน — user ที่ idle นานกว่า 1 ชม. แล้วกลับมาคลิกจะถูก reset
  4. removeAllStorageCurrentId_wf ใช้ prefix convention — ถ้าสร้าง key ใหม่ที่ขึ้นต้นด้วย current ลงท้ายด้วย Id จะถูกล้างด้วย

Styling — CSS หลายชั้น

vtrc-rc-backoffice/src/containers/App/index.js:11-14 import CSS 4 ชั้นแบบ global ตอน app เริ่ม:

javascript
import '../../config/custom-antd.css'
import 'antd/dist/antd.css'
import '../../assets/css/main.css'
import '../../assets/css/redesign.css'

จากนั้นแต่ละ module ยัง import CSS เฉพาะเพิ่ม รวมเป็น 7 ชั้นที่ซ้อนทับกัน:

ชั้นไฟล์ขนาด (บรรทัด)เสี่ยง conflict
1antd/dist/antd.css (จาก antd@4.15.4)~21,000ต่ำ (well-defined)
2src/config/custom-antd.css21,189ปานกลาง (override)
3src/assets/css/main.css4,644สูง (utility + class)
4src/assets/css/redesign.css13ต่ำ
5src/assets/css/theme/*.css (10 ไฟล์)~1,000+สูง (typo, ซ้ำ)
6src/assets/css/work-force.css794ต่ำ (isolated)
7Component-scoped CSS + inline~5,000+ปานกลาง

รวม CSS ที่โหลดตอนเปิดแอป: ~35,000+ บรรทัด


Layer 1 — AntD default

antd/dist/antd.css import ใน vtrc-rc-backoffice/src/containers/App/index.js:12 เป็น CSS default ของ AntD v4 (antd@4.15.4 ตาม vtrc-rc-backoffice/package.json:21) เป็น CSS ขนาดใหญ่ประมาณ 21,000 บรรทัด ถูก override โดย custom-antd.css ในเลเยอร์ถัดไป

Note: AntD v4 แนะนำให้ใช้ less variable + ConfigProvider แทนการ import antd.css แล้ว override ด้วย CSS แต่ codebase นี้ใช้แนวทาง import + override

Layer 2 — custom-antd.css (21,189 บรรทัด)

ไฟล์ใหญ่ที่สุดในแอป เป็น compiled AntD theme override บรรทัดแรก ๆ จาก vtrc-rc-backoffice/src/config/custom-antd.css:1-100:

css
/* stylelint-disable at-rule-empty-line-before,at-rule-name-space-after,at-rule-no-unknown */
/* stylelint-disable no-duplicate-selectors */
/* stylelint-disable */
html,
body {
  width: 100%;
  height: 100%;
}
*,
*::before,
*::after {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: rgba(0, 0, 0, 0.65);
  font-size: 14px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
               'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue',
               Helvetica, Arial, sans-serif, 'Apple Color Emoji',
               'Segoe UI Emoji', 'Segoe UI Symbol';
  font-variant: tabular-nums;
  line-height: 1.5;
  background-color: #fff;
  font-feature-settings: 'tnum';
}

ที่มา: ไฟล์นี้เป็น compiled output จาก sass — ทีมใช้ Prepros (desktop app) คอมไพล์ assets/sass/main.scssassets/css/main.css + เพิ่ม override ใน custom-antd.css

ข้อระวัง:

  1. ห้ามแก้ custom-antd.css โดยตรง — เป็น compiled output การแก้จะถูกเขียนทับตอน compile ใหม่
  2. แก้ที่ sass source แทน — assets/sass/main.scss + assets/sass/work-force.scss
  3. Prepros ไม่ได้ลงใน CI — ทำให้การ build ใน Docker อาจไม่ได้คอมไพล์ sass ใหม่

Layer 3 — main.css (4,644 บรรทัด)

vtrc-rc-backoffice/src/assets/css/main.css:1-50 import font Kanit 3 น้ำหนักแล้วนิยาม color utility class ฝังตรง ๆ:

css
@charset "UTF-8";
@import url("../fonts/Kanit-Medium/styles.css");
@import url("../fonts/Kanit-Regular/styles.css");
@import url("../fonts/Kanit/styles.css");

.color-tabHeader     { color: #C9F1FD; }
.color-menuIcon      { color: #777777; }
.color-deleteButton  { color: #4E6878; }
.color-favorite      { color: #FF9820; }
.color-checkbox      { color: #00BEF2; }
.color-tooltip       { color: #4A4A4A; }
.color-toggleSwitchOn{ color: #65C466; }
.color-success       { color: #119C59; }
.color-remind        { color: #EFAF00; }
.color-information   { color: #007BFF; }
.color-black         { color: #1E1E1E; }

Pattern ที่พบใน main.css:

หมวดตัวอย่าง class
Reset / normalize*, body, html
Typography.title, .subtitle, .text-link
Layout utilities.flex, .flex-col, .mb-2, .mt-4
Color tokens.color-success, .color-remind, .color-information
Component helpers.card, .form-section, .table-action

ข้อระวัง:

  • ใช้ร่วมกับ AntD class — บางครั้ง conflict (เช่น .ant-btn override)
  • มี utility คล้าย Tailwind แต่ไม่ใช่ Tailwind

Layer 4 — redesign.css (13 บรรทัด)

ไฟล์เล็กที่สุด vtrc-rc-backoffice/src/assets/css/redesign.css:1-13 มีแค่ sider width override และ placeholder selector ที่ยังไม่มี rule:

css
.ant-sider-redesign {
  width: 112px !important;
  max-width: 112px !important;
  flex: 0 0 112px !important;
}

.menu-sidebar-click {

}

เป็นไฟล์ที่เริ่มต้น redesign แต่ยังไม่สมบูรณ์ ใช้ !important ทำให้ override สูง

Layer 5 — theme/*.css (ไฟล์ที่มีชื่อใกล้เคียงกัน)

vtrc-rc-backoffice/src/assets/css/theme/ มี 10 ไฟล์:

abouts.css
ckeditor.css
inculdesNewsRedesign/        ← folder (typo: inculdes ควรเป็น includes)
login.css
profile.css
redesignBackoffice.css
template.css                 ← ชื่อที่ถูก
themeplate.css               ← typo (เพิ่ม h)
themplate.css                ← typo (หาย m)
time_attendace.css           ← typo (หาย d — ควรเป็น attendance)

ปัญหา: มี 3 ไฟล์ที่ชื่อใกล้เคียงกันมาก (template / themeplate / themplate) ทำให้ import ผิดไฟล์ได้ง่าย และ style ไม่ทำงาน

ผลกระทบ: dev ใหม่ที่เข้ามาอาจจะ import ผิดไฟล์ ทำให้ style ของ page นั้นหาย

คำแนะนำ:

  • grep หาการ import แต่ละไฟล์เพื่อดูว่าตัวจริงคือตัวไหน
  • อย่าสร้างไฟล์ใหม่ที่ชื่อใกล้เคียง

Layer 6 — work-force.css (794 บรรทัด)

เฉพาะ WorkForce module แยกจาก main.css เพราะ WorkForce มี design เฉพาะ (grid layout, custom card)

Layer 7 — Component-scoped CSS + inline

แต่ละ component มีไฟล์ .css คู่กับ .js เช่น src/components/SiderLayout/index.css และมี inline React style object (style=\{...\}) ใช้บ่อยใน module ใหม่

ข้อดี: ไม่ leak style ไปยัง component อื่น (ถ้าตั้งใจ)

ข้อเสีย:

  • ไม่ใช่ CSS Modules — ไม่มี scope จริง, class name อาจชนกัน
  • import ทุกครั้งที่ component ถูก import — ทำให้ CSS โหลดทั้งหมด
  • inline style ไม่สามารถ override ด้วย media query — ทำ responsive ยาก

MUI v5 ผสมใน AntD v4 codebase

vtrc-rc-backoffice/package.json:16-17 ลง @mui/material@^5.11.1 และ @mui/icons-material@^5.11.0 ไว้ใน dependency ทำให้มี UI library 2 ตัวใน codebase เดียว (AntD v4 + MUI v5)

json
"@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.1",

การใช้งานจริงกระจายอยู่ใน module ใหม่ ๆ เช่น vtrc-rc-backoffice/src/pages/WorkForce/Employee/info/index.js:9,20:

javascript
import { Label } from '@mui/icons-material';
import { formGroupClasses } from '@mui/material';

หรือ vtrc-rc-backoffice/src/pages/Payment/ExportPayment/Form/index.js:46:

javascript
import { teal } from "@mui/material/colors";

ข้อสังเกตสำคัญ:

  1. ไม่มี ThemeProvider หรือ createTheme ที่ global — grep ทั้ง codebase ไม่พบ createTheme หรือ ThemeProvider MUI จึงทำงานด้วย default theme ที่ไม่ได้ปรับ
  2. การ import บางรายการเป็น internal API (เช่น formGroupClasses, getAccordionDetailsUtilityClass) — ใช้เพื่อ override className ของ MUI component ไม่ใช่การใช้ component จริง
  3. ไม่มี source-of-truth ว่า MUI ใช้ในหน้าไหนบ้าง — ต้อง grep from "@mui/material ทีละไฟล์

ผลกระทบ:

  • bundle size โตขึ้นเพราะลงทั้ง AntD + MUI
  • theme ของ MUI ไม่ตรงกับ AntD (font, color, spacing) ทำให้ UI ดูไม่ consistent
  • migration ไป MUI เต็มตัวไม่ได้เกิดจริง — ยังเป็น AntD เป็นหลัก

Locale — ภาษาไทย + พ.ศ. (Buddhist Era)

vtrc-rc-backoffice ไม่มี global LocaleProvider หรือ ConfigProvider ที่ root tree จาก vtrc-rc-backoffice/src/index.js:1-33 เห็นได้ว่า Root ครอบแค่ ApolloProvider + RecoilRoot ไม่มี locale wrapper ใด ๆ

แต่ละ component จัดการ locale เองที่ใช้งานจริงมี 2 ทาง:

Pattern A — เรียก moment.locale("th") เฉพาะที่

vtrc-rc-backoffice/src/pages/NotificationMain/index.js:8-9,128:

javascript
import moment from "moment";
import "moment/locale/th";
// ...
moment.locale("th");

เรียก moment.locale("th") ใน component constructor / componentDidMount ซึ่งเป็น global mutation — เปลี่ยน locale ของ moment ทั้ง app ไม่ใช่เฉพาะ component นั้น

Pattern B — inline Thai month names (ไม่ใช้ moment.locale)

vtrc-rc-backoffice/src/utils/formatdate.js:1-39 นิยาม dictionary เดือนไทยเองแล้ว format ด้วยมือ:

javascript
import moment from 'moment'

export const changeFormatdate = date => {
  let monthThai = [];
  monthThai['01'] = 'มกราคม';
  monthThai['02'] = 'กุมภาพันธ์';
  monthThai['03'] = 'มีนาคม';
  monthThai['04'] = 'เมษายน';
  monthThai['05'] = 'พฤษภาคม';
  monthThai['06'] = 'มิถุนายน';
  monthThai['07'] = 'กรกฎาคม';
  monthThai['08'] = 'สิงหาคม';
  monthThai['09'] = 'กันยายน';
  monthThai['10'] = 'ตุลาคม';
  monthThai['11'] = 'พฤศจิกายน';
  monthThai['12'] = 'ธันวาคม';

  const newdate = moment(date).format('D') + " "
    + monthThai[moment(date).format('MM')] + " "
    + (parseInt(moment(date).format('YYYY')) + 543).toString()
  return newdate
}

เดือนไทย dictionary ถูกนิยามซ้ำในหลาย function ใน formatdate.js เดียวกัน: changeOnlyMonth, changeFormatdate, changeFormatFullDateTime, changeFormatFulldate, changeFormatdateThai — ทุก function ประกาศ array monthThai ใหม่ (copy-paste)

Format helpers ทั้งหมดใน formatdate.js

vtrc-rc-backoffice/src/utils/formatdate.js:1-161 export function ดังนี้:

FunctionOutputตัวอย่าง
changeOnlyMonth(date)ชื่อเดือนอย่างเดียว"กรกฎาคม"
changeFormatdate(date)D <เดือน> พ.ศ."10 กรกฎาคม 2569"
changeFormatFullDateTime(date)D <เดือน> พ.ศ. HH:mm"10 กรกฎาคม 2569 14:30"
changeFormatFulldate(date)เหมือน changeFormatFullDateTime"10 กรกฎาคม 2569 14:30"
changeFormatdateThai(date)เหมือน changeFormatdate แต่ปี ค.ศ. (ลืม +543)"10 กรกฎาคม 2026"
changeYear(year)+543"2569"
timeDate(date)HH:mm"14:30"
dateTh(date)DD/MM/พ.ศ."10/07/2569"
dateEng(date)D/MM/yyyy"10/07/2026"
dateEngTime(date)D/MM/yyyy HH:mm"10/07/2026 14:30"
getTimeFromMins(mins)H ชั่วโมง"2 ชั่วโมง"

และใน vtrc-rc-backoffice/src/utils/work-force.service.js:192-195 ยังมี formatDateThai ซ้ำกับ dateTh:

javascript
export const formatDateThai = date => {
  const newdate = moment(date).format('D') + "/"
    + moment(date).format('MM') + "/"
    + (parseInt(moment(date).format('YYYY')) + 543).toString()
  return newdate
}

ปัญหา: มี helper format วันที่กระจายกัน 3 ที่ (formatdate.js, work-force.service.js, และ inline ในแต่ละ module) บางอันชื่อคล้ายกันแต่ behavior ต่างกัน (changeFormatdate vs changeFormatdateThai ต่างกันที่ +543)

การใช้ใน AntD Table column

javascript
import { changeFormatdate } from '../../../utils/formatdate'

const columns = [
  {
    title: 'วันที่สร้าง',
    dataIndex: 'createdAt',
    render: (date) => changeFormatdate(date),
  },
]

การใช้ใน AntD DatePicker

javascript
<DatePicker
  format="DD/MM/YYYY"
  onChange={(date, dateStr) => {
    // dateStr = "10/07/2569" ถ้า locale เป็น th
    // แต่ส่วนใหญ่ codebase เก็บเป็น ค.ศ. ใน state แล้วแปลงตอนแสดง
    form.setFieldsValue({ startDate: date })
  }}
/>

AntD Table empty text locale

หลาย module override empty text ของ AntD Table เป็นภาษาไทย inline โดยตรง เช่น vtrc-rc-backoffice/src/pages/WorkForce/ManpowerReturn/list.js:778:

javascript
locale={{ emptyText: "ไม่พบข้อมูลรายการ" }}

grep พบ override แบบนี้มากกว่า 20 จุดใน codebase แสดงว่าไม่ได้ตั้งที่ global

ข้อระวังเรื่องพ.ศ.

  1. moment.locale('th') ปรับปีอัตโนมัติ — moment locale th จะแสดงปีเป็นพ.ศ.ใน YYYY format ด้วย แต่ทีมยังใช้ manual +543 ในหลายจุด ทำให้บางจุดอาจบวก 543 สองครั้ง
  2. backend ส่ง/รับ ค.ศ. — frontend แปลงพ.ศ. ตอนแสดงผลเท่านั้น (ใช้ ISO 8601 ใน GraphQL)
  3. moment ล้าสมัยแล้วmoment@^2.27.0 ตาม vtrc-rc-backoffice/package.json:47 เป็น library ที่อยู่ใน maintenance mode ควรพิจารณาย้ายไป dayjs (ที่ลงไว้ dayjs@^1.11.7 ใน vtrc-rc-backoffice/package.json:34 แต่ยังไม่ได้ migrate)
  4. ไม่มี i18n สำหรับภาษาอื่น — ทุกข้อความฝังเป็นภาษาไทยในโค้ด ไม่มี react-intl หรือ i18next

Pattern ที่ต้องระวัง

javascript
// ❌ ผิด — บวก 543 แล้วส่งไป server (server จะเก็บปี 2666)
const wrongDate = moment().year(moment().year() + 543).toISOString();
await saveDate({ date: wrongDate });

// ✓ ถูก — ส่ง ค.ศ. ไป server, แปลง พ.ศ. ตอนแสดงผลเท่านั้น
await saveDate({ date: moment().toISOString() });
// ตอนแสดงผล
changeFormatdate(savedDate);

Theme — hardcoded colors, ไม่มี theme switching

ไม่มี dark mode / light mode toggle สีทั้งหมด hardcoded ใน:

  • main.css (.color-success, .color-remind, ฯลฯ — ฝังเป็น hex string)
  • custom-antd.css (override AntD default)
  • inline style object เช่น color: '#FF9820' ในแต่ละ component

ไม่มี CSS variable ที่ตั้ง theme token ไม่มี AntD ConfigProvider theme prop และไม่มี MUI createTheme เพื่อรวม color token ทั้งระบบ

ผลกระทบ:

  • การเปลี่ยนสีหลักของแอปต้อง grep และแก้ทุกจุด
  • ไม่สามารถทำ dark mode ได้โดยไม่ refactor ครั้งใหญ่

เปรียบเทียบกับ vtrc-web (Volume 3)

ด้านvtrc-rc-backofficevtrc-web
Stateclass + useState + Recoil + localStorageclass + useState
Recoilใช้ (212 atoms, 57 folders)ไม่ใช้
UI libraryAntD v4 + MUI v5 ผสมAntD v4 อย่างเดียว
CSS systems7 ชั้น5 ชั้น
custom-antd.css21,189 บรรทัด~10,000 บรรทัด
Theme typosมี (template/themeplate/themplate)ไม่มี
momentใช้ (locale th + manual +543)ใช้ (manual +543)
ConfigProviderไม่มี globalไม่มี global
localStorageละเอียด (TTL helper)basic

เคล็ดลับตอนทำงานจริง

ตอนเพิ่ม state ใหม่

  1. ถ้าใช้เฉพาะใน component → useState หรือ this.state (ขึ้นกับว่าเป็น class หรือ function component)
  2. ถ้าต้อง share ข้าม component → สร้าง atom ใน src/recoil/states/<Entity>/index.js
  3. ถ้าต้อง persist → localStorage + helper ใน work-force.service.js (TTL 1 ชม.) หรือ storage.js (TTL อิสระ)
  4. อย่าลืมเพิ่มชื่อ atom ใน export { ... } ด้านล่างไฟล์ — Recoil จะไม่ tree-shake เอง

ตอนเพิ่ม CSS ใหม่

  1. อย่าแก้ custom-antd.css โดยตรง — แก้ที่ sass source (assets/sass/*.scss) แล้ว compile ด้วย Prepros
  2. ใช้ component-scoped CSS ถ้าเป็น style เฉพาะ component (เช่น src/components/Foo/index.css)
  3. ใช้ inline เฉพาะกรณีจำกัด (1–2 properties)
  4. อย่าสร้างไฟล์ theme ที่ชื่อใกล้เคียงกับ template/themeplate/themplate

ตอนจัดการวันที่

  1. เก็บเป็น ค.ศ. ใน state / server (GraphQL ISO 8601)
  2. แปลง พ.ศ. ตอนแสดงผลด้วย helper จาก formatdate.js (changeFormatdate, dateTh)
  3. รับจาก user → แปลง พ.ศ. → ค.ศ. ก่อนส่ง server (ถ้า DatePicker ใช้ locale th)
  4. อย่าบวก +543 ใน helper ที่ส่งค่าไป server

ตอนเพิ่ม module ใหม่

  1. สร้าง folder entity ใต้ src/recoil/states/ แล้วนิยาม atom ที่นั่น
  2. ใช้ atom key convention: current<Entity>Id, <entity>Data, isShow..., dropdown...
  3. ถ้าใช้ MUI component ใหม่ — ตรวจก่อนว่ามีใน AntD แล้วหรือไม่ เพื่อไม่เพิ่ม bundle
  4. CSS ของ module ใหม่แยกเป็น <module>.css แล้ว import เฉพาะใน entry point ของ module นั้น

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

ID (อ้างใน volume 10)เรื่องที่มา
MUI v5 + AntD v4 ผสมกัน ไม่มี ThemeProviderpackage.json:16-17 + ไม่พบ createTheme ใน codebase
changeFormatdateThai ลืม +543 (น่าจะ copy-paste)formatdate.js:142-161
monthThai dictionary ซ้ำใน 5 functionformatdate.js:3-161
theme typo (template/themeplate/themplate)src/assets/css/theme/
Recoil selector import แล้วไม่ใช้ (commented)recoil/states/default.js:1,144-150
moment.locale('th') เป็น global mutation ใน componentpages/NotificationMain/index.js:128

ขั้นตอนถัดไป

ไป 4.4 Security + performance