| @@ -4,7 +4,10 @@ Self-hosted fonts: no `fonts.googleapis.com` or `fonts.gstatic.com`. Static asse | |||||
| Adjust `report-uri` if your API base path or host differs (UAT example below; PROD uses `https://pnsps.gld.gov.hk/api/csp-report`). | Adjust `report-uri` if your API base path or host differs (UAT example below; PROD uses `https://pnsps.gld.gov.hk/api/csp-report`). | ||||
| See also: [csp-report-review-2026-08-03.md](./csp-report-review-2026-08-03.md) for the PROD report analysis that drove `frame-src` / `media-src` updates. | |||||
| See also: | |||||
| - [csp-report-review-2026-08-14.md](./csp-report-review-2026-08-14.md) — latest PROD + UAT report review and add/remove summary | |||||
| - [csp-report-review-2026-08-03.md](./csp-report-review-2026-08-03.md) — earlier PROD report analysis that drove `frame-src` / `media-src` updates | |||||
| ## Enforcing | ## Enforcing | ||||
| @@ -18,7 +21,7 @@ Header always set Content-Security-Policy "default-src 'self'; \ | |||||
| script-src 'self'; \ | script-src 'self'; \ | ||||
| style-src 'self' 'unsafe-inline'; \ | style-src 'self' 'unsafe-inline'; \ | ||||
| style-src-elem 'self' 'unsafe-inline'; \ | style-src-elem 'self' 'unsafe-inline'; \ | ||||
| img-src 'self' data: https://www.w3.org https://w3.org; \ | |||||
| img-src 'self' data:; \ | |||||
| media-src 'self' blob: data:; \ | media-src 'self' blob: data:; \ | ||||
| font-src 'self' data:; \ | font-src 'self' data:; \ | ||||
| connect-src 'self'; \ | connect-src 'self'; \ | ||||
| @@ -39,7 +42,7 @@ Header always set Content-Security-Policy-Report-Only "default-src 'self'; \ | |||||
| script-src 'self'; \ | script-src 'self'; \ | ||||
| style-src 'self' 'unsafe-inline'; \ | style-src 'self' 'unsafe-inline'; \ | ||||
| style-src-elem 'self' 'unsafe-inline'; \ | style-src-elem 'self' 'unsafe-inline'; \ | ||||
| img-src 'self' data: https://www.w3.org https://w3.org; \ | |||||
| img-src 'self' data:; \ | |||||
| media-src 'self' blob: data:; \ | media-src 'self' blob: data:; \ | ||||
| font-src 'self' data:; \ | font-src 'self' data:; \ | ||||
| connect-src 'self'; \ | connect-src 'self'; \ | ||||
| @@ -51,7 +54,7 @@ Header always set Content-Security-Policy-Report-Only "default-src 'self'; \ | |||||
| - **`frame-src`**: Required for proof file preview iframes that use `data:` / `blob:` URLs (`UploadFileTable.js`). Without this, framing falls back to `default-src 'self'` and browsers report `frame-src` violations (often with empty `blocked-uri`). | - **`frame-src`**: Required for proof file preview iframes that use `data:` / `blob:` URLs (`UploadFileTable.js`). Without this, framing falls back to `default-src 'self'` and browsers report `frame-src` violations (often with empty `blocked-uri`). | ||||
| - **`style-src-elem`**: Explicit, alongside `style-src`, for `<link rel="stylesheet">` behaviour in modern browsers. | - **`style-src-elem`**: Explicit, alongside `style-src`, for `<link rel="stylesheet">` behaviour in modern browsers. | ||||
| - **`img-src`**: Includes `https://www.w3.org` and `https://w3.org` so W3C WCAG badge URLs are allowed. | |||||
| - **`img-src`**: `'self' data:` is enough. The WCAG 2 AA badge is bundled locally (`src/assets/images/wcag2AA.png`) and no longer loaded from `www.w3.org`. | |||||
| - **`media-src`**: `blob:` for captcha audio object URLs; `data:` for data-URI media if used. | - **`media-src`**: `blob:` for captcha audio object URLs; `data:` for data-URI media if used. | ||||
| - **`font-src`**: `'self' data:` covers bundled fonts and `data:` URLs if used. | - **`font-src`**: `'self' data:` covers bundled fonts and `data:` URLs if used. | ||||
| - **Report noise**: Browser extensions (Kaspersky, Perplexity, Youdao, Quark, Google Fonts injected by tooling, `wasm-eval` from chrome-extension) will still appear under Report-Only. Do **not** allowlist those origins. | - **Report noise**: Browser extensions (Kaspersky, Perplexity, Youdao, Quark, Google Fonts injected by tooling, `wasm-eval` from chrome-extension) will still appear under Report-Only. Do **not** allowlist those origins. | ||||
| @@ -12,6 +12,7 @@ Source logs (external): | |||||
| Related app docs: | Related app docs: | ||||
| - [csp-apache.conf.md](./csp-apache.conf.md) — Apache header snippets to deploy | - [csp-apache.conf.md](./csp-apache.conf.md) — Apache header snippets to deploy | ||||
| - [csp-report-review-2026-08-14.md](./csp-report-review-2026-08-14.md) — later PROD + UAT review (add/remove summary) | |||||
| --- | --- | ||||
| @@ -0,0 +1,212 @@ | |||||
| # CSP Report Review (PROD + UAT, 2026-08-14) | |||||
| Review of Content-Security-Policy-Report-Only violations from PNSPS PROD | |||||
| (`https://pnsps.gld.gov.hk`) and UAT (`https://pnspsuat.gld.gov.hk`), for | |||||
| deciding Apache CSP updates. | |||||
| Source logs (external): | |||||
| - `P1 CSP.txt` — PROD node, 168 reports, 2026-07-30 to 2026-08-14 | |||||
| - `P2 CSP.txt` — PROD node, 137 reports, 2026-07-30 to 2026-08-14 | |||||
| - `TT CSP.txt` — UAT, 133 reports, 2026-05-29 to 2026-08-03 | |||||
| Related app docs: | |||||
| - [csp-apache.conf.md](./csp-apache.conf.md) — Apache header snippets to deploy | |||||
| - [csp-report-review-2026-08-03.md](./csp-report-review-2026-08-03.md) — earlier PROD review | |||||
| --- | |||||
| ## Verdict | |||||
| CSP is still **Report-Only** (`disposition: report`) — nothing is blocking users | |||||
| yet. There is **one real PNSPS SPA problem** (proof PDF preview `frame-src`). | |||||
| Everything else is browser-extension noise or a **different app** on the same | |||||
| UAT host. | |||||
| The 2026-08-03 recommendation to add `frame-src` is **still not on the live | |||||
| header**. | |||||
| --- | |||||
| ## Add / remove summary | |||||
| **PROD — add one line, remove nothing:** | |||||
| ```apache | |||||
| frame-src 'self' data: blob:; | |||||
| ``` | |||||
| Keep `script-src 'self'`. Do not add `'unsafe-inline'` or `'unsafe-eval'`. | |||||
| **UAT — add the same line, remove nothing now:** | |||||
| ```apache | |||||
| frame-src 'self' data: blob:; | |||||
| ``` | |||||
| Keep `script-src 'self' 'unsafe-inline' 'unsafe-eval'` for now (JSF payment recon | |||||
| on the same host). Do not copy this `script-src` to PROD. | |||||
| **Do not add on either env:** `'wasm-unsafe-eval'`, Youdao, NetEase CDN, | |||||
| `todesktop-internal`, Google Fonts. | |||||
| **Optional later (not required for these reports):** UAT can drop | |||||
| `'unsafe-inline' 'unsafe-eval'` from the SPA header only after | |||||
| `/paymentrecon-dept_web/` has its own CSP. | |||||
| --- | |||||
| ## Confirmed current PROD policy (Report-Only) | |||||
| This is the live header that generated the P1/P2 reports. It matches the | |||||
| `original-policy` in every PROD violation. | |||||
| ```apache | |||||
| Header always set Content-Security-Policy-Report-Only "default-src 'self'; \ | |||||
| base-uri 'self'; \ | |||||
| object-src 'none'; \ | |||||
| frame-ancestors 'none'; \ | |||||
| form-action 'self'; \ | |||||
| script-src 'self'; \ | |||||
| style-src 'self' 'unsafe-inline'; \ | |||||
| style-src-elem 'self' 'unsafe-inline'; \ | |||||
| img-src 'self' data: https://www.w3.org https://w3.org; \ | |||||
| media-src 'self' blob:; \ | |||||
| font-src 'self' data:; \ | |||||
| connect-src 'self'; \ | |||||
| upgrade-insecure-requests; \ | |||||
| report-uri https://pnsps.gld.gov.hk/api/csp-report" | |||||
| ``` | |||||
| Gap vs the reports: **no `frame-src`**. Framing therefore falls back to | |||||
| `default-src 'self'`, which is why proof PDF preview reports `frame-src` with | |||||
| an empty `blocked-uri`. | |||||
| --- | |||||
| ## Confirmed current UAT policy (Report-Only) | |||||
| ```apache | |||||
| Header always set Content-Security-Policy-Report-Only "default-src 'self'; \ | |||||
| base-uri 'self'; \ | |||||
| object-src 'none'; \ | |||||
| frame-ancestors 'none'; \ | |||||
| form-action 'self'; \ | |||||
| script-src 'self' 'unsafe-inline' 'unsafe-eval'; \ | |||||
| style-src 'self' 'unsafe-inline'; \ | |||||
| style-src-elem 'self' 'unsafe-inline'; \ | |||||
| img-src 'self' data: https://www.w3.org https://w3.org; \ | |||||
| media-src 'self' blob:; \ | |||||
| font-src 'self' data:; \ | |||||
| connect-src 'self'; \ | |||||
| upgrade-insecure-requests; \ | |||||
| report-uri https://pnspsuat.gld.gov.hk/api/csp-report" | |||||
| ``` | |||||
| UAT vs PROD differences: | |||||
| - UAT `script-src` is `'self' 'unsafe-inline' 'unsafe-eval'` — PROD is `'self'` only. | |||||
| - Both still lack `frame-src`. | |||||
| - TT report `original-policy` was older: `script-src 'self' 'unsafe-inline'` | |||||
| **without** `'unsafe-eval'`. That is why TT logged 42 JSF `eval` hits. Those | |||||
| should stop under the current UAT header. | |||||
| Do **not** copy `'unsafe-inline'` or `'unsafe-eval'` onto PROD. The React SPA | |||||
| does not need them. `'unsafe-eval'` is only there for the shared-host JSF | |||||
| payment recon app. | |||||
| --- | |||||
| ## Real problem (PROD P1 + P2) | |||||
| | Volume | Directive | Blocked | Pages | | |||||
| |---:|---|---|---| | |||||
| | 150 | `frame-src` | empty `""` | `/proof/create/{id}` | | |||||
| Source is always `static/js/4608.4af455e6.chunk.js`. Cause is proof file preview | |||||
| in `src/pages/Proof/Create_FromApp/UploadFileTable.js`: | |||||
| `FileReader.readAsDataURL` then `document.write` an `<iframe src="data:...">`. | |||||
| With no `frame-src`, CSP uses `default-src 'self'` and blocks `data:` frames. | |||||
| Browsers strip the `data:` URI from reports (empty `blocked-uri`). | |||||
| **If this policy is promoted to enforcing without `frame-src`, PDF preview on | |||||
| proof create will break.** | |||||
| Apache fix (already documented in [csp-apache.conf.md](./csp-apache.conf.md), | |||||
| not deployed): | |||||
| ```apache | |||||
| frame-src 'self' data: blob:; | |||||
| ``` | |||||
| Optional frontend follow-up: switch preview to `URL.createObjectURL` + `blob:` | |||||
| (still needs `frame-src ... blob:`). | |||||
| Optional (low priority): `media-src 'self' blob: data:;` — current `blob:` | |||||
| already covers captcha audio. | |||||
| --- | |||||
| ## Not a PNSPS SPA problem — do not allowlist | |||||
| | Volume | Where | Directive | Blocked | Why ignore | | |||||
| |---:|---|---|---|---| | |||||
| | 109 | PROD | `script-src` | `wasm-eval` | `source-file: chrome-extension` | | |||||
| | 18 | PROD | `media-src` | `dict.youdao.com` | Youdao translation plugin | | |||||
| | 11 | PROD | `img-src` | `ydlunacommon-cdn.nosdn.127.net` | Youdao/NetEase CDN icons | | |||||
| | 3 | PROD P2 | `script-src-elem` | `todesktop-internal` | Desktop wrapper | | |||||
| | 105 | PROD 14 + UAT 91 | `img-src` | `www.w3.org/WAI/wcag2AA` | Already allowed in policy; mostly SPA `status-code: 404` noise. Badge is in `src/components/cards/AuthFooter.js`. | | |||||
| Do **not** add `'unsafe-eval'`, `'wasm-unsafe-eval'`, Youdao, NetEase, or | |||||
| `todesktop-internal` to the SPA policy. | |||||
| --- | |||||
| ## UAT-only: payment recon JSF (not the React app) | |||||
| 42 TT reports: `script-src` / `eval` on: | |||||
| - `/paymentrecon-dept_web/report/paymentReconRpt01Search.jsf` (38) | |||||
| - `/paymentrecon-dept_web/report/paymentReconRpt02Search.jsf` (3) | |||||
| - `/paymentrecon-dept_web/manualrecon/manualReconSearch.jsf` (1) | |||||
| This is a **separate JSF app** on the same Apache host. The TT logs were | |||||
| generated under the **old** UAT policy (`script-src 'self' 'unsafe-inline'`). | |||||
| Current UAT already adds `'unsafe-eval'`, so those `eval` reports should | |||||
| disappear. | |||||
| That UAT looseness is a workaround for JSF, not a requirement of PNSPS. Before | |||||
| enforcing CSP: | |||||
| - Keep PROD at `script-src 'self'` (do not add `'unsafe-eval'`). | |||||
| - Prefer a path-specific header for `/paymentrecon-dept_web/` on UAT so the SPA | |||||
| can later drop `'unsafe-inline'` / `'unsafe-eval'`. | |||||
| --- | |||||
| ## Recommended actions (ops / Apache, not app code) | |||||
| 1. Add `frame-src 'self' data: blob:;` to both PROD and UAT Report-Only headers. | |||||
| Do not change PROD `script-src 'self'`. | |||||
| 2. Confirm proof-create preview reports drop after that deploy. | |||||
| 3. Keep Report-Only until `frame-src` volume is gone; remaining extension noise | |||||
| is expected. | |||||
| 4. Do not promote UAT's `'unsafe-inline' 'unsafe-eval'` to PROD. Those tokens | |||||
| exist for JSF payment recon on the shared UAT host. | |||||
| 5. Optional: give `/paymentrecon-dept_web/` its own Apache CSP so UAT SPA can | |||||
| later match PROD (`script-src 'self'`). | |||||
| 6. Optional later: host the WCAG badge locally to cut `img-src` noise; change | |||||
| preview to `blob:` URLs. | |||||
| No application code change is required for the reports to become clean after | |||||
| the Apache `frame-src` deploy. | |||||
| --- | |||||
| ## Backend reference | |||||
| - Report endpoint: `POST /csp-report` (context path → `/api/csp-report`) | |||||
| - Spring Security also sets a short API CSP on API responses | |||||
| (`default-src 'self'; script-src 'self'; frame-ancestors 'self'`). | |||||
| Frontend/Apache CSP above is what browsers enforce for the SPA document. | |||||
| @@ -22,7 +22,7 @@ let expiredAlertShownInMemory = false; | |||||
| /** Login / public auth endpoints must not trigger token refresh or session-expiry reload. */ | /** Login / public auth endpoints must not trigger token refresh or session-expiry reload. */ | ||||
| const isPublicAuthRequest = (reqUrl) => | const isPublicAuthRequest = (reqUrl) => | ||||
| reqUrl.includes('/login') || reqUrl.includes('/ldap-login'); | |||||
| reqUrl.includes('/login'); | |||||
| /** Clear stale session-expiry flag so a new login attempt can show its own error dialog. */ | /** Clear stale session-expiry flag so a new login attempt can show its own error dialog. */ | ||||
| export const clearExpiredSessionAlert = () => { | export const clearExpiredSessionAlert = () => { | ||||
| @@ -46,7 +46,6 @@ export default class JwtService { | |||||
| if (response && response.status === 401) { | if (response && response.status === 401) { | ||||
| const isPublicAuthRequest = | const isPublicAuthRequest = | ||||
| reqUrl.includes('/login') || | reqUrl.includes('/login') || | ||||
| reqUrl.includes('/ldap-login') || | |||||
| reqUrl.includes('/refresh-token') | reqUrl.includes('/refresh-token') | ||||
| if (isPublicAuthRequest) { | if (isPublicAuthRequest) { | ||||
| return Promise.reject(error) | return Promise.reject(error) | ||||
| @@ -21,7 +21,7 @@ export function FiDataGrid({ rows, columns, sx, autoHeight = true, | |||||
| hideFooterSelectedRowCount, rowModesModel, editMode, | hideFooterSelectedRowCount, rowModesModel, editMode, | ||||
| pageSizeOptions, filterItems, customPageSize, doLoad, applyGridOnReady, applySearch, | pageSizeOptions, filterItems, customPageSize, doLoad, applyGridOnReady, applySearch, | ||||
| tab, height, maxHeight, pagination = true, serverSorting = false, | tab, height, maxHeight, pagination = true, serverSorting = false, | ||||
| disablePagingOnGazetteIssue = true, ...props }) { | |||||
| disablePagingOnGazetteIssue = true, hideRowsPerPage = false, ...props }) { | |||||
| const intl = useIntl(); | const intl = useIntl(); | ||||
| const [_rows, set_rows] = useState([]); | const [_rows, set_rows] = useState([]); | ||||
| const [_doLoad, set_doLoad] = useState({}); | const [_doLoad, set_doLoad] = useState({}); | ||||
| @@ -73,7 +73,9 @@ export function FiDataGrid({ rows, columns, sx, autoHeight = true, | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (doLoad !== undefined && Object.keys(doLoad).length>0 ){ | if (doLoad !== undefined && Object.keys(doLoad).length>0 ){ | ||||
| if(applySearch!=undefined){ | |||||
| if (disablePagingOnGazetteIssue && hasGazetteIssueFilter(doLoad.params)) { | |||||
| setPage(0); | |||||
| } else if(applySearch!=undefined){ | |||||
| if (Object.keys(getSearchCriteria(window.location.pathname)).length>0){ | if (Object.keys(getSearchCriteria(window.location.pathname)).length>0){ | ||||
| const localStorageSearchCriteria = getSearchCriteria(window.location.pathname) | const localStorageSearchCriteria = getSearchCriteria(window.location.pathname) | ||||
| // console.log(localStorageSearchCriteria) | // console.log(localStorageSearchCriteria) | ||||
| @@ -117,6 +119,7 @@ export function FiDataGrid({ rows, columns, sx, autoHeight = true, | |||||
| if (!pagination || (disablePagingOnGazetteIssue && hasGazetteIssueFilter(params))) { | if (!pagination || (disablePagingOnGazetteIssue && hasGazetteIssueFilter(params))) { | ||||
| delete params.start; | delete params.start; | ||||
| delete params.limit; | delete params.limit; | ||||
| setPage(0); | |||||
| } else { | } else { | ||||
| params.start = 0; | params.start = 0; | ||||
| params.limit = pageSize; | params.limit = pageSize; | ||||
| @@ -130,12 +133,13 @@ export function FiDataGrid({ rows, columns, sx, autoHeight = true, | |||||
| } | } | ||||
| }; | }; | ||||
| const ignorePaging = !pagination || (disablePagingOnGazetteIssue && hasGazetteIssueFilter(_doLoad?.params)); | |||||
| const effectivePagination = pagination && !ignorePaging; | |||||
| const loadAllOnGazetteIssue = disablePagingOnGazetteIssue && hasGazetteIssueFilter(_doLoad?.params); | |||||
| const ignorePagingParams = !pagination || loadAllOnGazetteIssue; | |||||
| const showPaginationBar = pagination; | |||||
| useEffect(() => { | useEffect(() => { | ||||
| getDataList(); | getDataList(); | ||||
| }, [_doLoad, page]); | |||||
| }, [_doLoad, page, pageSize]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -216,7 +220,7 @@ export function FiDataGrid({ rows, columns, sx, autoHeight = true, | |||||
| if (_doLoad.params == undefined) return; | if (_doLoad.params == undefined) return; | ||||
| if (_doLoad.params.searchCriteria !== undefined) return; | if (_doLoad.params.searchCriteria !== undefined) return; | ||||
| if (_doLoad.params == null) _doLoad.params = {}; | if (_doLoad.params == null) _doLoad.params = {}; | ||||
| if (!pagination || (disablePagingOnGazetteIssue && hasGazetteIssueFilter(_doLoad.params))) { | |||||
| if (ignorePagingParams) { | |||||
| delete _doLoad.params.start; | delete _doLoad.params.start; | ||||
| delete _doLoad.params.limit; | delete _doLoad.params.limit; | ||||
| } else { | } else { | ||||
| @@ -232,7 +236,12 @@ export function FiDataGrid({ rows, columns, sx, autoHeight = true, | |||||
| localStorage.setItem('searchCriteria', JSON.stringify({path:window.location.pathname,data:_doLoad.params})) | localStorage.setItem('searchCriteria', JSON.stringify({path:window.location.pathname,data:_doLoad.params})) | ||||
| } | } | ||||
| } | } | ||||
| setLoading(true); | |||||
| if (applyGridOnReady !== undefined) { | |||||
| applyGridOnReady(true); | |||||
| } | |||||
| HttpUtils.get({ | HttpUtils.get({ | ||||
| url: _doLoad.url, | url: _doLoad.url, | ||||
| params: _doLoad.params, | params: _doLoad.params, | ||||
| @@ -309,13 +318,13 @@ export function FiDataGrid({ rows, columns, sx, autoHeight = true, | |||||
| disableColumnMenu | disableColumnMenu | ||||
| shrinkWrap | shrinkWrap | ||||
| rowModesModel={_rowModesModel} | rowModesModel={_rowModesModel} | ||||
| pageSizeOptions={effectivePagination ? _pageSizeOptions : []} | |||||
| pageSizeOptions={showPaginationBar ? _pageSizeOptions : []} | |||||
| editMode={_editMode} | editMode={_editMode} | ||||
| autoHeight={effectiveAutoHeight} | autoHeight={effectiveAutoHeight} | ||||
| hideFooterSelectedRowCount={myHideFooterSelectedRowCount} | hideFooterSelectedRowCount={myHideFooterSelectedRowCount} | ||||
| filterModel={{ items: _filterItems }} | filterModel={{ items: _filterItems }} | ||||
| loading={loading} | loading={loading} | ||||
| paginationMode={effectivePagination ? "server" : undefined} | |||||
| paginationMode={showPaginationBar ? "server" : undefined} | |||||
| sortingMode={serverSorting ? "server" : undefined} | sortingMode={serverSorting ? "server" : undefined} | ||||
| sortModel={serverSorting ? sortModel : undefined} | sortModel={serverSorting ? sortModel : undefined} | ||||
| onSortModelChange={serverSorting ? handleSortModelChange : undefined} | onSortModelChange={serverSorting ? handleSortModelChange : undefined} | ||||
| @@ -324,9 +333,13 @@ export function FiDataGrid({ rows, columns, sx, autoHeight = true, | |||||
| '& .MuiDataGrid-virtualScroller': { | '& .MuiDataGrid-virtualScroller': { | ||||
| overflowY: height || maxHeight ? 'auto' : 'visible', | overflowY: height || maxHeight ? 'auto' : 'visible', | ||||
| overflowX: height || maxHeight ? 'auto' : 'visible', | overflowX: height || maxHeight ? 'auto' : 'visible', | ||||
| ...(loading && { filter: 'blur(2px)' }), | |||||
| }, | |||||
| '& .MuiDataGrid-overlay': { | |||||
| backgroundColor: 'rgba(255, 255, 255, 0.55)', | |||||
| }, | }, | ||||
| // 👇 completely hide the footer when pagination is off | |||||
| ...(!effectivePagination && { | |||||
| // Hide the footer only when pagination is disabled for the grid | |||||
| ...(!showPaginationBar && { | |||||
| '& .MuiDataGrid-footerContainer': { | '& .MuiDataGrid-footerContainer': { | ||||
| display: 'none', | display: 'none', | ||||
| }, | }, | ||||
| @@ -334,25 +347,30 @@ export function FiDataGrid({ rows, columns, sx, autoHeight = true, | |||||
| }} | }} | ||||
| components={{ | components={{ | ||||
| NoRowsOverlay: CustomNoRowsOverlay, | NoRowsOverlay: CustomNoRowsOverlay, | ||||
| ...(effectivePagination | |||||
| ...(showPaginationBar | |||||
| ? { | ? { | ||||
| Pagination: () => ( | |||||
| Pagination: () => { | |||||
| const total = rowCount || 0; | |||||
| const pagerPage = loadAllOnGazetteIssue ? 0 : page; | |||||
| const pagerPageSize = loadAllOnGazetteIssue ? Math.max(total, 1) : pageSize; | |||||
| return ( | |||||
| <TablePagination | <TablePagination | ||||
| component="div" | component="div" | ||||
| count={rowCount || 0} | |||||
| page={page} | |||||
| rowsPerPage={pageSize} | |||||
| rowsPerPageOptions={_pageSizeOptions} | |||||
| count={total} | |||||
| page={pagerPage} | |||||
| rowsPerPage={pagerPageSize} | |||||
| rowsPerPageOptions={(loadAllOnGazetteIssue || hideRowsPerPage) ? [] : _pageSizeOptions} | |||||
| labelDisplayedRows={() => { | labelDisplayedRows={() => { | ||||
| const total = rowCount || 0; | |||||
| if (!_rows?.length || total === 0) { | if (!_rows?.length || total === 0) { | ||||
| return `0-0 ${intl.formatMessage({ id: "of" })} ${total}`; | return `0-0 ${intl.formatMessage({ id: "of" })} ${total}`; | ||||
| } | } | ||||
| const from = page * pageSize + 1; | |||||
| const to = Math.min(page * pageSize + _rows.length, total); | |||||
| const from = loadAllOnGazetteIssue ? 1 : page * pageSize + 1; | |||||
| const to = loadAllOnGazetteIssue | |||||
| ? _rows.length | |||||
| : Math.min(page * pageSize + _rows.length, total); | |||||
| return `${from}-${to} ${intl.formatMessage({ id: "of" })} ${total}`; | return `${from}-${to} ${intl.formatMessage({ id: "of" })} ${total}`; | ||||
| }} | }} | ||||
| labelRowsPerPage={intl.formatMessage({ id: "rowsPerPage" }) + ":"} | |||||
| labelRowsPerPage={hideRowsPerPage ? () => "" : intl.formatMessage({ id: "rowsPerPage" }) + ":"} | |||||
| getItemAriaLabel={(type) => { | getItemAriaLabel={(type) => { | ||||
| if (type === 'previous') { | if (type === 'previous') { | ||||
| return intl.formatMessage({ id: 'paginationPrev' }); | return intl.formatMessage({ id: 'paginationPrev' }); | ||||
| @@ -362,10 +380,11 @@ export function FiDataGrid({ rows, columns, sx, autoHeight = true, | |||||
| } | } | ||||
| return ''; | return ''; | ||||
| }} | }} | ||||
| onPageChange={handleChangePage} | |||||
| onRowsPerPageChange={handleChangePageSize} | |||||
| onPageChange={loadAllOnGazetteIssue ? () => {} : handleChangePage} | |||||
| onRowsPerPageChange={(loadAllOnGazetteIssue || hideRowsPerPage) ? () => {} : handleChangePageSize} | |||||
| /> | /> | ||||
| ), | |||||
| ); | |||||
| }, | |||||
| } | } | ||||
| : {}), | : {}), | ||||
| }} | }} | ||||
| @@ -1,6 +1,7 @@ | |||||
| // material-ui | // material-ui | ||||
| import { useMediaQuery, Container, Link, Typography, Stack } from '@mui/material'; | import { useMediaQuery, Container, Link, Typography, Stack } from '@mui/material'; | ||||
| import bhkLogo from 'assets/images/BHK_logo_rgb_zh-hk.png'; | import bhkLogo from 'assets/images/BHK_logo_rgb_zh-hk.png'; | ||||
| import wcag2AA from 'assets/images/wcag2AA.png'; | |||||
| import {FormattedMessage} from "react-intl"; | import {FormattedMessage} from "react-intl"; | ||||
| import {useIntl} from "react-intl"; | import {useIntl} from "react-intl"; | ||||
| import { | import { | ||||
| @@ -62,7 +63,7 @@ const AuthFooter = () => { | |||||
| <img | <img | ||||
| height="32" | height="32" | ||||
| width="88" | width="88" | ||||
| src="https://www.w3.org/WAI/wcag2AA" | |||||
| src={wcag2AA} | |||||
| alt={wcagAlt} | alt={wcagAlt} | ||||
| /> | /> | ||||
| </a> | </a> | ||||
| @@ -53,7 +53,7 @@ export default function SearchTable({ previewSearchCriteria, onPreviewGridOnRead | |||||
| id: 'paymentMethod', | id: 'paymentMethod', | ||||
| field: 'paymentMethod', | field: 'paymentMethod', | ||||
| headerName: 'GFMIS Payment Method', | headerName: 'GFMIS Payment Method', | ||||
| flex: 4, | |||||
| flex: 2, | |||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| let paymentMethod = params.row.paymentMethod; | let paymentMethod = params.row.paymentMethod; | ||||
| return <div style={{ margin: 4 }}>{paymentMethod}</div> | return <div style={{ margin: 4 }}>{paymentMethod}</div> | ||||
| @@ -63,11 +63,27 @@ export default function SearchTable({ previewSearchCriteria, onPreviewGridOnRead | |||||
| id: 'payAmount', | id: 'payAmount', | ||||
| field: 'payAmount', | field: 'payAmount', | ||||
| headerName: 'Amount ($)', | headerName: 'Amount ($)', | ||||
| flex: 5, | |||||
| valueGetter: (params) => { | |||||
| flex: 2, | |||||
| align: 'right', | |||||
| headerAlign: 'right', | |||||
| // keep the raw number as the grid value so sorting is numeric; | |||||
| // valueFormatter is used for display only | |||||
| valueFormatter: (params) => { | |||||
| return (params?.value) ? "$ " + FormatUtils.currencyFormat(params?.value) : ""; | return (params?.value) ? "$ " + FormatUtils.currencyFormat(params?.value) : ""; | ||||
| }, | |||||
| sortComparator: (v1, v2) => { | |||||
| const n1 = (v1 == null || isNaN(v1)) ? -Infinity : Number(v1); | |||||
| const n2 = (v2 == null || isNaN(v2)) ? -Infinity : Number(v2); | |||||
| return n1 - n2; | |||||
| } | } | ||||
| }, | }, | ||||
| { | |||||
| id: 'spare', | |||||
| field: 'spare', | |||||
| headerName: '', | |||||
| flex: 4, | |||||
| disableColumnMenu: true, | |||||
| }, | |||||
| ]; | ]; | ||||
| @@ -76,7 +92,7 @@ export default function SearchTable({ previewSearchCriteria, onPreviewGridOnRead | |||||
| <FiDataGrid | <FiDataGrid | ||||
| key={previewToken} | key={previewToken} | ||||
| sx={_sx} | sx={_sx} | ||||
| rowHeight={80} | |||||
| getRowHeight={() => 'auto'} | |||||
| columns={columns} | columns={columns} | ||||
| customPageSize={10} | customPageSize={10} | ||||
| onRowDoubleClick={handleEditClick} | onRowDoubleClick={handleEditClick} | ||||
| @@ -65,6 +65,14 @@ export default function SearchPaymentTable({ searchCriteria, applyGridOnReady, a | |||||
| flex: 1, | flex: 1, | ||||
| minWidth: 200, | minWidth: 200, | ||||
| cellClassName: 'actions', | cellClassName: 'actions', | ||||
| // sorting/filtering uses the raw payment no. (transNo), not the link | |||||
| valueGetter: (params) => params?.row?.transNo ?? '', | |||||
| sortComparator: (v1, v2) => { | |||||
| const n1 = Number(v1); | |||||
| const n2 = Number(v2); | |||||
| if (!isNaN(n1) && !isNaN(n2)) return n1 - n2; | |||||
| return String(v1 ?? '').localeCompare(String(v2 ?? '')); | |||||
| }, | |||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| return clickableLink('/paymentPage/details/' + params.row.id, params.row.transNo); | return clickableLink('/paymentPage/details/' + params.row.id, params.row.transNo); | ||||
| }, | }, | ||||
| @@ -107,17 +115,33 @@ export default function SearchPaymentTable({ searchCriteria, applyGridOnReady, a | |||||
| field: 'payAmount', | field: 'payAmount', | ||||
| headerName: 'Amount ($)', | headerName: 'Amount ($)', | ||||
| width: 150, | width: 150, | ||||
| valueGetter: (params) => { | |||||
| align: 'right', | |||||
| headerAlign: 'right', | |||||
| // keep the raw number as the grid value so sorting is numeric; | |||||
| // valueFormatter is used for display only | |||||
| valueFormatter: (params) => { | |||||
| return (params?.value) ? "$ " + FormatUtils.currencyFormat(params?.value) : ""; | return (params?.value) ? "$ " + FormatUtils.currencyFormat(params?.value) : ""; | ||||
| }, | |||||
| sortComparator: (v1, v2) => { | |||||
| const n1 = (v1 == null || isNaN(v1)) ? -Infinity : Number(v1); | |||||
| const n2 = (v2 == null || isNaN(v2)) ? -Infinity : Number(v2); | |||||
| return n1 - n2; | |||||
| } | } | ||||
| }, | }, | ||||
| { | |||||
| id: 'spare', | |||||
| field: 'spare', | |||||
| headerName: '', | |||||
| width: 30, | |||||
| disableColumnMenu: true, | |||||
| }, | |||||
| ]; | ]; | ||||
| return ( | return ( | ||||
| <div style={{ width: '100%' }}> | <div style={{ width: '100%' }}> | ||||
| <FiDataGrid | <FiDataGrid | ||||
| sx={_sx} | sx={_sx} | ||||
| rowHeight={60} | |||||
| getRowHeight={() => "auto"} | |||||
| columns={columns} | columns={columns} | ||||
| height={500} | height={500} | ||||
| autoHeight={false} | autoHeight={false} | ||||
| @@ -24,6 +24,7 @@ const LoadingComponent = Loadable(React.lazy(() => import('pages/extra-pages/Loa | |||||
| import titleBackgroundImg from 'assets/images/dashboard/gazette-bar.png' | import titleBackgroundImg from 'assets/images/dashboard/gazette-bar.png' | ||||
| import {FormattedMessage} from "react-intl"; | import {FormattedMessage} from "react-intl"; | ||||
| import { ERROR_CONTAINED_BUTTON_SX } from "themes/colorConst"; | |||||
| const BackgroundHead = { | const BackgroundHead = { | ||||
| backgroundImage: `url(${titleBackgroundImg})`, | backgroundImage: `url(${titleBackgroundImg})`, | ||||
| width: '100%', | width: '100%', | ||||
| @@ -161,11 +162,10 @@ const Index = () => { | |||||
| component="span" | component="span" | ||||
| variant="contained" | variant="contained" | ||||
| size="large" | size="large" | ||||
| color="error" | |||||
| onClick={() => { | onClick={() => { | ||||
| cancelPayment(); | cancelPayment(); | ||||
| }} | }} | ||||
| sx={{ m: 4 }} | |||||
| sx={{ m: 4, ...ERROR_CONTAINED_BUTTON_SX }} | |||||
| > | > | ||||
| <FormattedMessage id="payCancel"/> | <FormattedMessage id="payCancel"/> | ||||
| </Button> | </Button> | ||||
| @@ -4,6 +4,7 @@ import { | |||||
| Typography, | Typography, | ||||
| Stack, | Stack, | ||||
| Button, | Button, | ||||
| Box, | |||||
| } from '@mui/material'; | } from '@mui/material'; | ||||
| import * as React from "react"; | import * as React from "react"; | ||||
| import * as HttpUtils from "utils/HttpUtils"; | import * as HttpUtils from "utils/HttpUtils"; | ||||
| @@ -23,6 +24,7 @@ const LoadingComponent = Loadable(React.lazy(() => import('pages/extra-pages/Loa | |||||
| import titleBackgroundImg from 'assets/images/dashboard/gazette-bar.png' | import titleBackgroundImg from 'assets/images/dashboard/gazette-bar.png' | ||||
| import {FormattedMessage, useIntl} from "react-intl"; | import {FormattedMessage, useIntl} from "react-intl"; | ||||
| import { PRIMARY_CONTAINED_BUTTON_SX, ERROR_CONTAINED_BUTTON_SX } from "themes/colorConst"; | |||||
| const BackgroundHead = { | const BackgroundHead = { | ||||
| backgroundImage: `url(${titleBackgroundImg})`, | backgroundImage: `url(${titleBackgroundImg})`, | ||||
| width: '100%', | width: '100%', | ||||
| @@ -394,27 +396,48 @@ const Index = () => { | |||||
| </div> | </div> | ||||
| </Grid> | </Grid> | ||||
| {/*row 1*/} | {/*row 1*/} | ||||
| <Grid item xs={12} md={12} > | |||||
| <Grid container justifyContent="flex-start" alignItems="center" > | |||||
| <center> | |||||
| <Grid item xs={12} md={12} width="100%"> | |||||
| <Grid container justifyContent="flex-start" alignItems="center" width="100%"> | |||||
| <center style={{ width: '100%', display: 'block' }}> | |||||
| <Grid item xs={12} md={12} > | <Grid item xs={12} md={12} > | ||||
| <br /><br /> | |||||
| <Typography component="span" variant="body1" sx={{ width: '80%', margin: 'auto', mt: 4, textAlign: "left", fontSize: '20px' }}> | |||||
| <FormattedMessage id="payAlert"/> | |||||
| <br /><br /> | |||||
| <Box sx={{ width: { xs: '90%', md: '40%' }, mx: 'auto', mt: 4, textAlign: 'left' }}> | |||||
| {!isError && | |||||
| <Typography component="div" variant="body1" color="error" sx={{ fontSize: '20px', fontWeight: 700, textAlign: 'center' }}> | |||||
| <FormattedMessage id="payAlert"/> | |||||
| </Typography> | |||||
| } | |||||
| {isError && | |||||
| <Typography component="div" variant="body1" color="error" sx={{ fontSize: '20px', fontWeight: 700, textAlign: 'justify' }}> | |||||
| <FormattedMessage id="fpsPaymentErrorMsg1"/> | |||||
| <Box | |||||
| component="ul" | |||||
| sx={{ | |||||
| m: 0, | |||||
| mt: 1, | |||||
| px: 0, | |||||
| width: '100%', | |||||
| boxSizing: 'border-box', | |||||
| listStylePosition: 'outside', | |||||
| paddingLeft: '1.5em', | |||||
| textAlign: 'justify', | |||||
| }} | |||||
| > | |||||
| <li style={{fontWeight:'bold', textAlign: 'justify'}}><FormattedMessage id="fpsPaymentErrorMsg2"/></li> | |||||
| <li style={{fontWeight:'bold', textAlign: 'justify'}}><FormattedMessage id="fpsPaymentErrorMsg3"/></li> | |||||
| </Box> | |||||
| </Typography> | |||||
| } | |||||
| </Box> | |||||
| <Typography component="span" variant="body1" sx={{ width: '80%', margin: 'auto', mt: 4, textAlign: "center", fontSize: '20px', display: 'block' }}> | |||||
| <img src={FpsIcon} width="80" height="80" alt="FPS"></img> | <img src={FpsIcon} width="80" height="80" alt="FPS"></img> | ||||
| <br /> | <br /> | ||||
| <FormattedMessage id="payTotalDeatail"/> | <FormattedMessage id="payTotalDeatail"/> | ||||
| <br /> | <br /> | ||||
| {"HK$ " + currencyFormat(paymentData.amount)} | {"HK$ " + currencyFormat(paymentData.amount)} | ||||
| </Typography> | </Typography> | ||||
| <br /><br /> | |||||
| {isError ? | |||||
| <Typography component="span" variant="body1" color="error" sx={{ width: '80%', margin: 'auto', mt: 4, textAlign: "left", fontSize: '20px' }}> | |||||
| <FormattedMessage id="fpsPaymentErrorMsg"/> | |||||
| </Typography> | |||||
| : | |||||
| browserType==mobileBrowser? | |||||
| <br /> | |||||
| {!isError && | |||||
| (browserType==mobileBrowser? | |||||
| <Typography variant="h3" sx={{ ml: 8, mt: 4, mr: 8, textAlign: "center" }}> | <Typography variant="h3" sx={{ ml: 8, mt: 4, mr: 8, textAlign: "center" }}> | ||||
| { | { | ||||
| sysEnv=="prod"? | sysEnv=="prod"? | ||||
| @@ -480,26 +503,32 @@ const Index = () => { | |||||
| </> | </> | ||||
| } | } | ||||
| </Typography> | </Typography> | ||||
| } | |||||
| )} | |||||
| <Typography variant="h3" sx={{ ml: 8, mt: 4, mr: 8, textAlign: "center" }}> | <Typography variant="h3" sx={{ ml: 8, mt: 4, mr: 8, textAlign: "center" }}> | ||||
| <Button | |||||
| component="span" | |||||
| variant="contained" | |||||
| size="large" | |||||
| onClick={()=>{ | |||||
| cancelPayment(); | |||||
| }} | |||||
| sx={{ | |||||
| m: 4, | |||||
| backgroundColor: (theme) => theme.palette.error.darker, | |||||
| color: (theme) => theme.palette.error.contrastText, | |||||
| '&:hover': { | |||||
| backgroundColor: (theme) => theme.palette.error.dark, | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <FormattedMessage id="payCancel"/> | |||||
| </Button> | |||||
| {isError ? | |||||
| <Button | |||||
| component="span" | |||||
| variant="contained" | |||||
| size="large" | |||||
| color="primary" | |||||
| onClick={() => navigate("/dashboard")} | |||||
| sx={{ m: 4, ...PRIMARY_CONTAINED_BUTTON_SX }} | |||||
| > | |||||
| <FormattedMessage id="backToMainPage"/> | |||||
| </Button> | |||||
| : | |||||
| <Button | |||||
| component="span" | |||||
| variant="contained" | |||||
| size="large" | |||||
| onClick={()=>{ | |||||
| cancelPayment(); | |||||
| }} | |||||
| sx={{ m: 4, ...ERROR_CONTAINED_BUTTON_SX }} | |||||
| > | |||||
| <FormattedMessage id="payCancel"/> | |||||
| </Button> | |||||
| } | |||||
| </Typography> | </Typography> | ||||
| </Grid> | </Grid> | ||||
| </center> | </center> | ||||
| @@ -18,6 +18,7 @@ const LoadingComponent = Loadable(React.lazy(() => import('pages/extra-pages/Loa | |||||
| import titleBackgroundImg from 'assets/images/dashboard/gazette-bar.png' | import titleBackgroundImg from 'assets/images/dashboard/gazette-bar.png' | ||||
| import {FormattedMessage} from "react-intl"; | import {FormattedMessage} from "react-intl"; | ||||
| import { ERROR_CONTAINED_BUTTON_SX } from "themes/colorConst"; | |||||
| const BackgroundHead = { | const BackgroundHead = { | ||||
| backgroundImage: `url(${titleBackgroundImg})`, | backgroundImage: `url(${titleBackgroundImg})`, | ||||
| width: '100%', | width: '100%', | ||||
| @@ -322,11 +323,10 @@ const Index = () => { | |||||
| component="span" | component="span" | ||||
| variant="contained" | variant="contained" | ||||
| size="large" | size="large" | ||||
| color="error" | |||||
| onClick={()=>{ | onClick={()=>{ | ||||
| cancelPayment(); | cancelPayment(); | ||||
| }} | }} | ||||
| sx={{ m: 4 }} | |||||
| sx={{ m: 4, ...ERROR_CONTAINED_BUTTON_SX }} | |||||
| > | > | ||||
| <FormattedMessage id="payCancel"/> | <FormattedMessage id="payCancel"/> | ||||
| </Button> | </Button> | ||||
| @@ -58,6 +58,8 @@ const MultiPaymentWindow = (props) => { | |||||
| const [paymentHoldedErrText, setPaymentHoldedErrText] = React.useState(""); | const [paymentHoldedErrText, setPaymentHoldedErrText] = React.useState(""); | ||||
| const [paymentHoldedErr, setPaymentHoldedErr] = React.useState(false); | const [paymentHoldedErr, setPaymentHoldedErr] = React.useState(false); | ||||
| const [isPaying, setIsPaying] = React.useState(false); | |||||
| const payingRef = React.useRef(false); | |||||
| const mobileBrowser = "Mobile"; | const mobileBrowser = "Mobile"; | ||||
| @@ -144,7 +146,13 @@ const MultiPaymentWindow = (props) => { | |||||
| }, [paymentMethod]); | }, [paymentMethod]); | ||||
| const releasePayLock = () => { | |||||
| payingRef.current = false; | |||||
| setIsPaying(false); | |||||
| }; | |||||
| const selectedPaymentMethodHandle = (method) => () =>{ | const selectedPaymentMethodHandle = (method) => () =>{ | ||||
| if (payingRef.current) return; | |||||
| if (method != paymentMethod){ | if (method != paymentMethod){ | ||||
| resetForm() | resetForm() | ||||
| let totalAmount = props.totalAmount; | let totalAmount = props.totalAmount; | ||||
| @@ -198,6 +206,10 @@ const MultiPaymentWindow = (props) => { | |||||
| }; | }; | ||||
| const handlePaymentCheck = () => { | const handlePaymentCheck = () => { | ||||
| if (payingRef.current) return; | |||||
| payingRef.current = true; | |||||
| setIsPaying(true); | |||||
| let appIdList = props.appIds | let appIdList = props.appIds | ||||
| // console.log(props.appIds) | // console.log(props.appIds) | ||||
| // console.log(appIdList) | // console.log(appIdList) | ||||
| @@ -207,6 +219,8 @@ const MultiPaymentWindow = (props) => { | |||||
| appIds: appIdList | appIds: appIdList | ||||
| }, | }, | ||||
| onSuccess: (responseData) => { | onSuccess: (responseData) => { | ||||
| if (!payingRef.current) return; | |||||
| const latestData = {}; | const latestData = {}; | ||||
| responseData.forEach(item => { | responseData.forEach(item => { | ||||
| @@ -235,13 +249,21 @@ const MultiPaymentWindow = (props) => { | |||||
| const resultString = HoldingApplication.map(item => item.appNo).join(' , '); | const resultString = HoldingApplication.map(item => item.appNo).join(' , '); | ||||
| setPaymentHoldedErrText(resultString); | setPaymentHoldedErrText(resultString); | ||||
| // setPaymentHoldedErrText(intl.formatMessage({ id: 'MSG.paymentHolded' }, { appNo: record.appNo })); | // setPaymentHoldedErrText(intl.formatMessage({ id: 'MSG.paymentHolded' }, { appNo: record.appNo })); | ||||
| releasePayLock(); | |||||
| setPaymentHoldedErr(true); | setPaymentHoldedErr(true); | ||||
| } | } | ||||
| }, | |||||
| onFail: () => { | |||||
| releasePayLock(); | |||||
| }, | |||||
| onError: () => { | |||||
| releasePayLock(); | |||||
| } | } | ||||
| }); | }); | ||||
| }; | }; | ||||
| const closeHandle = () => () =>{ | const closeHandle = () => () =>{ | ||||
| releasePayLock(); | |||||
| resetForm() | resetForm() | ||||
| props.setOpen(false) | props.setOpen(false) | ||||
| }; | }; | ||||
| @@ -261,6 +283,12 @@ const MultiPaymentWindow = (props) => { | |||||
| } | } | ||||
| }, [availableMethodData]); | }, [availableMethodData]); | ||||
| useEffect(() => { | |||||
| if (!props.confirmPayment) { | |||||
| releasePayLock(); | |||||
| } | |||||
| }, [props.confirmPayment]); | |||||
| const formik = useFormik({ | const formik = useFormik({ | ||||
| initialValues: ({ | initialValues: ({ | ||||
| username: '', | username: '', | ||||
| @@ -272,28 +300,26 @@ const MultiPaymentWindow = (props) => { | |||||
| return ( | return ( | ||||
| <Dialog | <Dialog | ||||
| open={props.open} | open={props.open} | ||||
| onClose={() => props.setOpen(false)} | |||||
| onClose={closeHandle()} | |||||
| fullWidth={true} | fullWidth={true} | ||||
| maxWidth={'xl'} | maxWidth={'xl'} | ||||
| fullScreen={props.isFullScreen} | fullScreen={props.isFullScreen} | ||||
| > | > | ||||
| <DialogTitle > | |||||
| <Grid container> | |||||
| <Grid item> | |||||
| <Stack direction="column" justifyContent="flex-start" alignItems="center"> | |||||
| <Typography variant="h4"> | |||||
| {windowTitle} | |||||
| </Typography> | |||||
| </Stack> | |||||
| </Grid> | |||||
| </Grid> | |||||
| <DialogTitle sx={{ pb: 1, pt: 2 }}> | |||||
| <Typography variant="h4"> | |||||
| {windowTitle} | |||||
| </Typography> | |||||
| </DialogTitle> | </DialogTitle> | ||||
| <FormikProvider value={formik}> | <FormikProvider value={formik}> | ||||
| <form> | <form> | ||||
| <DialogContent> | |||||
| <DialogContentText> | |||||
| <FormLabel sx={{ fontSize: "20px", color: "#000000", textAlign: "left", width: "100%", whiteSpace: "pre-line", paddingLeft: 0 }}> | |||||
| <FormattedMessage id="paymentProcessLimited"/> | |||||
| <DialogContent sx={{ pt: 1 }}> | |||||
| <DialogContentText component="div" sx={{ m: 0 }}> | |||||
| <FormLabel component="div" sx={{ fontSize: "18px", color: "#000000", textAlign: "left", width: "100%", paddingLeft: 0, mb: 0 }}> | |||||
| <Box component="ul" sx={{ m: 0, pl: 2.5 }}> | |||||
| <li><FormattedMessage id="paymentProcessLimited1"/></li> | |||||
| <li><FormattedMessage id="paymentProcessLimited2"/></li> | |||||
| <li><FormattedMessage id="paymentProcessLimited3"/></li> | |||||
| </Box> | |||||
| </FormLabel> | </FormLabel> | ||||
| <Grid item xs={12} md={12} sx={{ pt: 2 }} style={{ height: '100%' }} width="100%"> | <Grid item xs={12} md={12} sx={{ pt: 2 }} style={{ height: '100%' }} width="100%"> | ||||
| <Box xs={12} md={12} sx={{ p: 4, border: '3px solid #eee', borderRadius: '10px' }} > | <Box xs={12} md={12} sx={{ p: 4, border: '3px solid #eee', borderRadius: '10px' }} > | ||||
| @@ -320,27 +346,27 @@ const MultiPaymentWindow = (props) => { | |||||
| </Grid> | </Grid> | ||||
| <Grid item sx={{display: { sm: 'block', md: 'none' }}}></Grid> | <Grid item sx={{display: { sm: 'block', md: 'none' }}}></Grid> | ||||
| <Grid item> | <Grid item> | ||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("FPS")} disabled={props.fpsStatus.active === "N"}> | |||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("FPS")} disabled={props.fpsStatus.active === "N" || isPaying}> | |||||
| <img className={fpsClass} src={FpsIcon} width="80" height="80" alt="FPS"></img> | <img className={fpsClass} src={FpsIcon} width="80" height="80" alt="FPS"></img> | ||||
| </Button> | </Button> | ||||
| </Grid> | </Grid> | ||||
| <Grid item> | <Grid item> | ||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("Visa")} disabled={props.creditCardStatus.active === "N"}> | |||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("Visa")} disabled={props.creditCardStatus.active === "N" || isPaying}> | |||||
| <img className={visaClass} src={VisaIcon} width="80" height="80" alt="Visa"></img> | <img className={visaClass} src={VisaIcon} width="80" height="80" alt="Visa"></img> | ||||
| </Button> | </Button> | ||||
| </Grid> | </Grid> | ||||
| <Grid item> | <Grid item> | ||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("MasterCard")} disabled={props.creditCardStatus.active === "N"}> | |||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("MasterCard")} disabled={props.creditCardStatus.active === "N" || isPaying}> | |||||
| <img className={mastercardClass} src={MasterIcon} width="80" height="80" alt="MasterCard"></img> | <img className={mastercardClass} src={MasterIcon} width="80" height="80" alt="MasterCard"></img> | ||||
| </Button> | </Button> | ||||
| </Grid> | </Grid> | ||||
| <Grid item> | <Grid item> | ||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("UnionPay")} disabled={props.unionPayStatus.active === "N"}> | |||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("UnionPay")} disabled={props.unionPayStatus.active === "N" || isPaying}> | |||||
| <img className={unionPayClass} src={UnionPayIcon} width="80" height="80" alt="UnionPay"></img> | <img className={unionPayClass} src={UnionPayIcon} width="80" height="80" alt="UnionPay"></img> | ||||
| </Button> | </Button> | ||||
| </Grid> | </Grid> | ||||
| <Grid item> | <Grid item> | ||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("JCB")} disabled={props.unionPayStatus.active === "N"}> | |||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("JCB")} disabled={props.unionPayStatus.active === "N" || isPaying}> | |||||
| <img className={jCBClass} src={JcbIcon} width="80" height="80" alt="JCB"></img> | <img className={jCBClass} src={JcbIcon} width="80" height="80" alt="JCB"></img> | ||||
| </Button> | </Button> | ||||
| </Grid> | </Grid> | ||||
| @@ -348,7 +374,7 @@ const MultiPaymentWindow = (props) => { | |||||
| {props.browserType==mobileBrowser? | {props.browserType==mobileBrowser? | ||||
| null | null | ||||
| : | : | ||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("PPS")} disabled={props.ppsStatus.active === "N"}> | |||||
| <Button variant="contained" color="white" onClick={selectedPaymentMethodHandle("PPS")} disabled={props.ppsStatus.active === "N" || isPaying}> | |||||
| <img className={pPSClass} src={PpsIcon} width="80" height="80" alt="PPS"></img> | <img className={pPSClass} src={PpsIcon} width="80" height="80" alt="PPS"></img> | ||||
| </Button> | </Button> | ||||
| } | } | ||||
| @@ -449,10 +475,10 @@ const MultiPaymentWindow = (props) => { | |||||
| </DialogActions> | </DialogActions> | ||||
| <DialogActions> | <DialogActions> | ||||
| { | { | ||||
| props.onPayment? | |||||
| props.onPayment || isPaying? | |||||
| <LoadingComponent disableText={true} alignItems="flex-start"/> | <LoadingComponent disableText={true} alignItems="flex-start"/> | ||||
| : | : | ||||
| <Button variant="contained" onClick={confirmPaymentHandle()} disabled={paymentMethod === "" || isLimit || isPPSLimit} sx={PAY_CONTAINED_BUTTON_SX}> | |||||
| <Button variant="contained" onClick={confirmPaymentHandle()} disabled={paymentMethod === "" || isLimit || isPPSLimit || isPaying} sx={PAY_CONTAINED_BUTTON_SX}> | |||||
| <FormattedMessage id="pay"/> | <FormattedMessage id="pay"/> | ||||
| </Button> | </Button> | ||||
| } | } | ||||
| @@ -194,7 +194,7 @@ export default function SearchPaymentTable({ searchCriteria, applyGridOnReady, a | |||||
| <div style={{ width: '100%' }}> | <div style={{ width: '100%' }}> | ||||
| <FiDataGrid | <FiDataGrid | ||||
| sx={_sx} | sx={_sx} | ||||
| rowHeight={80} | |||||
| getRowHeight={() => 'auto'} | |||||
| columns={columns} | columns={columns} | ||||
| customPageSize={10} | customPageSize={10} | ||||
| onRowDoubleClick={handleEditClick} | onRowDoubleClick={handleEditClick} | ||||
| @@ -18,6 +18,21 @@ import dayjs from "dayjs"; | |||||
| import {DemoItem} from "@mui/x-date-pickers/internals/demo"; | import {DemoItem} from "@mui/x-date-pickers/internals/demo"; | ||||
| import {LocalizationProvider} from "@mui/x-date-pickers/LocalizationProvider"; | import {LocalizationProvider} from "@mui/x-date-pickers/LocalizationProvider"; | ||||
| import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; | import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| const getDefaultDateFrom = () => DateUtils.dateValue(new Date().setDate(new Date().getDate() - 14)); | |||||
| const getDefaultDateTo = () => DateUtils.dateValue(new Date()); | |||||
| const isSubmitDateEmpty = (value) => | |||||
| value == null || value === "" || value === "dd / mm / yyyy"; | |||||
| const isBlank = (value) => value == null || String(value).trim() === ""; | |||||
| const toDayjsOrNull = (value) => { | |||||
| if (isSubmitDateEmpty(value)) return null; | |||||
| const d = dayjs(value); | |||||
| return d.isValid() ? d : null; | |||||
| }; | |||||
| // ==============================|| DASHBOARD - DEFAULT ||============================== // | // ==============================|| DASHBOARD - DEFAULT ||============================== // | ||||
| const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => { | const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => { | ||||
| @@ -25,12 +40,11 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => | |||||
| const [maxDate, setMaxDate] = React.useState(searchCriteria.dateTo); | const [maxDate, setMaxDate] = React.useState(searchCriteria.dateTo); | ||||
| const [status, setStatus] = React.useState(ComboData.paymentStatus[0]); | const [status, setStatus] = React.useState(ComboData.paymentStatus[0]); | ||||
| const [payMethod, setPayMethod] = React.useState(ComboData.payMethod[0]); | const [payMethod, setPayMethod] = React.useState(ComboData.payMethod[0]); | ||||
| const { reset, register, handleSubmit } = useForm() | |||||
| const marginBottom = 2.5; | const marginBottom = 2.5; | ||||
| const [fromDateValue, setFromDateValue] = React.useState("dd / mm / yyyy"); | const [fromDateValue, setFromDateValue] = React.useState("dd / mm / yyyy"); | ||||
| const [toDateValue, setToDateValue] = React.useState("dd / mm / yyyy"); | const [toDateValue, setToDateValue] = React.useState("dd / mm / yyyy"); | ||||
| const prevHasOtherRef = React.useRef(null); | |||||
| React.useEffect(() => { | React.useEffect(() => { | ||||
| if(searchCriteria.status!=undefined){ | if(searchCriteria.status!=undefined){ | ||||
| @@ -72,20 +86,77 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => | |||||
| setToDateValue(maxDate); | setToDateValue(maxDate); | ||||
| }, [maxDate]); | }, [maxDate]); | ||||
| const { reset, register, handleSubmit, watch } = useForm({ | |||||
| defaultValues: { | |||||
| code: searchCriteria.code || "", | |||||
| transNo: searchCriteria.transNo || "" | |||||
| } | |||||
| }); | |||||
| const code = watch("code"); | |||||
| const transNo = watch("transNo"); | |||||
| // add near the top inside the component (after useState for payMethod) | // add near the top inside the component (after useState for payMethod) | ||||
| const toPayMethodArray = (opt) => { | const toPayMethodArray = (opt) => { | ||||
| if (!opt || opt.type === 'all') return []; | if (!opt || opt.type === 'all') return []; | ||||
| return Array.isArray(opt.type) ? opt.type : [opt.type]; | return Array.isArray(opt.type) ? opt.type : [opt.type]; | ||||
| }; | }; | ||||
| const clearSubmitDates = () => { | |||||
| setMinDate(null); | |||||
| setMaxDate(null); | |||||
| }; | |||||
| const restoreDefaultSubmitDates = () => { | |||||
| setMinDate(getDefaultDateFrom()); | |||||
| setMaxDate(getDefaultDateTo()); | |||||
| }; | |||||
| const hasOtherCriteria = (textFields = {}) => { | |||||
| if (!isBlank(textFields.code)) return true; | |||||
| if (!isBlank(textFields.transNo)) return true; | |||||
| if (status?.type && status.type !== "all" && status.type !== "") return true; | |||||
| if (payMethod?.type && payMethod.type !== "all") return true; | |||||
| return false; | |||||
| }; | |||||
| React.useEffect(() => { | |||||
| const hasOther = hasOtherCriteria({ code, transNo }); | |||||
| if (prevHasOtherRef.current === null) { | |||||
| prevHasOtherRef.current = hasOther; | |||||
| return; | |||||
| } | |||||
| if (hasOther && !prevHasOtherRef.current) { | |||||
| clearSubmitDates(); | |||||
| } else if (!hasOther && prevHasOtherRef.current) { | |||||
| // Only refill defaults when From or To is empty; keep user-entered dates otherwise | |||||
| if (isSubmitDateEmpty(minDate) || isSubmitDateEmpty(maxDate)) { | |||||
| restoreDefaultSubmitDates(); | |||||
| } | |||||
| } | |||||
| prevHasOtherRef.current = hasOther; | |||||
| }, [code, transNo, status, payMethod]); | |||||
| const onSubmit = (data) => { | const onSubmit = (data) => { | ||||
| let sentDateFrom = ""; | let sentDateFrom = ""; | ||||
| let sentDateTo = ""; | let sentDateTo = ""; | ||||
| if (fromDateValue != "dd / mm / yyyy" && toDateValue != "dd / mm / yyyy") { | |||||
| sentDateFrom = DateUtils.dateValue(fromDateValue) | |||||
| sentDateTo = DateUtils.dateValue(toDateValue) | |||||
| const hasOther = hasOtherCriteria({ | |||||
| code: data.code, | |||||
| transNo: data.transNo | |||||
| }); | |||||
| const datesEmpty = isSubmitDateEmpty(fromDateValue) || isSubmitDateEmpty(toDateValue) | |||||
| || minDate == null || maxDate == null; | |||||
| if (!hasOther && datesEmpty) { | |||||
| const dateFrom = getDefaultDateFrom(); | |||||
| const dateTo = getDefaultDateTo(); | |||||
| setMinDate(dateFrom); | |||||
| setMaxDate(dateTo); | |||||
| sentDateFrom = dateFrom; | |||||
| sentDateTo = dateTo; | |||||
| } else if (!datesEmpty) { | |||||
| sentDateFrom = DateUtils.dateValue(fromDateValue); | |||||
| sentDateTo = DateUtils.dateValue(toDateValue); | |||||
| } | } | ||||
| const temp = { | const temp = { | ||||
| @@ -98,18 +169,36 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => | |||||
| start:0, | start:0, | ||||
| limit:10 | limit:10 | ||||
| }; | }; | ||||
| if (searchCriteria?.sort && searchCriteria?.direction) { | |||||
| temp.sort = searchCriteria.sort; | |||||
| temp.direction = searchCriteria.direction; | |||||
| } | |||||
| applySearch(temp); | applySearch(temp); | ||||
| }; | }; | ||||
| function resetForm() { | function resetForm() { | ||||
| setStatus(ComboData.paymentStatus[0]); | setStatus(ComboData.paymentStatus[0]); | ||||
| setMinDate(DateUtils.dateValue(new Date().setDate(new Date().getDate()-14))) | |||||
| setMaxDate(DateUtils.dateValue(new Date())) | |||||
| setPayMethod(ComboData.payMethod[0]); | |||||
| const dateFrom = getDefaultDateFrom(); | |||||
| const dateTo = getDefaultDateTo(); | |||||
| setMinDate(dateFrom); | |||||
| setMaxDate(dateTo); | |||||
| reset({ | reset({ | ||||
| code:"", | code:"", | ||||
| transNo:"" | transNo:"" | ||||
| }); | }); | ||||
| localStorage.setItem('searchCriteria',"") | |||||
| prevHasOtherRef.current = false; | |||||
| localStorage.setItem('searchCriteria',""); | |||||
| applySearch({ | |||||
| code: "", | |||||
| transNo: "", | |||||
| dateFrom, | |||||
| dateTo, | |||||
| status: "", | |||||
| payMethod: [], | |||||
| start: 0, | |||||
| limit: 10 | |||||
| }); | |||||
| } | } | ||||
| @@ -150,22 +239,21 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => | |||||
| <DemoItem components={['DatePicker']}> | <DemoItem components={['DatePicker']}> | ||||
| <DatePicker | <DatePicker | ||||
| id="dateFrom" | id="dateFrom" | ||||
| // onError={(newError) => setReceiptFromError(newError)} | |||||
| onError={() => {}} | |||||
| slotProps={{ | slotProps={{ | ||||
| field: { readOnly: true, }, | |||||
| // textField: { | |||||
| // helperText: receiptFromErrorMessage, | |||||
| // }, | |||||
| field: { readOnly: true, clearable: true }, | |||||
| textField: { | |||||
| InputLabelProps: { shrink: true }, | |||||
| error: false, | |||||
| helperText: null | |||||
| }, | |||||
| }} | }} | ||||
| format="DD/MM/YYYY" | format="DD/MM/YYYY" | ||||
| label="Payment Date (From)" | label="Payment Date (From)" | ||||
| value={minDate === null ? null : dayjs(minDate)} | |||||
| maxDate={maxDate === null ? null : dayjs(maxDate)} | |||||
| value={toDayjsOrNull(minDate)} | |||||
| maxDate={toDayjsOrNull(maxDate)} | |||||
| onChange={(newValue) => { | onChange={(newValue) => { | ||||
| // console.log(newValue) | |||||
| if(newValue!=null){ | |||||
| setMinDate(newValue); | |||||
| } | |||||
| setMinDate(newValue && newValue.isValid?.() ? newValue : null); | |||||
| }} | }} | ||||
| /> | /> | ||||
| </DemoItem > | </DemoItem > | ||||
| @@ -176,22 +264,21 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => | |||||
| <DemoItem components={['DatePicker']}> | <DemoItem components={['DatePicker']}> | ||||
| <DatePicker | <DatePicker | ||||
| id="dateTo" | id="dateTo" | ||||
| // onError={(newError) => setReceiptFromError(newError)} | |||||
| onError={() => {}} | |||||
| slotProps={{ | slotProps={{ | ||||
| field: { readOnly: true, }, | |||||
| // textField: { | |||||
| // helperText: receiptFromErrorMessage, | |||||
| // }, | |||||
| field: { readOnly: true, clearable: true }, | |||||
| textField: { | |||||
| InputLabelProps: { shrink: true }, | |||||
| error: false, | |||||
| helperText: null | |||||
| }, | |||||
| }} | }} | ||||
| format="DD/MM/YYYY" | format="DD/MM/YYYY" | ||||
| label="Payment Date (To)" | label="Payment Date (To)" | ||||
| value={maxDate === null ? null : dayjs(maxDate)} | |||||
| minDate={minDate === null ? null : dayjs(minDate)} | |||||
| value={toDayjsOrNull(maxDate)} | |||||
| minDate={toDayjsOrNull(minDate)} | |||||
| onChange={(newValue) => { | onChange={(newValue) => { | ||||
| // console.log(newValue) | |||||
| if(newValue!=null){ | |||||
| setMaxDate(newValue); | |||||
| } | |||||
| setMaxDate(newValue && newValue.isValid?.() ? newValue : null); | |||||
| }} | }} | ||||
| /> | /> | ||||
| </DemoItem > | </DemoItem > | ||||
| @@ -119,7 +119,7 @@ export default function SearchPublicNoticeTable({ searchCriteria, applyGridOnRea | |||||
| <div style={{ minHeight: 400, width: '100%' }}> | <div style={{ minHeight: 400, width: '100%' }}> | ||||
| <FiDataGrid | <FiDataGrid | ||||
| sx={_sx} | sx={_sx} | ||||
| rowHeight={80} | |||||
| getRowHeight={() => 'auto'} | |||||
| columns={columns} | columns={columns} | ||||
| customPageSize={10} | customPageSize={10} | ||||
| onRowDoubleClick={handleEditDoubleClick} | onRowDoubleClick={handleEditDoubleClick} | ||||
| @@ -20,6 +20,21 @@ import dayjs from "dayjs"; | |||||
| import {DemoItem} from "@mui/x-date-pickers/internals/demo"; | import {DemoItem} from "@mui/x-date-pickers/internals/demo"; | ||||
| import {LocalizationProvider} from "@mui/x-date-pickers/LocalizationProvider"; | import {LocalizationProvider} from "@mui/x-date-pickers/LocalizationProvider"; | ||||
| import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; | import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| const getDefaultDateFrom = () => DateUtils.dateValue(new Date().setDate(new Date().getDate() - 14)); | |||||
| const getDefaultDateTo = () => DateUtils.dateValue(new Date()); | |||||
| const isSubmitDateEmpty = (value) => | |||||
| value == null || value === "" || value === "dd / mm / yyyy"; | |||||
| const isBlank = (value) => value == null || String(value).trim() === ""; | |||||
| const toDayjsOrNull = (value) => { | |||||
| if (isSubmitDateEmpty(value)) return null; | |||||
| const d = dayjs(value); | |||||
| return d.isValid() ? d : null; | |||||
| }; | |||||
| // ==============================|| DASHBOARD - DEFAULT ||============================== // | // ==============================|| DASHBOARD - DEFAULT ||============================== // | ||||
| const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => { | const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => { | ||||
| const intl = useIntl(); | const intl = useIntl(); | ||||
| @@ -29,9 +44,9 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => | |||||
| const [fromDateValue, setFromDateValue] = React.useState("dd / mm / yyyy"); | const [fromDateValue, setFromDateValue] = React.useState("dd / mm / yyyy"); | ||||
| const [toDateValue, setToDateValue] = React.useState("dd / mm / yyyy"); | const [toDateValue, setToDateValue] = React.useState("dd / mm / yyyy"); | ||||
| const prevHasOtherRef = React.useRef(null); | |||||
| React.useEffect(() => { | React.useEffect(() => { | ||||
| // console.log(minDate) | |||||
| setFromDateValue(minDate); | setFromDateValue(minDate); | ||||
| }, [minDate]); | }, [minDate]); | ||||
| @@ -55,15 +70,72 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => | |||||
| } | } | ||||
| } | } | ||||
| const { reset, register, handleSubmit } = useForm() | |||||
| const { reset, register, handleSubmit, watch } = useForm({ | |||||
| defaultValues: { | |||||
| code: searchCriteria.code || "", | |||||
| transNo: searchCriteria.transNo || "" | |||||
| } | |||||
| }); | |||||
| const code = watch("code"); | |||||
| const transNo = watch("transNo"); | |||||
| const clearSubmitDates = () => { | |||||
| setMinDate(null); | |||||
| setMaxDate(null); | |||||
| }; | |||||
| const restoreDefaultSubmitDates = () => { | |||||
| setMinDate(getDefaultDateFrom()); | |||||
| setMaxDate(getDefaultDateTo()); | |||||
| }; | |||||
| const hasOtherCriteria = (textFields = {}) => { | |||||
| if (!isBlank(textFields.code)) return true; | |||||
| if (!isBlank(textFields.transNo)) return true; | |||||
| if (status?.type && status.type !== "all" && status.type !== "") return true; | |||||
| return false; | |||||
| }; | |||||
| React.useEffect(() => { | |||||
| const hasOther = hasOtherCriteria({ code, transNo }); | |||||
| if (prevHasOtherRef.current === null) { | |||||
| prevHasOtherRef.current = hasOther; | |||||
| return; | |||||
| } | |||||
| if (hasOther && !prevHasOtherRef.current) { | |||||
| clearSubmitDates(); | |||||
| } else if (!hasOther && prevHasOtherRef.current) { | |||||
| // Only refill defaults when From or To is empty; keep user-entered dates otherwise | |||||
| if (isSubmitDateEmpty(minDate) || isSubmitDateEmpty(maxDate)) { | |||||
| restoreDefaultSubmitDates(); | |||||
| } | |||||
| } | |||||
| prevHasOtherRef.current = hasOther; | |||||
| }, [code, transNo, status]); | |||||
| const onSubmit = (data) => { | const onSubmit = (data) => { | ||||
| let sentDateFrom = ""; | let sentDateFrom = ""; | ||||
| let sentDateTo = ""; | let sentDateTo = ""; | ||||
| if( fromDateValue!="dd / mm / yyyy"&&toDateValue!="dd / mm / yyyy"){ | |||||
| sentDateFrom = DateUtils.dateValue(fromDateValue) | |||||
| sentDateTo = DateUtils.dateValue(toDateValue) | |||||
| const hasOther = hasOtherCriteria({ | |||||
| code: data.code, | |||||
| transNo: data.transNo | |||||
| }); | |||||
| const datesEmpty = isSubmitDateEmpty(fromDateValue) || isSubmitDateEmpty(toDateValue) | |||||
| || minDate == null || maxDate == null; | |||||
| if (!hasOther && datesEmpty) { | |||||
| const dateFrom = getDefaultDateFrom(); | |||||
| const dateTo = getDefaultDateTo(); | |||||
| setMinDate(dateFrom); | |||||
| setMaxDate(dateTo); | |||||
| sentDateFrom = dateFrom; | |||||
| sentDateTo = dateTo; | |||||
| } else if (!datesEmpty) { | |||||
| sentDateFrom = DateUtils.dateValue(fromDateValue); | |||||
| sentDateTo = DateUtils.dateValue(toDateValue); | |||||
| } | } | ||||
| const temp = { | const temp = { | ||||
| code: data.code, | code: data.code, | ||||
| transNo: data.transNo, | transNo: data.transNo, | ||||
| @@ -73,18 +145,34 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => | |||||
| start:0, | start:0, | ||||
| limit:10 | limit:10 | ||||
| }; | }; | ||||
| if (searchCriteria?.sort && searchCriteria?.direction) { | |||||
| temp.sort = searchCriteria.sort; | |||||
| temp.direction = searchCriteria.direction; | |||||
| } | |||||
| applySearch(temp); | applySearch(temp); | ||||
| }; | }; | ||||
| function resetForm() { | function resetForm() { | ||||
| setStatus(ComboData.paymentStatus[0]); | setStatus(ComboData.paymentStatus[0]); | ||||
| setMinDate(DateUtils.dateValue(new Date().setDate(new Date().getDate()-14))) | |||||
| setMaxDate(DateUtils.dateValue(new Date())) | |||||
| const dateFrom = getDefaultDateFrom(); | |||||
| const dateTo = getDefaultDateTo(); | |||||
| setMinDate(dateFrom); | |||||
| setMaxDate(dateTo); | |||||
| reset({ | reset({ | ||||
| code:"", | code:"", | ||||
| transNo:"" | transNo:"" | ||||
| }); | }); | ||||
| localStorage.setItem('searchCriteria',"") | |||||
| prevHasOtherRef.current = false; | |||||
| localStorage.setItem('searchCriteria',""); | |||||
| applySearch({ | |||||
| code: "", | |||||
| transNo: "", | |||||
| dateFrom, | |||||
| dateTo, | |||||
| status: "", | |||||
| start: 0, | |||||
| limit: 10 | |||||
| }); | |||||
| } | } | ||||
| @@ -128,23 +216,21 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => | |||||
| <DemoItem components={['DatePicker']}> | <DemoItem components={['DatePicker']}> | ||||
| <DatePicker | <DatePicker | ||||
| id="dateFrom" | id="dateFrom" | ||||
| // onError={(newError) => setReceiptFromError(newError)} | |||||
| onError={() => {}} | |||||
| slotProps={{ | slotProps={{ | ||||
| field: { readOnly: true, }, | |||||
| // textField: { | |||||
| // helperText: receiptFromErrorMessage, | |||||
| // }, | |||||
| field: { readOnly: true, clearable: true }, | |||||
| textField: { | |||||
| InputLabelProps: { shrink: true }, | |||||
| error: false, | |||||
| helperText: null | |||||
| }, | |||||
| }} | }} | ||||
| format="DD/MM/YYYY" | format="DD/MM/YYYY" | ||||
| label={intl.formatMessage({id: 'payDateFrom'})} | label={intl.formatMessage({id: 'payDateFrom'})} | ||||
| // defaultValue={searchCriteria.dateFrom} | |||||
| value={minDate === null ? null : dayjs(minDate)} | |||||
| maxDate={maxDate === null ? null : dayjs(maxDate)} | |||||
| value={toDayjsOrNull(minDate)} | |||||
| maxDate={toDayjsOrNull(maxDate)} | |||||
| onChange={(newValue) => { | onChange={(newValue) => { | ||||
| // console.log(newValue) | |||||
| if(newValue!=null){ | |||||
| setMinDate(newValue); | |||||
| } | |||||
| setMinDate(newValue && newValue.isValid?.() ? newValue : null); | |||||
| }} | }} | ||||
| /> | /> | ||||
| </DemoItem > | </DemoItem > | ||||
| @@ -156,23 +242,21 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, onGridReady }) => | |||||
| <DemoItem components={['DatePicker']}> | <DemoItem components={['DatePicker']}> | ||||
| <DatePicker | <DatePicker | ||||
| id="dateTo" | id="dateTo" | ||||
| // onError={(newError) => setReceiptFromError(newError)} | |||||
| onError={() => {}} | |||||
| slotProps={{ | slotProps={{ | ||||
| field: { readOnly: true, }, | |||||
| // textField: { | |||||
| // helperText: receiptFromErrorMessage, | |||||
| // }, | |||||
| field: { readOnly: true, clearable: true }, | |||||
| textField: { | |||||
| InputLabelProps: { shrink: true }, | |||||
| error: false, | |||||
| helperText: null | |||||
| }, | |||||
| }} | }} | ||||
| format="DD/MM/YYYY" | format="DD/MM/YYYY" | ||||
| label={intl.formatMessage({id: 'payDateTo'})} | label={intl.formatMessage({id: 'payDateTo'})} | ||||
| // defaultValue={searchCriteria.dateTo} | |||||
| value={maxDate === null ? null : dayjs(maxDate)} | |||||
| minDate={minDate === null ? null : dayjs(minDate)} | |||||
| value={toDayjsOrNull(maxDate)} | |||||
| minDate={toDayjsOrNull(minDate)} | |||||
| onChange={(newValue) => { | onChange={(newValue) => { | ||||
| // console.log(newValue) | |||||
| if(newValue!=null){ | |||||
| setMaxDate(newValue); | |||||
| } | |||||
| setMaxDate(newValue && newValue.isValid?.() ? newValue : null); | |||||
| }} | }} | ||||
| /> | /> | ||||
| </DemoItem > | </DemoItem > | ||||
| @@ -435,6 +435,7 @@ const Index = () => { | |||||
| // const confirmPaymentHandle = () => () => { | // const confirmPaymentHandle = () => () => { | ||||
| useEffect(() => { | useEffect(() => { | ||||
| if (confirmPayment){ | if (confirmPayment){ | ||||
| setOnPayment(true); | |||||
| HttpUtils.post({ | HttpUtils.post({ | ||||
| url: UrlUtils.POST_CHECK_APP_EXPRITY_DATE, | url: UrlUtils.POST_CHECK_APP_EXPRITY_DATE, | ||||
| params: { | params: { | ||||
| @@ -445,12 +446,22 @@ const Index = () => { | |||||
| setAfterConfirmPayment(true); | setAfterConfirmPayment(true); | ||||
| return; | return; | ||||
| } | } | ||||
| setOnPayment(false); | |||||
| setConfirmPayment(false); | |||||
| let str = ""; | let str = ""; | ||||
| responData.msg.forEach((item) => { | responData.msg.forEach((item) => { | ||||
| str += "App: " + item.appNo + ", 到期日: " + DateUtils.datetimeStr_Cht(item.expiryDate) + "\n"; | str += "App: " + item.appNo + ", 到期日: " + DateUtils.datetimeStr_Cht(item.expiryDate) + "\n"; | ||||
| }); | }); | ||||
| setExpiryDateErrText(str.split('\n').map(str => <>{str}<br/></>)); | setExpiryDateErrText(str.split('\n').map(str => <>{str}<br/></>)); | ||||
| setExpiryDateErr(true); | setExpiryDateErr(true); | ||||
| }, | |||||
| onFail: () => { | |||||
| setOnPayment(false); | |||||
| setConfirmPayment(false); | |||||
| }, | |||||
| onError: () => { | |||||
| setOnPayment(false); | |||||
| setConfirmPayment(false); | |||||
| } | } | ||||
| }); | }); | ||||
| } | } | ||||
| @@ -493,7 +504,7 @@ const Index = () => { | |||||
| onClick={() => paymentClick()} | onClick={() => paymentClick()} | ||||
| sx={{ mt: 4, ...PAY_CONTAINED_BUTTON_SX }} | sx={{ mt: 4, ...PAY_CONTAINED_BUTTON_SX }} | ||||
| > | > | ||||
| <FormattedMessage id="selectPaymentMethodBtn"/> | |||||
| <FormattedMessage id="pay"/> | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| component="span" | component="span" | ||||
| @@ -551,6 +562,7 @@ const Index = () => { | |||||
| setSelectedPaymentMethod={setSelectedPaymentMethod} | setSelectedPaymentMethod={setSelectedPaymentMethod} | ||||
| selectedPaymentMethod={selectedPaymentMethod} | selectedPaymentMethod={selectedPaymentMethod} | ||||
| setConfirmPayment={setConfirmPayment} | setConfirmPayment={setConfirmPayment} | ||||
| confirmPayment={confirmPayment} | |||||
| getMethodImgClass = {getMethodImgClass} | getMethodImgClass = {getMethodImgClass} | ||||
| onReady = {onReady} | onReady = {onReady} | ||||
| locale = {locale} | locale = {locale} | ||||
| @@ -146,7 +146,6 @@ export default function SearchPublicNoticeTable({searchCriteria, applyGridOnRead | |||||
| applyGridOnReady={applyGridOnReady} | applyGridOnReady={applyGridOnReady} | ||||
| applySearch = {applySearch} | applySearch = {applySearch} | ||||
| serverSorting | serverSorting | ||||
| disablePagingOnGazetteIssue={false} | |||||
| doLoad={React.useMemo(() => ({ | doLoad={React.useMemo(() => ({ | ||||
| url: LIST_PROOF, | url: LIST_PROOF, | ||||
| params: _searchCriteria, | params: _searchCriteria, | ||||
| @@ -161,6 +161,11 @@ const SearchPublicNoticeForm = ({ applySearch, searchCriteria, issueComboData, o | |||||
| limit: 10, | limit: 10, | ||||
| }; | }; | ||||
| if (issueSelected?.id) { | |||||
| delete temp.start; | |||||
| delete temp.limit; | |||||
| } | |||||
| if (searchCriteria?.sort && searchCriteria?.direction) { | if (searchCriteria?.sort && searchCriteria?.direction) { | ||||
| temp.sort = searchCriteria.sort; | temp.sort = searchCriteria.sort; | ||||
| temp.direction = searchCriteria.direction; | temp.direction = searchCriteria.direction; | ||||
| @@ -137,8 +137,14 @@ export default function SearchPublicNoticeTable({ searchCriteria, applyGridOnRea | |||||
| company = company + ": " + params.row.custName | company = company + ": " + params.row.custName | ||||
| } | } | ||||
| let paymentMethod = params.row.paymentMethod!=null?intl.formatMessage({ id: utils.getPaymentMethod(params.row.paymentMethod)}):"" | let paymentMethod = params.row.paymentMethod!=null?intl.formatMessage({ id: utils.getPaymentMethod(params.row.paymentMethod)}):"" | ||||
| const lines = [params?.value, company, paymentMethod].filter(line => line != null && line !== ""); | |||||
| return (<> | return (<> | ||||
| {params?.value}<br />{company} <br/>{paymentMethod} | |||||
| {lines.map((line, index) => ( | |||||
| <React.Fragment key={index}> | |||||
| {index > 0 && <br />} | |||||
| {line} | |||||
| </React.Fragment> | |||||
| ))} | |||||
| </>); | </>); | ||||
| } | } | ||||
| }, | }, | ||||
| @@ -7,7 +7,7 @@ import { | |||||
| } from '@mui/material'; | } from '@mui/material'; | ||||
| import MainCard from "components/MainCard"; | import MainCard from "components/MainCard"; | ||||
| import { useForm } from "react-hook-form"; | import { useForm } from "react-hook-form"; | ||||
| import { useState } from "react"; | |||||
| import { useState,useEffect } from "react"; | |||||
| import {PNSPS_BUTTON_THEME} from "themes/buttonConst"; | import {PNSPS_BUTTON_THEME} from "themes/buttonConst"; | ||||
| import {ThemeProvider} from "@emotion/react"; | import {ThemeProvider} from "@emotion/react"; | ||||
| // ==============================|| DASHBOARD - DEFAULT ||============================== // | // ==============================|| DASHBOARD - DEFAULT ||============================== // | ||||
| @@ -16,15 +16,22 @@ import {ThemeProvider} from "@emotion/react"; | |||||
| const UserSearchForm_Individual = ({ applySearch, onGridReady, searchCriteria }) => { | const UserSearchForm_Individual = ({ applySearch, onGridReady, searchCriteria }) => { | ||||
| const [type, setType] = useState([]); | const [type, setType] = useState([]); | ||||
| const [accountFilter, setAccountFilter] = useState(() => { | |||||
| if (searchCriteria.accountFilter != undefined && searchCriteria.accountFilter !== "") { | |||||
| return searchCriteria.accountFilter; | |||||
| } | |||||
| return "All"; | |||||
| }); | |||||
| const [accountFilter, setAccountFilter] = useState("All"); | |||||
| const { reset, register, handleSubmit } = useForm() | const { reset, register, handleSubmit } = useForm() | ||||
| useEffect(() => { | |||||
| if(searchCriteria.accountFilter!=undefined){ | |||||
| if(searchCriteria.accountFilter === ""){ | |||||
| setAccountFilter("All") | |||||
| }else{ | |||||
| setAccountFilter(searchCriteria.accountFilter) | |||||
| } | |||||
| }else{ | |||||
| setAccountFilter("All") | |||||
| } | |||||
| }, [searchCriteria]); | |||||
| const onSubmit = (data) => { | const onSubmit = (data) => { | ||||
| let typeArray = []; | let typeArray = []; | ||||
| @@ -45,15 +52,6 @@ const UserSearchForm_Individual = ({ applySearch, onGridReady, searchCriteria }) | |||||
| applySearch(temp); | applySearch(temp); | ||||
| }; | }; | ||||
| function onToBeVerified() { | |||||
| applySearch({ | |||||
| accountFilter: "Not Verified", | |||||
| descOrder: true, | |||||
| start: 0, | |||||
| limit: 10 | |||||
| }); | |||||
| } | |||||
| function resetForm() { | function resetForm() { | ||||
| setType([]); | setType([]); | ||||
| setAccountFilter("All"); | setAccountFilter("All"); | ||||
| @@ -180,22 +178,9 @@ const UserSearchForm_Individual = ({ applySearch, onGridReady, searchCriteria }) | |||||
| <Grid container maxWidth justifyContent="flex-end"> | <Grid container maxWidth justifyContent="flex-end"> | ||||
| <ThemeProvider theme={PNSPS_BUTTON_THEME}> | <ThemeProvider theme={PNSPS_BUTTON_THEME}> | ||||
| <Grid item sx={{ ml: 3, mr: 3, mb: 3 }}> | <Grid item sx={{ ml: 3, mr: 3, mb: 3 }}> | ||||
| <Button | |||||
| variant="contained" | |||||
| color="edit" | |||||
| type="button" | |||||
| onClick={onToBeVerified} | |||||
| disabled={onGridReady} | |||||
| > | |||||
| To be verified | |||||
| </Button> | |||||
| </Grid> | |||||
| <Grid item sx={{ mr: 3, mb: 3 }}> | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="cancel" | color="cancel" | ||||
| type="button" | |||||
| onClick={resetForm} | onClick={resetForm} | ||||
| > | > | ||||
| Reset | Reset | ||||
| @@ -91,18 +91,6 @@ export default function UserTable_Individual({ searchCriteria, applyGridOnReady, | |||||
| flex: 1, | flex: 1, | ||||
| minWidth: 150, | minWidth: 150, | ||||
| }, | }, | ||||
| { | |||||
| id: 'created', | |||||
| field: 'created', | |||||
| headerName: 'Created Date', | |||||
| flex: 1, | |||||
| minWidth: 200, | |||||
| valueGetter: (params) => { | |||||
| if (params.value) { | |||||
| return DateUtils.datetimeStr(params.value); | |||||
| } | |||||
| } | |||||
| }, | |||||
| { | { | ||||
| id: 'lastLogin', | id: 'lastLogin', | ||||
| field: 'lastLogin', | field: 'lastLogin', | ||||
| @@ -9,7 +9,7 @@ import MainCard from "../../../components/MainCard"; | |||||
| import OrgComboAutocomplete from "../../../components/OrgComboAutocomplete"; | import OrgComboAutocomplete from "../../../components/OrgComboAutocomplete"; | ||||
| import {useForm} from "react-hook-form"; | import {useForm} from "react-hook-form"; | ||||
| import { useState } from "react"; | |||||
| import { useState, useEffect} from "react"; | |||||
| import {PNSPS_BUTTON_THEME} from "../../../themes/buttonConst"; | import {PNSPS_BUTTON_THEME} from "../../../themes/buttonConst"; | ||||
| import {ThemeProvider} from "@emotion/react"; | import {ThemeProvider} from "@emotion/react"; | ||||
| // ==============================|| DASHBOARD - DEFAULT ||============================== // | // ==============================|| DASHBOARD - DEFAULT ||============================== // | ||||
| @@ -18,15 +18,22 @@ import {ThemeProvider} from "@emotion/react"; | |||||
| const UserSearchForm_Organization = ({applySearch, onGridReady, searchCriteria}) => { | const UserSearchForm_Organization = ({applySearch, onGridReady, searchCriteria}) => { | ||||
| const [type, setType] = useState([]); | const [type, setType] = useState([]); | ||||
| const [accountFilter, setAccountFilter] = useState(() => { | |||||
| if (searchCriteria.accountFilter != undefined && searchCriteria.accountFilter !== "") { | |||||
| return searchCriteria.accountFilter; | |||||
| } | |||||
| return "All"; | |||||
| }); | |||||
| const [accountFilter, setAccountFilter] = useState("All"); | |||||
| const [orgSelected, setOrgSelected] = useState({}); | const [orgSelected, setOrgSelected] = useState({}); | ||||
| const { reset, register, handleSubmit } = useForm() | const { reset, register, handleSubmit } = useForm() | ||||
| useEffect(() => { | |||||
| if(searchCriteria.accountFilter!=undefined){ | |||||
| if(searchCriteria.accountFilter === ""){ | |||||
| setAccountFilter("All") | |||||
| }else{ | |||||
| setAccountFilter(searchCriteria.accountFilter) | |||||
| } | |||||
| }else{ | |||||
| setAccountFilter("All") | |||||
| } | |||||
| }, [searchCriteria]); | |||||
| const onSubmit = (data) => { | const onSubmit = (data) => { | ||||
| @@ -51,15 +58,6 @@ const UserSearchForm_Organization = ({applySearch, onGridReady, searchCriteria}) | |||||
| applySearch(temp); | applySearch(temp); | ||||
| }; | }; | ||||
| function onToBeVerified() { | |||||
| applySearch({ | |||||
| accountFilter: "Not Verified", | |||||
| descOrder: true, | |||||
| start: 0, | |||||
| limit: 10 | |||||
| }); | |||||
| } | |||||
| function resetForm(){ | function resetForm(){ | ||||
| setType([]); | setType([]); | ||||
| setAccountFilter("All"); | setAccountFilter("All"); | ||||
| @@ -213,22 +211,9 @@ const UserSearchForm_Organization = ({applySearch, onGridReady, searchCriteria}) | |||||
| <Grid container maxWidth justifyContent="flex-end"> | <Grid container maxWidth justifyContent="flex-end"> | ||||
| <ThemeProvider theme={PNSPS_BUTTON_THEME}> | <ThemeProvider theme={PNSPS_BUTTON_THEME}> | ||||
| <Grid item sx={{ml:3, mr:3, mb:3}}> | <Grid item sx={{ml:3, mr:3, mb:3}}> | ||||
| <Button | |||||
| variant="contained" | |||||
| color="edit" | |||||
| type="button" | |||||
| onClick={onToBeVerified} | |||||
| disabled={onGridReady} | |||||
| > | |||||
| To be verified | |||||
| </Button> | |||||
| </Grid> | |||||
| <Grid item sx={{mr:3, mb:3}}> | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="cancel" | color="cancel" | ||||
| type="button" | |||||
| onClick={resetForm} | onClick={resetForm} | ||||
| > | > | ||||
| Reset | Reset | ||||
| @@ -85,18 +85,6 @@ export default function UserTable_Organization({searchCriteria, applyGridOnReady | |||||
| flex: 1, | flex: 1, | ||||
| minWidth: 150, | minWidth: 150, | ||||
| }, | }, | ||||
| { | |||||
| id: 'created', | |||||
| field: 'created', | |||||
| headerName: 'Created Date', | |||||
| flex: 1, | |||||
| minWidth: 200, | |||||
| valueGetter:(params)=>{ | |||||
| if(params.value){ | |||||
| return DateUtils.datetimeStr(params.value); | |||||
| } | |||||
| } | |||||
| }, | |||||
| { | { | ||||
| id: 'lastLogin', | id: 'lastLogin', | ||||
| field: 'lastLogin', | field: 'lastLogin', | ||||
| @@ -17,6 +17,7 @@ import { | |||||
| } from '@mui/material'; | } from '@mui/material'; | ||||
| import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined'; | import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined'; | ||||
| import {FormattedMessage} from "react-intl"; | import {FormattedMessage} from "react-intl"; | ||||
| import { PRIMARY_CONTAINED_BUTTON_SX } from "themes/colorConst"; | |||||
| const PasswordAlertDialog = (props) => { | const PasswordAlertDialog = (props) => { | ||||
| return ( | return ( | ||||
| @@ -63,7 +64,7 @@ const PasswordAlertDialog = (props) => { | |||||
| </DialogContentText> | </DialogContentText> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button variant="contained" color="error" onClick={props.handleClose} autoFocus> | |||||
| <Button variant="contained" onClick={props.handleClose} autoFocus sx={PRIMARY_CONTAINED_BUTTON_SX}> | |||||
| <FormattedMessage id="close"/> | <FormattedMessage id="close"/> | ||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| @@ -12,6 +12,7 @@ import { | |||||
| import { isORGLoggedIn, } from "utils/Utils"; | import { isORGLoggedIn, } from "utils/Utils"; | ||||
| import titleBackgroundImg from 'assets/images/dashboard/gazette-bar.png' | import titleBackgroundImg from 'assets/images/dashboard/gazette-bar.png' | ||||
| import { FormattedMessage, useIntl } from "react-intl"; | import { FormattedMessage, useIntl } from "react-intl"; | ||||
| import { ERROR_CONTAINED_BUTTON_SX } from "themes/colorConst"; | |||||
| import AdsClickRoundedIcon from '@mui/icons-material/AdsClickRounded'; | import AdsClickRoundedIcon from '@mui/icons-material/AdsClickRounded'; | ||||
| import * as React from "react"; | import * as React from "react"; | ||||
| import Loadable from 'components/Loadable'; | import Loadable from 'components/Loadable'; | ||||
| @@ -300,7 +301,7 @@ const DashboardDefault = () => { | |||||
| </Typography> | </Typography> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button variant="contained" color="error" onClick={() => setIsPopUp(false)}><Typography variant="h5"><FormattedMessage id="close" /></Typography></Button> | |||||
| <Button variant="contained" onClick={() => setIsPopUp(false)} sx={ERROR_CONTAINED_BUTTON_SX}><Typography variant="h5"><FormattedMessage id="close" /></Typography></Button> | |||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| </div> | </div> | ||||
| @@ -15,8 +15,8 @@ const GroupAuthTable = Loadable(lazy(() => import('./GroupAuthTable'))); | |||||
| const GroupAuthCard = ({isCollectData, updateUserAuthList,userGroupData,isNewRecord, editMode}) => { | const GroupAuthCard = ({isCollectData, updateUserAuthList,userGroupData,isNewRecord, editMode}) => { | ||||
| const [currentAuthData, setCurrentAuthData] = React.useState({}); | const [currentAuthData, setCurrentAuthData] = React.useState({}); | ||||
| const [onReady, setOnReady] = useState(false); | const [onReady, setOnReady] = useState(false); | ||||
| const [selectedRow, setSelectedRow] = useState([]); | |||||
| const [referenceRow, setReferenceRow] = useState([]); | |||||
| const [selectedRow, setSelectedRow] = useState(userGroupData?.authIds || []); | |||||
| const [referenceRow, setReferenceRow] = useState(userGroupData?.authIds || []); | |||||
| const [_editMode, setEditMode] = useState(editMode); | const [_editMode, setEditMode] = useState(editMode); | ||||
| useEffect(()=>{ | useEffect(()=>{ | ||||
| @@ -39,14 +39,22 @@ const GroupAuthCard = ({isCollectData, updateUserAuthList,userGroupData,isNewRec | |||||
| } | } | ||||
| }, [currentAuthData]); | }, [currentAuthData]); | ||||
| const toIdList = (ids) => { | |||||
| if (!ids) { | |||||
| return []; | |||||
| } | |||||
| const raw = Array.isArray(ids) ? ids : (ids.ids ? Array.from(ids.ids) : []); | |||||
| return raw.map((id) => Number(id)).filter((id) => !Number.isNaN(id)); | |||||
| }; | |||||
| useEffect(() => { | useEffect(() => { | ||||
| //upload latest data to parent | |||||
| let deletedList = referenceRow.filter(x => !selectedRow.includes(x)); | |||||
| const currentIds = toIdList(selectedRow); | |||||
| const referenceIds = toIdList(referenceRow); | |||||
| updateUserAuthList({ | updateUserAuthList({ | ||||
| "currentList": selectedRow, | |||||
| "deletedList": deletedList | |||||
| currentList: currentIds, | |||||
| deletedList: referenceIds.filter((id) => !currentIds.includes(id)) | |||||
| }); | }); | ||||
| }, [isCollectData]); | |||||
| }, [isCollectData, selectedRow, referenceRow]); | |||||
| return ( | return ( | ||||
| @@ -98,8 +98,10 @@ export default function GroupAuthTable({setSelectedRow, userAuth,isNewRecord, ed | |||||
| rowSelectionModel={currentSelectedRow} | rowSelectionModel={currentSelectedRow} | ||||
| onRowSelectionModelChange={(ids) => { | onRowSelectionModelChange={(ids) => { | ||||
| if(_editMode){ | if(_editMode){ | ||||
| setSelectedRow(ids); | |||||
| setCurrentSelectedRow(ids); | |||||
| const raw = Array.isArray(ids) ? ids : (ids?.ids ? Array.from(ids.ids) : []); | |||||
| const nextIds = raw.map((id) => Number(id)).filter((id) => !Number.isNaN(id)); | |||||
| setSelectedRow(nextIds); | |||||
| setCurrentSelectedRow(nextIds); | |||||
| } | } | ||||
| }} | }} | ||||
| autoHeight | autoHeight | ||||
| @@ -90,12 +90,11 @@ const UserAddCard = ({ isCollectData, updateGroupMember, userGroupData, isNewRec | |||||
| }, [currentUserData]); | }, [currentUserData]); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| //upload latest data to parent | |||||
| updateGroupMember({ | updateGroupMember({ | ||||
| "currentList": groupUserData, | |||||
| "deletedList": deletedList | |||||
| currentList: groupUserData, | |||||
| deletedList: deletedList | |||||
| }); | }); | ||||
| }, [isCollectData]); | |||||
| }, [isCollectData, groupUserData, deletedList]); | |||||
| return ( | return ( | ||||
| @@ -47,12 +47,12 @@ const UserMaintainPage = () => { | |||||
| const [isCollectData, setIsCollectData] = useState(false); | const [isCollectData, setIsCollectData] = useState(false); | ||||
| const [editedGroupData, setEditedGroupData] = useState({}); | const [editedGroupData, setEditedGroupData] = useState({}); | ||||
| const [userGroupData, setUserGroupData] = useState([]); | const [userGroupData, setUserGroupData] = useState([]); | ||||
| const [userAuthData, setUserAuthData] = useState([]); | |||||
| const saveInProgressRef = useRef(false); | const saveInProgressRef = useRef(false); | ||||
| const [groupMember, setGroupMember] = useState([]); | |||||
| const userAuthDataRef = useRef([]); | |||||
| const deletedAuthListRef = useRef([]); | |||||
| const groupMemberRef = useRef([]); | |||||
| const deletedUserListRef = useRef([]); | |||||
| const [isNewRecord, setIsNewRecord] = useState(false); | const [isNewRecord, setIsNewRecord] = useState(false); | ||||
| const [deletedUserList, setDeletedUserList] = useState([]); | |||||
| const [deletedAuthList, setDeletedAuthList] = useState([]); | |||||
| const [isWindowOpen, setIsWindowOpen] = useState(false); | const [isWindowOpen, setIsWindowOpen] = useState(false); | ||||
| const handleClose = () => { | const handleClose = () => { | ||||
| @@ -84,13 +84,17 @@ const UserMaintainPage = () => { | |||||
| } | } | ||||
| function updateGroupMember(groupMember) { | function updateGroupMember(groupMember) { | ||||
| setGroupMember(groupMember.currentList); | |||||
| setDeletedUserList(groupMember.deletedList); | |||||
| const currentList = groupMember.currentList || []; | |||||
| const deletedList = groupMember.deletedList || []; | |||||
| groupMemberRef.current = currentList; | |||||
| deletedUserListRef.current = deletedList; | |||||
| } | } | ||||
| function updateUserAuthList(userAuthData) { | |||||
| setUserAuthData(userAuthData.currentList); | |||||
| setDeletedAuthList(userAuthData.deletedList); | |||||
| function updateUserAuthList(authData) { | |||||
| const currentList = authData.currentList || []; | |||||
| const deletedList = authData.deletedList || []; | |||||
| userAuthDataRef.current = currentList; | |||||
| deletedAuthListRef.current = deletedList; | |||||
| } | } | ||||
| const submitData = async () => { | const submitData = async () => { | ||||
| @@ -106,19 +110,36 @@ const UserMaintainPage = () => { | |||||
| return; | return; | ||||
| } | } | ||||
| const latestGroupFormData = getLatestGroupFormData(); | const latestGroupFormData = getLatestGroupFormData(); | ||||
| const finalDeletedUserList = getDeletedRecordWithRefList(deletedUserList, getIdList(groupMember)); | |||||
| const latestGroupMember = groupMemberRef.current; | |||||
| const latestAuthIds = userAuthDataRef.current; | |||||
| const latestDeletedAuthIds = deletedAuthListRef.current; | |||||
| const finalDeletedUserList = getDeletedRecordWithRefList( | |||||
| deletedUserListRef.current, | |||||
| getIdList(latestGroupMember) | |||||
| ); | |||||
| const response = await axios.post(POST_AND_UPDATE_USER_GROUP, { | const response = await axios.post(POST_AND_UPDATE_USER_GROUP, { | ||||
| id: parseInt(params.id) !== -1 ? parseInt(params.id) : null, | id: parseInt(params.id) !== -1 ? parseInt(params.id) : null, | ||||
| name: latestGroupFormData.userGroupName, | name: latestGroupFormData.userGroupName, | ||||
| description: latestGroupFormData.description, | description: latestGroupFormData.description, | ||||
| addUserIds: getIdList(groupMember), | |||||
| addUserIds: getIdList(latestGroupMember), | |||||
| removeUserIds: finalDeletedUserList, | removeUserIds: finalDeletedUserList, | ||||
| addAuthIds: userAuthData, | |||||
| removeAuthIds: deletedAuthList, | |||||
| addAuthIds: latestAuthIds, | |||||
| removeAuthIds: latestDeletedAuthIds, | |||||
| }); | }); | ||||
| if (response.status === 200) { | if (response.status === 200) { | ||||
| navigate('/usergroupSearchview'); | |||||
| notifySaveSuccess(); | notifySaveSuccess(); | ||||
| const savedId = response.data?.id; | |||||
| const currentId = parseInt(params.id); | |||||
| if ((currentId === -1 || Number.isNaN(currentId)) && savedId) { | |||||
| navigate(`/userGroup/${savedId}`, { replace: true }); | |||||
| await loadGroupData(savedId); | |||||
| } else { | |||||
| await loadGroupData(currentId); | |||||
| } | |||||
| setIsNewRecord(false); | |||||
| setEditMode(false); | |||||
| deletedUserListRef.current = []; | |||||
| deletedAuthListRef.current = []; | |||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| console.log(error); | console.log(error); | ||||
| @@ -177,14 +198,19 @@ const UserMaintainPage = () => { | |||||
| return true; | return true; | ||||
| }; | }; | ||||
| const loadGroupData = async (groupId) => { | |||||
| const response = await axios.get(`${GET_GROUP_LIST_PATH}/${groupId}`); | |||||
| if (response.status === 200) { | |||||
| setUserGroupData(response.data); | |||||
| const loadedAuthIds = response.data?.authIds || []; | |||||
| userAuthDataRef.current = loadedAuthIds; | |||||
| } | |||||
| return response; | |||||
| }; | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (params.id > 0) { | if (params.id > 0) { | ||||
| axios.get(`${GET_GROUP_LIST_PATH}/${params.id}`) | |||||
| .then((response) => { | |||||
| if (response.status === 200) { | |||||
| setUserGroupData(response.data); | |||||
| } | |||||
| }) | |||||
| loadGroupData(params.id) | |||||
| .catch(error => { | .catch(error => { | ||||
| console.log(error); | console.log(error); | ||||
| return false; | return false; | ||||
| @@ -68,7 +68,7 @@ export default function UserGroupTable({searchCriteria, applyGridOnReady,applySe | |||||
| <FiDataGrid | <FiDataGrid | ||||
| columns={columns} | columns={columns} | ||||
| customPageSize={10} | customPageSize={10} | ||||
| pageSizeOptions={[10, 15, 20]} | |||||
| hideRowsPerPage | |||||
| onRowDoubleClick={handleRowDoubleClick} | onRowDoubleClick={handleRowDoubleClick} | ||||
| applyGridOnReady={applyGridOnReady} | applyGridOnReady={applyGridOnReady} | ||||
| applySearch={applySearch} | applySearch={applySearch} | ||||
| @@ -58,6 +58,17 @@ export const CONTAINED_PRIMARY_BLUE = '#0c489e'; | |||||
| /** Validation error text/border — matches FormHelperText in styles.css (e.g. "Please enter password"). WCAG AA on white. */ | /** Validation error text/border — matches FormHelperText in styles.css (e.g. "Please enter password"). WCAG AA on white. */ | ||||
| export const VALIDATION_ERROR_COLOR = '#B00020'; | export const VALIDATION_ERROR_COLOR = '#B00020'; | ||||
| /** WCAG 2.0 AA contained error button red — lightest tone passing 4.5:1 with white text (~4.57:1). */ | |||||
| export const CONTAINED_ERROR_RED = '#e61f2a'; | |||||
| export const ERROR_CONTAINED_BUTTON_SX = { | |||||
| backgroundColor: CONTAINED_ERROR_RED, | |||||
| color: '#FFFFFF', | |||||
| '&:hover': { | |||||
| backgroundColor: '#d32f2f', | |||||
| }, | |||||
| }; | |||||
| export const PRIMARY_CONTAINED_BUTTON_SX = { | export const PRIMARY_CONTAINED_BUTTON_SX = { | ||||
| backgroundColor: CONTAINED_PRIMARY_BLUE, | backgroundColor: CONTAINED_PRIMARY_BLUE, | ||||
| color: '#FFFFFF', | color: '#FFFFFF', | ||||
| @@ -48,13 +48,14 @@ export const PNSPS_THEME = createTheme({ | |||||
| }, | }, | ||||
| styleOverrides: { | styleOverrides: { | ||||
| root: ({ ownerState }) => ({ | root: ({ ownerState }) => ({ | ||||
| ...(ownerState.color === "cancel" && { | |||||
| borderColor: "#9E9E9E", | |||||
| }), | |||||
| '&:active': { | |||||
| boxShadow: 'none', | |||||
| transform: 'none', | |||||
| }, | |||||
| ...(ownerState.color === "cancel" && { | |||||
| borderColor: "#9E9E9E", | |||||
| }), | |||||
| textTransform: 'none', | |||||
| '&:active': { | |||||
| boxShadow: 'none', | |||||
| transform: 'none', | |||||
| }, | |||||
| }), | }), | ||||
| contained: ({ theme, ownerState }) => ({ | contained: ({ theme, ownerState }) => ({ | ||||
| ...(ownerState.color === "cancel" && { | ...(ownerState.color === "cancel" && { | ||||
| @@ -121,6 +121,7 @@ | |||||
| "iAmSmartNoIdNoMsg": "Invalid information, please return to the creation of account page.", | "iAmSmartNoIdNoMsg": "Invalid information, please return to the creation of account page.", | ||||
| "mainPage": "Main Page", | "mainPage": "Main Page", | ||||
| "backToMainPage": "Back to Main Page", | |||||
| "myPublicNotice": "My Public Notices", | "myPublicNotice": "My Public Notices", | ||||
| "publicNotice": "Public Notice", | "publicNotice": "Public Notice", | ||||
| "publicNoticeApp": "Public Notice Application", | "publicNoticeApp": "Public Notice Application", | ||||
| @@ -421,8 +422,8 @@ | |||||
| "payId": "Payment No.", | "payId": "Payment No.", | ||||
| "payIdNRefer": "Payment No. / Payment Reference No.", | "payIdNRefer": "Payment No. / Payment Reference No.", | ||||
| "payConfirm": "Confirm payment", | "payConfirm": "Confirm payment", | ||||
| "payCancel": "Cancel payment", | |||||
| "payAlert": "Please don’t close this window, you may either complete this payment or cancel this payment by the button at the bottom of this page.", | |||||
| "payCancel": "Cancel Payment", | |||||
| "payAlert": "Please do not refresh or close this page during payment to avoid payment failure. In case of cancellation, please use the “Cancel Payment” button at the bottom of this page.", | |||||
| "payTotalDeatail": "Total Payment Amount", | "payTotalDeatail": "Total Payment Amount", | ||||
| "payDeatail": "Total Payment Amount", | "payDeatail": "Total Payment Amount", | ||||
| "payTotal": "Total Payment Amount", | "payTotal": "Total Payment Amount", | ||||
| @@ -455,7 +456,9 @@ | |||||
| "paymentLimitPrice2":" is only applicable when minimum amount is HK$0.10 and maximum amount is HK$9,999,999.99", | "paymentLimitPrice2":" is only applicable when minimum amount is HK$0.10 and maximum amount is HK$9,999,999.99", | ||||
| "paymentLimitPPS":" Payment could not be made via mobile device browsers, please use desktop computers to make payment.", | "paymentLimitPPS":" Payment could not be made via mobile device browsers, please use desktop computers to make payment.", | ||||
| "paymentMethod": "Payment Method", | "paymentMethod": "Payment Method", | ||||
| "paymentProcessLimited":"Please complete the payment process within 15 minutes. Note: For FPS payments, scanning, payment and all necessary approvals must be finished within 3 minutes due to security-related QR code expiry.", | |||||
| "paymentProcessLimited1":"Please complete the payment process within 15 minutes.", | |||||
| "paymentProcessLimited2":"Please do not refresh or close any page during payment to avoid payment failure.", | |||||
| "paymentProcessLimited3":"Note: For FPS payments, scanning, payment and all necessary approvals must be finished within 3 minutes due to security-related QR code expiry.", | |||||
| "publicNoticeDetailTitle": "Public Notice Application Information", | "publicNoticeDetailTitle": "Public Notice Application Information", | ||||
| "applyPerson": "Applicant", | "applyPerson": "Applicant", | ||||
| @@ -514,12 +517,14 @@ | |||||
| "payNPGOMethod":"NPGO Collection Office", | "payNPGOMethod":"NPGO Collection Office", | ||||
| "payOnlineBtn":"Pay Online", | "payOnlineBtn":"Pay Online", | ||||
| "fpsQrcodeTitle1":"Please scan the following QR code", | "fpsQrcodeTitle1":"Please scan the following QR code", | ||||
| "fpsQrcodeTitle2":"QR code is valid for 3 minutes", | |||||
| "fpsQrcodeTitle2":"QR code is valid for 3 minutes only", | |||||
| "fpsQrcodeTitle3":"Please complete the payment process within the specified time", | "fpsQrcodeTitle3":"Please complete the payment process within the specified time", | ||||
| "fpsQrcodeTitle4":"Remaining time:", | "fpsQrcodeTitle4":"Remaining time:", | ||||
| "fpsQrcodeTitle5":"s", | "fpsQrcodeTitle5":"s", | ||||
| "fpsQrcodeExpired":"QR code has expired.", | "fpsQrcodeExpired":"QR code has expired.", | ||||
| "fpsPaymentErrorMsg":"An error occurred while loading the payment QR code. Please do not refresh/reload this page manually during the payment. If the payment was not made successfully, please click 'Cancel payment' button and make the payment again. Sorry for the inconvenience caused.", | |||||
| "fpsPaymentErrorMsg1":"The payment QR code is no longer valid after reloading the page. Please verify your banking transaction history:", | |||||
| "fpsPaymentErrorMsg2":"If payment was deducted: Please contact Accounts Section (Tel.: 2231 5183/2231 5318) with your transaction details.", | |||||
| "fpsPaymentErrorMsg3":"If payment was not deducted: Please click \"Back to Main Page\" and try again after 30 minutes.", | |||||
| "fpsSelectPaymentApp":"Please Select Bank App", | "fpsSelectPaymentApp":"Please Select Bank App", | ||||
| "payDnRemark": "Payment proof (e.g. ATM receipt, internet banking record) to be sent to [email protected] by {date} 12:30 p.m.", | "payDnRemark": "Payment proof (e.g. ATM receipt, internet banking record) to be sent to [email protected] by {date} 12:30 p.m.", | ||||
| @@ -90,7 +90,9 @@ | |||||
| "fpsQrcodeTitle4":"剩余时间:", | "fpsQrcodeTitle4":"剩余时间:", | ||||
| "fpsQrcodeTitle5":"秒", | "fpsQrcodeTitle5":"秒", | ||||
| "fpsQrcodeExpired":"二维码已过期", | "fpsQrcodeExpired":"二维码已过期", | ||||
| "fpsPaymentErrorMsg":"载入支付二维码时发生错误。请勿在付款过程中更新此页面。如果付款未完成,请点击「取消支付」按钮并重新支付。由此造成的不便,敬请谅解。", | |||||
| "fpsPaymentErrorMsg1":"页面重新加载时发生错误,支付二维码已失效。请先确认银行交易纪录:", | |||||
| "fpsPaymentErrorMsg2":"如已扣款:请联络会计组(电话:2231 5183 / 2231 5318)并提供交易信息。", | |||||
| "fpsPaymentErrorMsg3":"如未扣款:请点击「返回主页」并等待30分钟后重新尝试。", | |||||
| "fpsSelectPaymentApp":"请选择付款支付程序", | "fpsSelectPaymentApp":"请选择付款支付程序", | ||||
| "payDnRemark": "在{date}下午12时30分前将付款证明(例如银行入数纸或网上银行付款记录)电邮至 [email protected]", | "payDnRemark": "在{date}下午12时30分前将付款证明(例如银行入数纸或网上银行付款记录)电邮至 [email protected]", | ||||
| @@ -159,6 +161,7 @@ | |||||
| "iAmSmartNoIdNoMsg": "无效资料,请返回建立账户页面。", | "iAmSmartNoIdNoMsg": "无效资料,请返回建立账户页面。", | ||||
| "mainPage": "主页", | "mainPage": "主页", | ||||
| "backToMainPage": "返回主页", | |||||
| "publicNotice": "公共启事", | "publicNotice": "公共启事", | ||||
| "publicNoticeApp": "公共啟事申请", | "publicNoticeApp": "公共啟事申请", | ||||
| "myPublicNotice": "我的公共启事", | "myPublicNotice": "我的公共启事", | ||||
| @@ -456,7 +459,7 @@ | |||||
| "payIdNRefer": "付款编号 / 付款参考编号", | "payIdNRefer": "付款编号 / 付款参考编号", | ||||
| "payConfirm": "确认付款", | "payConfirm": "确认付款", | ||||
| "payCancel": "取消付款", | "payCancel": "取消付款", | ||||
| "payAlert": "请不要关闭此窗口,您可以通过此页面底部的按钮完成此付款或取消此付款。", | |||||
| "payAlert": "付款过程中请勿重新整理或关闭此页面,以免支付失败。如需取消,请使用本页底部的「取消付款」按钮。", | |||||
| "payTotalDeatail": "付款总额", | "payTotalDeatail": "付款总额", | ||||
| "payDeatail": "付款总额", | "payDeatail": "付款总额", | ||||
| "payTotal": "付款总额", | "payTotal": "付款总额", | ||||
| @@ -490,7 +493,9 @@ | |||||
| "paymentLimitPrice2":"只适用于最小金额为 0.10 港元及最高金额为 9,999,999.99港元", | "paymentLimitPrice2":"只适用于最小金额为 0.10 港元及最高金额为 9,999,999.99港元", | ||||
| "paymentLimitPPS":"付款不适用于流动装置的浏览器,请使用桌面电脑。", | "paymentLimitPPS":"付款不适用于流动装置的浏览器,请使用桌面电脑。", | ||||
| "paymentMethod": "付款方式", | "paymentMethod": "付款方式", | ||||
| "paymentProcessLimited":"请于15分钟内完成付款程序。 注意:使用转数快(FPS)时,因二维码具安全时效限制,须于3分钟内完成扫码、付款及所有相关审核程序。", | |||||
| "paymentProcessLimited1":"请于15分钟内完成付款程序。", | |||||
| "paymentProcessLimited2":"付款过程中请勿重新整理或关闭任何页面,以免支付失败。", | |||||
| "paymentProcessLimited3":"注意:使用转数快(FPS)时,因二维码具安全时效限制,须于3分钟内完成扫码、付款及所有相关审核程序。", | |||||
| "publicNoticeDetailTitle": "公共启事申请资料", | "publicNoticeDetailTitle": "公共启事申请资料", | ||||
| "applyPerson": "申请人", | "applyPerson": "申请人", | ||||
| @@ -90,7 +90,9 @@ | |||||
| "fpsQrcodeTitle4":"剩餘時間:", | "fpsQrcodeTitle4":"剩餘時間:", | ||||
| "fpsQrcodeTitle5":"秒", | "fpsQrcodeTitle5":"秒", | ||||
| "fpsQrcodeExpired":"二維碼已過期", | "fpsQrcodeExpired":"二維碼已過期", | ||||
| "fpsPaymentErrorMsg":"載入支付二維碼時發生錯誤。請勿在付款過程中更新此頁面。如果付款未完成,請點擊「取消支付」按鈕並重新支付。由此造成的不便,敬請諒解。", | |||||
| "fpsPaymentErrorMsg1":"頁面重新載入時發生錯誤,支付二維碼已失效。請先確認銀行交易紀錄:", | |||||
| "fpsPaymentErrorMsg2":"如已扣款:請聯絡會計組(電話:2231 5183 / 2231 5318)並提供交易資料。", | |||||
| "fpsPaymentErrorMsg3":"如未扣款:請點擊「返回主頁」並等待30分鐘後重新嘗試。", | |||||
| "fpsSelectPaymentApp":"請選擇付款支付程式", | "fpsSelectPaymentApp":"請選擇付款支付程式", | ||||
| "payDnRemark": "在{date}下午12時30分前將付款證明(例如銀行入數紙或網上銀行付款記錄)電郵至 [email protected]", | "payDnRemark": "在{date}下午12時30分前將付款證明(例如銀行入數紙或網上銀行付款記錄)電郵至 [email protected]", | ||||
| @@ -159,6 +161,7 @@ | |||||
| "iAmSmartNoIdNoMsg": "無效資料,請返回建立賬戶頁面。", | "iAmSmartNoIdNoMsg": "無效資料,請返回建立賬戶頁面。", | ||||
| "mainPage": "主頁", | "mainPage": "主頁", | ||||
| "backToMainPage": "返回主頁", | |||||
| "publicNotice": "公共啟事", | "publicNotice": "公共啟事", | ||||
| "publicNoticeApp": "公共啟事申請", | "publicNoticeApp": "公共啟事申請", | ||||
| "myPublicNotice": "我的公共啟事", | "myPublicNotice": "我的公共啟事", | ||||
| @@ -457,7 +460,7 @@ | |||||
| "payIdNRefer": "付款編號 / 付款參考編號", | "payIdNRefer": "付款編號 / 付款參考編號", | ||||
| "payConfirm": "確認付款", | "payConfirm": "確認付款", | ||||
| "payCancel": "取消付款", | "payCancel": "取消付款", | ||||
| "payAlert": "請不要關閉此窗口,您可以透過本頁底部的按鈕完成此付款或取消本付款。", | |||||
| "payAlert": "付款過程中請勿重新整理或關閉此頁面,以免支付失敗。如需取消,請使用本頁底部的「取消付款」按鈕。", | |||||
| "payTotalDeatail": "付款總額", | "payTotalDeatail": "付款總額", | ||||
| "payDeatail": "付款總額", | "payDeatail": "付款總額", | ||||
| "payTotal": "付款總額", | "payTotal": "付款總額", | ||||
| @@ -491,7 +494,9 @@ | |||||
| "paymentLimitPrice2":"只適用於最小金額為 0.10 港元及最高金額為 9,999,999.99港元", | "paymentLimitPrice2":"只適用於最小金額為 0.10 港元及最高金額為 9,999,999.99港元", | ||||
| "paymentLimitPPS":"付款不適用於流動裝置的瀏覽器,請使用桌面電腦。", | "paymentLimitPPS":"付款不適用於流動裝置的瀏覽器,請使用桌面電腦。", | ||||
| "paymentMethod": "付款方法", | "paymentMethod": "付款方法", | ||||
| "paymentProcessLimited":"請於15分鐘內完成付款程序。 注意:使用轉數快(FPS)時,因二維碼具安全時效限制,須於3分鐘內完成掃碼、付款及所有相關審核程序。", | |||||
| "paymentProcessLimited1":"請於15分鐘內完成付款程序。", | |||||
| "paymentProcessLimited2":"付款過程中請勿重新整理或關閉任何頁面,以免支付失敗。", | |||||
| "paymentProcessLimited3":"注意:使用轉數快(FPS)時,因二維碼具安全時效限制,須於3分鐘內完成掃碼、付款及所有相關審核程序。", | |||||
| "publicNoticeDetailTitle": "公共啟事申請資料", | "publicNoticeDetailTitle": "公共啟事申請資料", | ||||
| "applyPerson": "申請人", | "applyPerson": "申請人", | ||||