8.8 · State, styling, locale (พ.ศ.)
บทนี้อธิบายว่า vtrc-rc-backoffice จัดการ state อย่างไร (3 ระบบผสม), CSS กี่ชั้น, และจัดการ locale พ.ศ. อย่างไร
State — 3 ระบบผสม
vtrc-rc-backoffice มีระบบ state 3 แบบที่ใช้พร้อมกัน:
┌─────────────────────────────────────────────────┐
│ 1. Local component state (class) │
│ ├── this.state / this.setState │
│ └── module เก่า (Leave, ManageUser, ...) │
├─────────────────────────────────────────────────┤
│ 2. Local component state (hook) │
│ ├── useState │
│ └── module ใหม่ที่ไม่ได้ share state │
├─────────────────────────────────────────────────┤
│ 3. Global state (Recoil) │
│ ├── useRecoilState / useRecoilValue │
│ └── module WorkForce, PMS │
├─────────────────────────────────────────────────┤
│ 4. Persisted state (localStorage) │
│ ├── token, refreshToken, apiKey │
│ └── work-force.service.js helpers │
└─────────────────────────────────────────────────┘1. Local component state (class)
class MyComponent extends React.Component {
state = {
users: [],
loading: false,
modalVisible: false,
};
// อัปเดต
this.setState({ loading: true });
// อัปเดตโดยใช้ค่าเดิม
this.setState((prev) => ({ count: prev.count + 1 }));
}ข้อดี
- เรียบง่าย, แยกจาก component อื่นโดยสมบูรณ์
- ไม่ต้อง setup library
ข้อเสีย
- ไม่ share ข้าม component — ถ้า 2 component ต้องการ state เดียวกัน ต้องยกขึ้น parent (prop drilling) หรือใช้ Recoil
- refresh browser แล้ว state หาย
เมื่อไรใช้
- state ที่ใช้เฉพาะใน component นั้น (เช่น
modalVisible,loading) - module เก่าที่ไม่ได้ใช้ Recoil
2. Local component state (hook — useState)
const MyComponent = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
// อัปเดต
setLoading(true);
setUsers([...users, newUser]);
};ใช้ใน module ใหม่ที่ไม่ต้อง share state
เช่น ในหน้า form เล็ก ๆ ที่ใช้แค่ใน component เดียว
3. Global state (Recoil)
// นิยาม atom
export const employeeListState = atom({
key: "employeeListState",
default: [],
});
// ใช้ใน component
const [employees, setEmployees] = useRecoilState(employeeListState);
const employees = useRecoilValue(employeeListState); // อ่านอย่างเดียว
const setEmployees = useSetRecoilState(employeeListState); // เขียนอย่างเดียวดูรายละเอียดใน บท 7.6
โครงสร้าง atom
src/recoil/states/
├── WorkForce/
│ ├── Employee/
│ │ ├── index.js ← atom: employeeListState, employeeLoadingState
│ │ ├── currentId.js ← atom: currentEmployeeIdState
│ │ ├── filters.js ← atom: employeeFiltersState
│ │ ├── modal.js ← atom: employeeModalState
│ │ └── viewMode.js ← atom: employeeViewModeState
│ ├── PositionManage/
│ └── ...
├── PMS/
└── Recruitment/Pattern ที่พบใน WorkForce module
แต่ละ entity (Employee, Position, Manpower) มี atom แยกตามหน้าที่:
| Atom | หน้าที่ |
|---|---|
<Entity>ListState | รายการในตาราง |
<Entity>LoadingState | สถานะ loading |
<Entity>FiltersState | ตัวกรอง (search, departmentId, page) |
<Entity>ModalState | สถานะ modal (visible, editingId) |
<Entity>ViewModeState | โหมดการดู (view, edit) |
ข้อดี
- share ข้าม component โดยไม่ต้อง prop drilling
- debug ง่าย — ดูได้ใน React DevTools
- ทำงานกับ React 16 เป็นธรรมชาติ
ข้อเสีย
- state ไม่ persist — refresh แล้วหาย
- เพิ่ม dependency — Recoil 0.7
4. Persisted state (localStorage)
vtrc-rc-backoffice ใช้ localStorage สำหรับ:
| Key | ที่เก็บ | ที่ใช้อ่าน |
|---|---|---|
token | Login, PathLogin | getToken() / isLoggedIn() (auth.js) |
refreshToken | Login, PathLogin | getToken() / refreshTokenBO |
isRemember | Login | สลับ localStorage vs sessionStorage |
currentEmployeeId (WorkForce) | คลิก row ในตาราง | work-force.service.js |
viewOnly (WorkForce) | toggle mode | work-force.service.js |
searchList (WorkForce) | cache ผลค้นหา | work-force.service.js |
Helper — work-force.service.js
// src/utils/work-force.service.js (concept)
export const setCurrentEmployeeId = (id) => {
localStorage.setItem("currentEmployeeId", id);
};
export const getCurrentEmployeeId = () => {
return localStorage.getItem("currentEmployeeId");
};
export const setViewOnly = (flag) => {
localStorage.setItem("viewOnly", flag ? "true" : "false");
};
export const getViewOnly = () => {
return localStorage.getItem("viewOnly") === "true";
};Helper — storage.js (with expiry)
// src/utils/storage.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 ได้
- ไม่ sync ข้าม tab — ถ้าผู้ใช้เปิด 2 tab อาจได้ state ต่างกัน
- refresh แล้วค้าง — ถ้าโค้ดลืมเคลียร์ (เช่น token หมดอายุแต่ยังอยู่ใน localStorage)
Styling — CSS หลายชั้น
vtrc-rc-backoffice มี CSS จากหลายแหล่ง:
┌────────────────────────────────────────────────┐
│ 1. AntD v4 default (antd.css) │
│ import ใน src/index.js │
├────────────────────────────────────────────────┤
│ 2. Custom AntD override (custom-antd.css) │
│ 21,189 บรรทัด — compiled จาก sass │
├────────────────────────────────────────────────┤
│ 3. main.css (4,644 บรรทัด) │
│ global styles + utilities │
├────────────────────────────────────────────────┤
│ 4. theme/*.css (หลายไฟล์) │
│ themeplate.css (typo), template.css, ... │
├────────────────────────────────────────────────┤
│ 5. work-force.css (794 บรรทัด) │
│ เฉพาะ WorkForce module │
├────────────────────────────────────────────────┤
│ 6. Component-scoped CSS (.css คู่กับ .js) │
│ เช่น MyComponent/index.css │
├────────────────────────────────────────────────┤
│ 7. Inline style (style={{...}}) │
│ ใช้บ่อยใน component │
└────────────────────────────────────────────────┘1. AntD v4 default
// src/index.js
import "antd/dist/antd.css";เป็น CSS ของ AntD ที่ import ตรง ๆ — ใหญ่ประมาณ 600 KB
2. Custom AntD override — custom-antd.css
ไฟล์ใหญ่ที่สุดในแอป (21,189 บรรทัด) — เป็น AntD theme override:
/* src/config/custom-antd.css (ตัวอย่าง concept) */
.ant-btn {
border-radius: 6px;
font-family: "Kanit", sans-serif;
}
.ant-table-thead > tr > th {
background: #f0f2f5;
font-weight: 600;
}
.ant-modal-content {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}ที่มา
ไฟล์นี้เป็น 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 ใหม่
3. main.css (4,644 บรรทัด)
// src/index.js
import "./assets/css/main.css";ประกอบด้วย:
| หมวด | ตัวอย่าง |
|---|---|
| Reset / normalize | *, body, html |
| Typography | .title, .subtitle, .text-link |
| Layout utilities | .flex, .flex-col, .mb-2, .mt-4 |
| Color tokens | .text-primary, .bg-secondary |
| Component helpers | .card, .form-section, .table-action |
ข้อระวัง
- ใช้ร่วมกับ AntD class — บางครั้ง conflict (เช่น
.ant-btnoverride) - มี utility คล้าย Tailwind แต่ไม่ใช่ Tailwind
4. theme/*.css — หลายไฟล์ใกล้เคียงกัน
src/assets/css/theme/
├── template.css ← ชื่อที่ถูก
├── themeplate.css ← typo (เพิ่ม h)
├── theplate.css ← typo (หาย m)
├── time_attendace.css ← typo (หาย d — ควรเป็น attendance)
└── ...ปัญหา: มี 3 ไฟล์ที่ชื่อใกล้เคียงกัน (template/themeplate/themplate) — ไม่ชัดว่าตัวจริงตัวไหน
ผล: มีการ import ผิดไฟล์ได้ง่าย ทำให้ style ไม่ทำงาน
คำแนะนำ
- grep หาการ import แต่ละไฟล์เพื่อดูว่าตัวจริงคือตัวไหน
- อย่าสร้างไฟล์ใหม่ที่ชื่อใกล้เคียง
5. work-force.css (794 บรรทัด)
เฉพาะ WorkForce module — import ใน WorkForce component:
// pages/WorkForce/Employee/index.js (concept)
import "assets/css/work-force.css";แยกจาก main.css เพราะ WorkForce มี design เฉพาะ (grid layout, custom card)
6. Component-scoped CSS
แต่ละ component มีไฟล์ .css คู่กับ .js:
src/components/SiderLayout/
├── index.js
├── index.css ← style เฉพาะ SiderLayout
└── index_bk.js ← snapshot (ห้ามใช้)ข้อดี
- ไม่ leak style ไปยัง component อื่น
ข้อเสีย
- ไม่ใช่ CSS Modules — ไม่มี scope จริง, class name อาจชนกัน
- import ทุกครั้งที่ component ถูก import — ทำให้ CSS โหลดทั้งหมด
7. Inline style
<div style={{ display: "flex", gap: 8 }}>
<Button style={{ marginRight: 8 }}>Cancel</Button>
<Button type="primary">Save</Button>
</div>ใช้บ่อย — โดยเฉพาะใน module ใหม่
ข้อระวัง
- ไม่สามารถ override ด้วย media query — ทำ responsive ยาก
- ไม่ reuse ได้ — ทำให้โค้ดซ้ำ
CSS — สรุปความซับซ้อน
| ชั้น | ขนาด | เสี่ยง conflict |
|---|---|---|
| AntD default | 600 KB | ต่ำ (well-defined) |
| custom-antd.css | 21,189 บรรทัด | ปานกลาง (override) |
| main.css | 4,644 บรรทัด | สูง (utility + class) |
| theme/*.css | ~1,000 บรรทัด | สูง (typos, ซ้ำ) |
| work-force.css | 794 บรรทัด | ต่ำ (isolated) |
| Component CSS | ~5,000 บรรทัดรวม | ปานกลาง |
| Inline | (ไม่นับ) | ปานกลาง |
รวม — CSS ที่โหลดตอนเปิดแอป ~35,000 บรรทัด
Locale — พ.ศ. (Buddhist Era)
vtrc-rc-backoffice ใช้ moment.js สำหรับจัดการวันที่ และแปลง ค.ศ. → พ.ศ. ด้วย manual +543
Pattern
// src/utils/formatdate.js (concept)
import moment from "moment";
import "moment/locale/th";
moment.locale("th");
export const formatDate = (date, format = "DD/MM/YYYY") => {
if (!date) return "";
return moment(date).format(format);
};
export const formatDateBE = (date, format = "DD MMMM YYYY") => {
if (!date) return "";
// moment('th') locale ปรับปีเป็นพ.ศ.อัตโนมัติ — แต่ทีมยังใช้ manual +543 บางจุด
const m = moment(date);
const beYear = m.year() + 543;
return m.year(beYear).format(format);
};
export const parseBE = (dateStr) => {
// รับ "10/07/2569" → แปลงเป็น Date object (ค.ศ.)
const [day, month, yearBE] = dateStr.split("/");
const yearAD = parseInt(yearBE) - 543;
return new Date(yearAD, parseInt(month) - 1, parseInt(day));
};การใช้ใน component
// แสดงวันที่ในตาราง
const columns = [
{
title: "วันที่สร้าง",
dataIndex: "createdAt",
render: (date) => formatDateBE(date, "DD/MM/YYYY"),
},
];
// รับจาก AntD DatePicker
<Form.Item name="startDate" label="วันที่เริ่ม">
<DatePicker
format="DD/MM/YYYY"
onChange={(date, dateStr) => {
// dateStr = "10/07/2569"
const isoDate = parseBE(dateStr);
form.setFieldsValue({ startDate: isoDate });
}}
/>
</Form.Item>ข้อระวังเรื่องพ.ศ.
moment('th')ปรับปีอัตโนมัติ — แต่ทีมยังใช้ manual+543ในบางจุด ทำให้สับสน- backend ส่ง/รับ ค.ศ. — frontend แปลงพ.ศ. ตอนแสดงผลเท่านั้น
momentล้าสมัยแล้ว — ควรใช้dayjsหรือdate-fnsแทน (ขนาดเล็กกว่า)- ไม่มี i18n สำหรับภาษาอื่น — ทุกข้อความฝังเป็นภาษาไทยในโค้ด
Pattern ที่ต้องระวัง
// ❌ ผิด — บวก 543 แล้วส่งไป server (server จะเก็บปี 2666)
const wrongDate = moment().year(moment().year() + 543).toISOString();
await saveDate({ date: wrongDate });
// ✓ ถูก — ส่ง ค.ศ. ไป server, แปลง พ.ศ. ตอนแสดงผลเท่านั้น
await saveDate({ date: moment().toISOString() });
// ตอนแสดงผล
formatDateBE(savedDate);Locale — moment ภาษาไทย
// ตั้งใน index.js หรือ formatdate.js
import "moment/locale/th";
moment.locale("th");
// ใช้
moment().format("MMMM"); // → "กรกฎาคม" (แทน "July")ข้อระวัง
moment.locale("th")ทำให้ format เดือนเป็นภาษาไทย แต่ year ก็ถูกปรับเป็นพ.ศ.ด้วย (ถ้าใช้YYYYformat)- ถ้าต้องการ ค.ศ. — ใช้
moment.locale("en")ชั่วคราว
เปรียบเทียบกับ vtrc-web (ดู Volume 8 บท 6.8)
| ด้าน | vtrc-rc-backoffice | vtrc-web |
|---|---|---|
| State | class + Recoil | class |
| Recoil | ใช้ | ไม่ใช้ |
| CSS systems | 7 ชั้น | 5 ชั้น |
| custom-antd.css | 21,189 บรรทัด | ~10,000 |
| theme typos | มี (3 ไฟล์ใกล้กัน) | ไม่มี |
| moment | ใช้ | ใช้ |
| พ.ศ. | manual +543 + locale | manual +543 |
เคล็ดลปตอนทำงานจริง
ตอนเพิ่ม state ใหม่
- ถ้าใช้เฉพาะใน component →
useStateหรือthis.state - ถ้าต้อง share ข้าม component → สร้าง atom ใน Recoil
- ถ้าต้อง persist →
localStorage+ helper (storage.jsหรือwork-force.service.js)
ตอนเพิ่ม CSS ใหม่
- อย่าแก้
custom-antd.cssโดยตรง — แก้ที่ sass source - ใช้ component-scoped CSS ถ้าเป็น style เฉพาะ component
- ใช้ inline เฉพาะกรณีจำกัด (1-2 properties)
ตอนจัดการวันที่
- เก็บเป็น ค.ศ. ใน state / server
- แปลง พ.ศ. ตอนแสดงผลด้วย
formatDateBE() - รับจาก user → แปลง พ.ศ. → ค.ศ. ด้วย
parseBE()ก่อนส่ง server