Skip to content

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, truncate

User 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 ไม่ trusted

Quirks

  • 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 message

Quirks

  • 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 calendarmoment.js + th_TH locale แสดงปี พ.ศ. แต่ส่งค่า ค.ศ. ให้ 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 Loginvtrc-rc-backoffice Login
Mutation nameMutations.loginMutations.loginBackoffice (suffix BO)
RSA encryptionไม่มีไม่มี (V1 ผิด — ไม่มีในทั้งคู่)
OTP stepใช่ (firstLogin check)ไม่ใช่
Organization fieldใช่ใช่ (sessionStorage)
Redirect after login/profile/ (SiderLayout)
Token storagelocalStorage + sessionStoragelocalStorage + sessionStorage

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

ไป บท 4.1.5 Security analysis