4.1.4 · Page cluster deep dive — Login, News, Leave
บทนี้เจาะลึก 3 หน้าสำคัญของ vtrc-web เพื่อให้เห็น pattern จริงของหน้าจอ user-side — Login (auth), News (list + detail), Leave (form ใหญ่ + multi-step + upload)
แต่ละหน้าจะอธิบาย: โครงสร้าง folder → flow ผู้ใช้ step-by-step → state shape → GraphQL operations → bugs/quirks
1. Login — src/pages/user/Login/index.js
หน้า login ของ user-side เป็นจุดเริ่มต้น auth flow ทั้งหมด
โครงสร้าง folder
src/pages/user/Login/
├── index.js — main component
├── components/
│ ├── LoginForm.js — form กรอก username/password
│ ├── OtpInput.js — OTP 6 หลัก (หลัง login ครั้งแรก)
│ └── OrganizationSelect.js — เลือกองค์กร (ศูนย์บริการฯ)
└── utils.js — helper (validate เบื้องต้น)User flow (step-by-step)
ผู้ใช้เปิด /login
↓
[1] Login componentDidMount
├── เรียน getOrganization() จาก sessionStorage
└── ตั้ง state.loading = false
↓
[2] ผู้ใช้กรอก username + password + เลือก organization
↓
[3] คลิก "Sign In" → handleSubmit
├── validateFields (AntD form)
│ ├── fail → setState({ error: validation message })
│ └── pass → continue
↓
[4] เรียก Mutations.login ผ่าน this.props.client.mutate
├── variables: { empCode, password, organization, apiKey }
└── backend ส่ง { accessToken, refreshToken, profileData }
↓
[5] setToken(data.login) — เก็บ token ใน localStorage + sessionStorage
↓
[6] ถ้า profileData.firstLogin === true
├── setState({ requireOtp: true })
└── render <OtpInput> รอ OTP
↓
[7] ถ้าไม่ใช่ firstLogin หรือ OTP ผ่าน
└── this.props.history.push('/profile')โค้ดตัวอย่าง (concept)
javascript
// src/pages/user/Login/index.js (concept — ประมาณการจาก pattern)
import React, { Component } from 'react'
import { Form, Input, Button, Select, message } from 'antd'
import { withApollo } from 'react-apollo'
import { Mutations } from '../../../graphql'
import { setToken, getOrganization, setOrganization } from '../../../utils/auth'
class Login extends Component {
state = {
loading: false,
requireOtp: false,
otpTransactionId: null
}
handleSubmit = (e) => {
e.preventDefault()
this.props.form.validateFields(async (err, values) => {
if (err) return
this.setState({ loading: true })
try {
const { data, errors } = await this.props.client.mutate({
mutation: Mutations.login,
variables: {
empCode: values.empCode,
password: values.password,
organization: values.organization,
apiKey: apiKeyNews()
}
})
if (errors) throw new Error(errors[0].message)
setToken(data.login) // localStorage + sessionStorage
setOrganization(values.organization)
if (data.login.profileData.firstLogin) {
this.setState({
requireOtp: true,
otpTransactionId: data.login.otpTransactionId,
loading: false
})
} else {
this.props.history.push('/profile')
}
} catch (err) {
message.error(err.message)
this.setState({ loading: false })
}
})
}
render() {
const { requireOtp } = this.state
if (requireOtp) return <OtpInput transactionId={this.state.otpTransactionId} />
return (
<Form onSubmit={this.handleSubmit}>
<Form.Item>
{this.props.form.getFieldDecorator('empCode', {
rules: [{ required: true }]
})(<Input placeholder="รหัสบุคลากร" />)}
</Form.Item>
{/* ... password, organization */}
<Button htmlType="submit" loading={this.state.loading}>Sign In</Button>
</Form>
)
}
}
export default withApollo(Form.create()(Login))Quirks
- ไม่มี RSA encryption ฝั่ง client — password ส่ง plaintext ผ่าน HTTPS (ต่างจาก
vtrc-rc-backofficeที่ V1 เขียนผิดไว้ว่ามี RSA — จริง ๆ ไม่มีทั้งคู่ ยกเว้นvtrc-mobileที่มีจริง) - OTP ที่หน้า login: บางครั้ง backend ส่ง
firstLogin: trueเพื่อบังคับตั้ง OTP ก่อนเข้า —OtpInput.jsรับ 6 หลักแล้วเรียกMutations.verifyOtp - Organization default จาก sessionStorage: ถ้าเคย login แล้วเก็บ
organizationไว้ ครั้งต่อไปจะ auto-select - ไม่มี "Remember Me" feature — token TTL 15 นาที (access) / 30 วัน (refresh) เท่านั้น
2. News list + detail — src/pages/user/News/
โครงสร้าง folder
src/pages/user/News/
├── index.js — list view (default export)
├── detail.js — detail view (รับ :newsId param)
├── components/
│ ├── NewsCard.js — card ข่าวใน list
│ ├── NewsCategory.js — filter ตามหมวด
│ └── NewsPagination.js — pagination footer
└── utils.js — formatDate, truncateUser flow
ผู้ใช้เปิด /news
↓
[1] News componentDidMount
└── fetchNews(offset=0, limit=10)
↓
[2] fetchNews เรียก Queries.fetchListPost
├── variables: { offset, limit, category, search }
└── backend ส่ง { total, totalPage, data: [...] }
↓
[3] setState({ news: data, total, loading: false })
↓
[4] render <NewsCard> สำหรับแต่ละ item
↓
[5] ผู้ใช้คลิก card → this.props.history.push(`/news/${newsId}`)
↓
[6] NewsDetail เปิด → componentDidMount → fetchNewsDetail
└── เรียก Queries.fetchPostById
↓
[7] render <div dangerouslySetInnerHTML={{ __html: news.content }} />
← จุดนี้คือ XSS risk ถ้า backend data ไม่ trustedQuirks
dangerouslySetInnerHTMLใช้กับnews.contentที่ backend ส่งมาเป็น HTML ถ้า editor ฝั่ง admin ไม่ sanitize ก่อน save → XSS ในหน้า user- ไม่มี loading skeleton — ระหว่าง fetch ผู้ใช้เห็นหน้าว่าง (เป็น antd
<Spin>ตรงกลางจอ) - ไม่มี ErrorBoundary — ถ้า query fail และไม่ catch, component crash = white screen
- รูปใน content ใช้ absolute URL ที่ backend ส่งมา — ถ้า backend env เปลี่ยน domain รูปจะ broken
3. Leave form — src/pages/user/Leave/
หน้าลางานเป็นหน้าที่ซับซ้อนที่สุดใน user-side เพราะมีหลายประเภทการลา + multi-step + file upload
โครงสร้าง folder
src/pages/user/Leave/
├── index.js — list (history การลา)
├── Form/
│ ├── index.js — main form (multi-tab)
│ ├── PersonalLeaveTab.js — tab ลากิจ/ลาป่วย
│ ├── StudyLeaveTab.js — tab ลาศึกษาต่อ
│ ├── VacationLeaveTab.js — tab ลาพักร้อน
│ ├── AttachmentUpload.js — แนบไฟล์ (ใบรับรองแพทย์ฯ)
│ └── ReviewSubmit.js — review ก่อน submit
└── detail.js — ดูผลอนุมัติUser flow
ผู้ใช้คลิก "สร้างคำขอลา"
↓
[1] เปิด /leave/form
↓
[2] componentDidMount fetch master data
├── Queries.fetchMasterConfigTypeLeave (dropdown ประเภท)
├── Queries.fetchEmployeeDetail (วันลาคงเหลือ)
└── Queries.fetchProfile (เลขประจำตัว, ตำแหน่ง)
↓
[3] ผู้ใช้เลือกประเภทการลา
└── setState({ leaveType }) → แสดง tab ที่เกี่ยวข้อง
↓
[4] กรอกข้อมูลใน tab (วันที่เริ่ม-สิ้นสุด, เหตุผล, ผู้รับมอบ)
↓
[5] อัปโหลดไฟล์แนบ (ถ้าลาป่วย > 3 วัน)
└── AttachmentUpload.js ใช้ <Upload> component ของ AntD
↓
[6] คลิก Next → review → Submit
↓
[7] ส่ง Mutations.createLeaveRequest
├── variables: { leaveType, startDate, endDate, reason, delegateId, attachments }
└── ฝั่ง backend: validate → insert Leave + LeaveApprover → notify approver
↓
[8] Redirect ไป /leave (list) พร้อม success messageQuirks
- multi-tab state sync ผ่าน parent — แต่ละ tab เก็บ state ของตัวเองใน
this.stateแต่ต้อง sync กลับมาที่ parent ตอน submit ทำให้บางที field หาย - ไม่มี auto-save — ถ้าผู้ใช้กรอกครึ่งหนึ่งแล้วปิด browser ข้อมูลหาย
attachmentsส่งเป็น base64 ใน mutation variables — ทำให้ request body ใหญ่ (backend ต้อง decode + save แยก)- date picker ใช้ Buddhist calendar —
moment.js+th_THlocale แสดงปี พ.ศ. แต่ส่งค่า ค.ศ. ให้ backend - validation ซ้อนกันหลายชั้น — ฝั่ง form (AntD
rules) + ฝั่ง backend (resolver) บางครั้งขัดแย้งกัน (เช่น ลาพักร้อนต้องเหลือ >= 5 วันหลังลา — ฝั่ง form ไม่เช็ค, backend reject)
Pattern ที่ใช้ทั่วทุกหน้า
1. Class component + withApollo
javascript
class XxxPage extends Component {
componentDidMount() { /* fetch */ }
handleSubmit = () => { /* mutate */ }
render() { /* ... */ }
}
export default withApollo(Form.create()(XxxPage))2. componentDidMount สำหรับ initial fetch
javascript
async componentDidMount() {
const { data } = await this.props.client.query({
query: Queries.fetchXxx,
variables: { id: this.props.match.params.id },
fetchPolicy: 'network-only' // บางทีใช้ cache-first (default) ทำให้ข้อมูลเก่า
})
this.setState({ xxx: data.fetchXxx })
}3. State shape มาตรฐาน
javascript
state = {
loading: false, // สำหรับ <Spin>
error: null, // สำหรับ error message
data: [], // list items
detail: null, // detail item
formValues: {} // form state
}4. ไม่มี loading skeleton
ทุกหน้าใช้ <Spin> ของ AntD ตรงกลางจอระหว่าง fetch — ไม่มี skeleton ทำให้ UX กระตุก
5. ไม่มี ErrorBoundary
ถ้า query fail และไม่ catch ใน componentDidMount — component จะ throw ที่ render = white screen ทั้งหน้า
เปรียบเทียบกับ vtrc-rc-backoffice
| ด้าน | vtrc-web Login | vtrc-rc-backoffice Login |
|---|---|---|
| Mutation name | Mutations.login | Mutations.loginBackoffice (suffix BO) |
| RSA encryption | ไม่มี | ไม่มี (V1 ผิด — ไม่มีในทั้งคู่) |
| OTP step | ใช่ (firstLogin check) | ไม่ใช่ |
| Organization field | ใช่ | ใช่ (sessionStorage) |
| Redirect after login | /profile | / (SiderLayout) |
| Token storage | localStorage + sessionStorage | localStorage + sessionStorage |