CustomerListPage.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import { useEffect, useState } from 'react'
  2. import { Link } from 'react-router-dom'
  3. import { Helmet } from 'react-helmet-async'
  4. import {
  5. getCustomerDashboardSummary,
  6. listCustomers,
  7. type CustomerDashboardSummary,
  8. } from '../../../services/api/customers'
  9. import type { CustomerEntity } from '../../../services/api/customers'
  10. import { usePageContext } from '../../../shared/pageContext'
  11. import { DashboardLayout } from '../../../layouts/DashboardLayout'
  12. const emptySummary: CustomerDashboardSummary = {
  13. total: 0,
  14. today: 0,
  15. followUp: 0,
  16. simulated: 0,
  17. weeklyRecords: 0,
  18. }
  19. const HAIR_LENGTH_API_MAP: Record<string, string | undefined> = {
  20. '全部': undefined,
  21. '短发': 'SHORT',
  22. '中长发': 'MEDIUM',
  23. '长发': 'LONG',
  24. '未确认': 'UNCONFIRM',
  25. '全て': undefined,
  26. 'ショート': 'SHORT',
  27. 'ミディアム': 'MEDIUM',
  28. 'ロング': 'LONG',
  29. '未確認': 'UNCONFIRM',
  30. }
  31. function getHairLengthLabel(length: string | undefined, pageCopy: CustomerListPageCopy) {
  32. if (length === 'SHORT') return pageCopy.hairLengthMap.short
  33. if (length === 'MEDIUM') return pageCopy.hairLengthMap.medium
  34. if (length === 'LONG') return pageCopy.hairLengthMap.long
  35. if (length === 'UNCONFIRM') return pageCopy.statusMap?.none || '—'
  36. return '—'
  37. }
  38. function getVisiblePages(currentPage: number, totalPages: number) {
  39. if (totalPages <= 5) {
  40. return Array.from({ length: totalPages }, (_, index) => index + 1)
  41. }
  42. const start = Math.max(1, Math.min(currentPage - 2, totalPages - 4))
  43. return Array.from({ length: 5 }, (_, index) => start + index)
  44. }
  45. type CustomerListPageCopy = ReturnType<typeof usePageContext>['copy']['customerListPage']
  46. export function CustomerListPage() {
  47. const { locale, path, copy } = usePageContext()
  48. const [items, setItems] = useState<CustomerEntity[]>([])
  49. const [summary, setSummary] = useState<CustomerDashboardSummary>(emptySummary)
  50. const [query, setQuery] = useState('')
  51. const [page, setPage] = useState(1)
  52. const [pageSize] = useState(20)
  53. const [total, setTotal] = useState(0)
  54. const [styleFilter, setStyleFilter] = useState(copy.customerListPage.filters.styleOptions[0])
  55. const pageCopy = copy.customerListPage
  56. useEffect(() => {
  57. const timer = setTimeout(() => {
  58. listCustomers({
  59. page,
  60. pageSize,
  61. q: query.trim() || undefined,
  62. hairLength: HAIR_LENGTH_API_MAP[styleFilter],
  63. })
  64. .then((result) => {
  65. setItems(result.items)
  66. setTotal(result.total)
  67. })
  68. .catch(() => {
  69. setItems([])
  70. setTotal(0)
  71. })
  72. }, 300)
  73. return () => clearTimeout(timer)
  74. }, [page, pageSize, query, styleFilter])
  75. useEffect(() => {
  76. setPage(1)
  77. }, [query, styleFilter])
  78. useEffect(() => {
  79. getCustomerDashboardSummary().then(setSummary).catch(() => setSummary(emptySummary))
  80. }, [])
  81. const totalPages = Math.max(1, Math.ceil(total / pageSize))
  82. const visiblePages = getVisiblePages(page, totalPages)
  83. useEffect(() => {
  84. if (page > totalPages) {
  85. setPage(totalPages)
  86. }
  87. }, [page, totalPages])
  88. return (
  89. <DashboardLayout>
  90. <Helmet>
  91. <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
  92. <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
  93. <meta name="description" content={pageCopy.description} />
  94. </Helmet>
  95. <section className="stylist-workbench-top ipad-top-space-margin">
  96. <div className="container">
  97. <div className="row align-items-end justify-content-between mb-4">
  98. <div className="col-lg-7">
  99. <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
  100. <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
  101. <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
  102. </div>
  103. <div className="col-lg-4 text-lg-end mt-3 mt-lg-0">
  104. <Link to={path('/customers/new')} className="btn btn-extra-large btn-rounded btn-base-color btn-box-shadow fw-700">
  105. <i className="feather icon-feather-user-plus me-8px"></i>{pageCopy.newCustomer}
  106. </Link>
  107. </div>
  108. </div>
  109. <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px mb-4 customer-list-filter-panel">
  110. <div className="row g-3 align-items-end customer-list-filter-grid">
  111. <div className="col-lg-8">
  112. <label htmlFor="customer-search" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.searchLabel}</label>
  113. <div className="customer-search-field">
  114. <i className="feather icon-feather-search"></i>
  115. <input id="customer-search" type="search" className="form-control" value={query} onChange={(event) => setQuery(event.target.value)} placeholder={pageCopy.filters.searchPlaceholder} />
  116. </div>
  117. </div>
  118. <div className="col-lg-4 col-md-4">
  119. <label htmlFor="style-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.styleLabel}</label>
  120. <select id="style-filter" className="form-select customer-filter-control" value={styleFilter} onChange={(event) => setStyleFilter(event.target.value)}>
  121. {pageCopy.filters.styleOptions.map((option: string) => <option key={option} value={option}>{option}</option>)}
  122. </select>
  123. </div>
  124. </div>
  125. </div>
  126. <div className="row row-cols-2 row-cols-lg-2 row-cols-md-2 g-3 mb-4">
  127. <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{summary.total}</span><span className="fs-14">{pageCopy.stats.total}</span></div></div>
  128. <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{summary.simulated}</span><span className="fs-14">{pageCopy.stats.simulated}</span></div></div>
  129. </div>
  130. </div>
  131. </section>
  132. <section className="pt-0">
  133. <div className="container">
  134. <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
  135. <div className="d-flex align-items-center justify-content-between mb-25px">
  136. <div>
  137. <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.resultBadge}</span>
  138. <h2 className="fw-700 text-medium-gray fs-26 mb-0">{pageCopy.resultTitle.replace('{{count}}', String(total))}</h2>
  139. </div>
  140. </div>
  141. <div className="customer-table">
  142. <div className="customer-row customer-head">
  143. {pageCopy.tableHeaders.map((label: string, index: number) => (
  144. <span key={label} className={index === pageCopy.tableHeaders.length - 1 ? 'customer-row-action' : undefined}>{label}</span>
  145. ))}
  146. </div>
  147. {items.length === 0 ? (
  148. <p className="fs-15 text-medium-gray mb-0 mt-3">{copy.common.empty}</p>
  149. ) : items.map((customer) => (
  150. <Link key={customer.id} to={path(`/customers/${customer.id}`)} className="customer-row customer-row-link" aria-label={pageCopy.detail}>
  151. <div className="customer-profile">
  152. <img src={customer.avatar || '/images/default-user-avatar.svg'} alt={customer.name} className="customer-avatar" />
  153. <div>
  154. <span className="fw-700 text-dark-gray d-block">{customer.name}</span>
  155. <span className="fs-14 text-medium-gray">
  156. {customer.phone}
  157. {' / '}
  158. {getHairLengthLabel(customer.hairLength, pageCopy)}
  159. </span>
  160. </div>
  161. </div>
  162. <span>{customer.trouble || '—'}</span>
  163. <span className="customer-row-action btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.detail}</span>
  164. </Link>
  165. ))}
  166. </div>
  167. <div className="customer-pager">
  168. <button
  169. type="button"
  170. className={`customer-pager-link${page <= 1 ? ' is-disabled' : ''}`}
  171. aria-label={pageCopy.pagination?.prev || (locale === 'ja' ? '前へ' : '上一页')}
  172. disabled={page <= 1}
  173. onClick={() => setPage((current) => Math.max(1, current - 1))}
  174. >
  175. {pageCopy.pagination?.prev || (locale === 'ja' ? '前へ' : '上一页')}
  176. </button>
  177. {visiblePages.map((pageNumber) => (
  178. <button
  179. key={pageNumber}
  180. type="button"
  181. className={`customer-pager-link${pageNumber === page ? ' is-active' : ''}`}
  182. aria-current={pageNumber === page ? 'page' : undefined}
  183. onClick={() => setPage(pageNumber)}
  184. >
  185. {pageNumber}
  186. </button>
  187. ))}
  188. <button
  189. type="button"
  190. className={`customer-pager-link${page >= totalPages ? ' is-disabled' : ''}`}
  191. aria-label={pageCopy.pagination?.next || (locale === 'ja' ? '次へ' : '下一页')}
  192. disabled={page >= totalPages}
  193. onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
  194. >
  195. {pageCopy.pagination?.next || (locale === 'ja' ? '次へ' : '下一页')}
  196. </button>
  197. </div>
  198. </div>
  199. </div>
  200. </section>
  201. </DashboardLayout>
  202. )
  203. }