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 ที่ใช้จริง | จำนวน |
|---|---|---|
| State | class this.state + hook useState + Recoil + localStorage | 4 ระบบผสม |
| Global state atoms | recoil/states/ folder-per-entity | 212 atoms, 57 folders |
| CSS layers | AntD default + custom override + main + theme + module-scoped + inline | 7 ชั้น |
| Locale | moment.js + moment/locale/th + manual +543 | ไม่มี i18n library |
| Theme colors | hardcoded ใน CSS + inline | ไม่มี theme switching |
State management — 4 ระบบผสม
vtrc-rc-backoffice/src/index.js:1-33 เป็นจุดเข้าของแอป ครอบทั้ง ApolloProvider และ RecoilRoot ไว้ด้วยกัน:
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 นี้:
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:
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:
import { atom } from 'recoil';
const currentEmployeeIdState = atom({
key: 'currentEmployeeIdState',
default: ''
})
const employeeDataState = atom({
key: 'employeeDataState',
default: ''
})
export { currentEmployeeIdState, employeeDataState }การใช้งานใน component:
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:
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>IdState | id ที่ user เลือก (row click) |
<entity>DataState | ข้อมูลที่ fetch มาแสดงใน form/info |
isShow...State | flag เปิด/ปิด 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:
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:
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 รวมกัน:
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:
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:
// 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 | ที่เก็บ | ที่ใช้อ่าน |
|---|---|---|
token | Login, PathLogin | getToken() / isLoggedIn() ใน auth.js |
refreshToken | Login, PathLogin | getToken() |
isRemember | Login | สลับ localStorage vs sessionStorage |
current<Entity>Id | WorkForce row click | getStorageCurrentId_wf() |
viewOnly_wf | toggle mode ใน WorkForce | getStorageViewOnly_wf() |
empUserGroup | user group ที่ login | setStorageEmpUserGroup() |
current_<key>_search | cache ผลค้นหา list | getStorageSearchList() |
auth.js — token helper
vtrc-rc-backoffice/src/utils/auth.js:1-54 จัดการ token โดยสลับ storage ตาม isRemember:
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 ที่เลือก:
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 ชั่วโมง:
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):
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 ที่รับเป็นชั่วโมง):
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
- XSS สามารถขโมยได้ — JavaScript ใด ๆ ที่รันในแอปสามารถอ่าน
localStorageได้ ถ้ามี XSS ในหน้า token จะหลุด - ไม่ sync ข้าม tab — ถ้าผู้ใช้เปิด 2 tab อาจได้ state ต่างกัน (localStorage trigger
storageevent แต่ codebase ไม่ได้ subscribe) - TTL 1 ชม. อาจสั้นเกิน — user ที่ idle นานกว่า 1 ชม. แล้วกลับมาคลิกจะถูก reset
removeAllStorageCurrentId_wfใช้ prefix convention — ถ้าสร้าง key ใหม่ที่ขึ้นต้นด้วยcurrentลงท้ายด้วยIdจะถูกล้างด้วย
Styling — CSS หลายชั้น
vtrc-rc-backoffice/src/containers/App/index.js:11-14 import CSS 4 ชั้นแบบ global ตอน app เริ่ม:
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 |
|---|---|---|---|
| 1 | antd/dist/antd.css (จาก antd@4.15.4) | ~21,000 | ต่ำ (well-defined) |
| 2 | src/config/custom-antd.css | 21,189 | ปานกลาง (override) |
| 3 | src/assets/css/main.css | 4,644 | สูง (utility + class) |
| 4 | src/assets/css/redesign.css | 13 | ต่ำ |
| 5 | src/assets/css/theme/*.css (10 ไฟล์) | ~1,000+ | สูง (typo, ซ้ำ) |
| 6 | src/assets/css/work-force.css | 794 | ต่ำ (isolated) |
| 7 | Component-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:
/* 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.scss → assets/css/main.css + เพิ่ม override ใน custom-antd.css
ข้อระวัง:
- ห้ามแก้
custom-antd.cssโดยตรง — เป็น compiled output การแก้จะถูกเขียนทับตอน compile ใหม่ - แก้ที่ sass source แทน —
assets/sass/main.scss+assets/sass/work-force.scss - 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 ฝังตรง ๆ:
@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-btnoverride) - มี utility คล้าย Tailwind แต่ไม่ใช่ Tailwind
Layer 4 — redesign.css (13 บรรทัด)
ไฟล์เล็กที่สุด vtrc-rc-backoffice/src/assets/css/redesign.css:1-13 มีแค่ sider width override และ placeholder selector ที่ยังไม่มี rule:
.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)
"@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.1",การใช้งานจริงกระจายอยู่ใน module ใหม่ ๆ เช่น vtrc-rc-backoffice/src/pages/WorkForce/Employee/info/index.js:9,20:
import { Label } from '@mui/icons-material';
import { formGroupClasses } from '@mui/material';หรือ vtrc-rc-backoffice/src/pages/Payment/ExportPayment/Form/index.js:46:
import { teal } from "@mui/material/colors";ข้อสังเกตสำคัญ:
- ไม่มี
ThemeProviderหรือcreateThemeที่ global — grep ทั้ง codebase ไม่พบcreateThemeหรือThemeProviderMUI จึงทำงานด้วย default theme ที่ไม่ได้ปรับ - การ import บางรายการเป็น internal API (เช่น
formGroupClasses,getAccordionDetailsUtilityClass) — ใช้เพื่อ override className ของ MUI component ไม่ใช่การใช้ component จริง - ไม่มี 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:
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 ด้วยมือ:
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 ดังนี้:
| Function | Output | ตัวอย่าง |
|---|---|---|
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:
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
import { changeFormatdate } from '../../../utils/formatdate'
const columns = [
{
title: 'วันที่สร้าง',
dataIndex: 'createdAt',
render: (date) => changeFormatdate(date),
},
]การใช้ใน AntD DatePicker
<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:
locale={{ emptyText: "ไม่พบข้อมูลรายการ" }}grep พบ override แบบนี้มากกว่า 20 จุดใน codebase แสดงว่าไม่ได้ตั้งที่ global
ข้อระวังเรื่องพ.ศ.
moment.locale('th')ปรับปีอัตโนมัติ — moment localethจะแสดงปีเป็นพ.ศ.ในYYYYformat ด้วย แต่ทีมยังใช้ manual+543ในหลายจุด ทำให้บางจุดอาจบวก 543 สองครั้ง- backend ส่ง/รับ ค.ศ. — frontend แปลงพ.ศ. ตอนแสดงผลเท่านั้น (ใช้ ISO 8601 ใน GraphQL)
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)- ไม่มี i18n สำหรับภาษาอื่น — ทุกข้อความฝังเป็นภาษาไทยในโค้ด ไม่มี
react-intlหรือi18next
Pattern ที่ต้องระวัง
// ❌ ผิด — บวก 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-backoffice | vtrc-web |
|---|---|---|
| State | class + useState + Recoil + localStorage | class + useState |
| Recoil | ใช้ (212 atoms, 57 folders) | ไม่ใช้ |
| UI library | AntD v4 + MUI v5 ผสม | AntD v4 อย่างเดียว |
| CSS systems | 7 ชั้น | 5 ชั้น |
custom-antd.css | 21,189 บรรทัด | ~10,000 บรรทัด |
| Theme typos | มี (template/themeplate/themplate) | ไม่มี |
| moment | ใช้ (locale th + manual +543) | ใช้ (manual +543) |
| ConfigProvider | ไม่มี global | ไม่มี global |
| localStorage | ละเอียด (TTL helper) | basic |
เคล็ดลับตอนทำงานจริง
ตอนเพิ่ม state ใหม่
- ถ้าใช้เฉพาะใน component →
useStateหรือthis.state(ขึ้นกับว่าเป็น class หรือ function component) - ถ้าต้อง share ข้าม component → สร้าง atom ใน
src/recoil/states/<Entity>/index.js - ถ้าต้อง persist →
localStorage+ helper ในwork-force.service.js(TTL 1 ชม.) หรือstorage.js(TTL อิสระ) - อย่าลืมเพิ่มชื่อ atom ใน
export { ... }ด้านล่างไฟล์ — Recoil จะไม่ tree-shake เอง
ตอนเพิ่ม CSS ใหม่
- อย่าแก้
custom-antd.cssโดยตรง — แก้ที่ sass source (assets/sass/*.scss) แล้ว compile ด้วย Prepros - ใช้ component-scoped CSS ถ้าเป็น style เฉพาะ component (เช่น
src/components/Foo/index.css) - ใช้ inline เฉพาะกรณีจำกัด (1–2 properties)
- อย่าสร้างไฟล์ theme ที่ชื่อใกล้เคียงกับ
template/themeplate/themplate
ตอนจัดการวันที่
- เก็บเป็น ค.ศ. ใน state / server (GraphQL ISO 8601)
- แปลง พ.ศ. ตอนแสดงผลด้วย helper จาก
formatdate.js(changeFormatdate,dateTh) - รับจาก user → แปลง พ.ศ. → ค.ศ. ก่อนส่ง server (ถ้า DatePicker ใช้ locale th)
- อย่าบวก
+543ใน helper ที่ส่งค่าไป server
ตอนเพิ่ม module ใหม่
- สร้าง folder entity ใต้
src/recoil/states/แล้วนิยาม atom ที่นั่น - ใช้ atom key convention:
current<Entity>Id,<entity>Data,isShow...,dropdown... - ถ้าใช้ MUI component ใหม่ — ตรวจก่อนว่ามีใน AntD แล้วหรือไม่ เพื่อไม่เพิ่ม bundle
- CSS ของ module ใหม่แยกเป็น
<module>.cssแล้ว import เฉพาะใน entry point ของ module นั้น
รายการ debt ที่เกี่ยวข้อง
| ID (อ้างใน volume 10) | เรื่อง | ที่มา |
|---|---|---|
| — | MUI v5 + AntD v4 ผสมกัน ไม่มี ThemeProvider | package.json:16-17 + ไม่พบ createTheme ใน codebase |
| — | changeFormatdateThai ลืม +543 (น่าจะ copy-paste) | formatdate.js:142-161 |
| — | monthThai dictionary ซ้ำใน 5 function | formatdate.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 ใน component | pages/NotificationMain/index.js:128 |