From 7cd1c15d3be99235282f47fa1a8a25a759849c78 Mon Sep 17 00:00:00 2001 From: nahyebin Date: Wed, 10 Jun 2026 09:53:02 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[KAN-1372]=20feat:=20=EA=B0=80=EB=A7=B9?= =?UTF-8?q?=EC=A0=90=20=EC=8A=B9=EC=9D=B8=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web-merchant/api/backend-api.jsx | 108 ++++++++++++++++++++------- 1 file changed, 81 insertions(+), 27 deletions(-) diff --git a/src/web-merchant/api/backend-api.jsx b/src/web-merchant/api/backend-api.jsx index 133ba06..a77e68b 100644 --- a/src/web-merchant/api/backend-api.jsx +++ b/src/web-merchant/api/backend-api.jsx @@ -5,6 +5,14 @@ const MERCHANT_API_BASE_URL = import.meta.env.VITE_MERCHANT_API_BASE_URL || "/ba const SESSION_KEY = "erumpay.merchant.session"; const PROFILE_KEY = "erumpay.merchant.profile"; +// Remove tokens left by older builds that used persistent browser storage. +try { + localStorage.removeItem(SESSION_KEY); + localStorage.removeItem(PROFILE_KEY); +} catch { + // Storage may be unavailable in restricted browser contexts. +} + class ApiError extends Error { constructor(message, status, code, details) { super(message); @@ -51,35 +59,79 @@ const request = async (baseUrl, path, options = {}) => { const AuthSession = { get: () => { try { - return JSON.parse(localStorage.getItem(SESSION_KEY)) || {}; + return JSON.parse(sessionStorage.getItem(SESSION_KEY)) || {}; } catch { return {}; } }, - set: (value) => localStorage.setItem(SESSION_KEY, JSON.stringify(value)), + set: (value) => sessionStorage.setItem(SESSION_KEY, JSON.stringify(value)), merge: (value) => AuthSession.set({ ...AuthSession.get(), ...value }), clear: () => { - localStorage.removeItem(SESSION_KEY); - localStorage.removeItem(PROFILE_KEY); + sessionStorage.removeItem(SESSION_KEY); + sessionStorage.removeItem(PROFILE_KEY); + }, + isAuthenticated: () => { + const session = AuthSession.get(); + return Boolean(session.accessToken && session.status === "ACTIVE"); }, - isAuthenticated: () => Boolean(AuthSession.get().accessToken), getProfile: () => { try { - return JSON.parse(localStorage.getItem(PROFILE_KEY)); + return JSON.parse(sessionStorage.getItem(PROFILE_KEY)); } catch { return null; } }, - setProfile: (profile) => localStorage.setItem(PROFILE_KEY, JSON.stringify(profile)), + setProfile: (profile) => sessionStorage.setItem(PROFILE_KEY, JSON.stringify(profile)), }; -let refreshTokenMemory = null; - const getAuthHeaders = () => { const { accessToken } = AuthSession.get(); return accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; }; +const refreshAccessToken = async () => { + const { refreshToken } = AuthSession.get(); + if (!refreshToken) { + throw new ApiError("로그인 세션이 만료되었습니다.", 401, "REFRESH_TOKEN_REQUIRED"); + } + + const response = await request(AUTH_API_BASE_URL, "/api/v1/auth/token/refresh", { + method: "POST", + headers: { Authorization: `Bearer ${refreshToken}` }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + AuthSession.merge({ + accessToken: response.access_token, + refreshToken: response.refresh_token || refreshToken, + }); + return response.access_token; +}; + +const authorizedRequest = async (baseUrl, path, options = {}) => { + try { + return await request(baseUrl, path, { + ...options, + headers: { ...getAuthHeaders(), ...options.headers }, + }); + } catch (error) { + if (error.status !== 401 || !AuthSession.get().refreshToken) throw error; + let accessToken; + try { + accessToken = await refreshAccessToken(); + } catch (refreshError) { + AuthSession.clear(); + throw refreshError; + } + return request(baseUrl, path, { + ...options, + headers: { + Authorization: `Bearer ${accessToken}`, + ...options.headers, + }, + }); + } +}; + const AuthApi = { getKakaoAuthorizeUrl: () => { const clientId = import.meta.env.VITE_KAKAO_CLIENT_ID; @@ -105,11 +157,12 @@ const AuthApi = { merchantId: response.merchant_id, status: response.status, accessToken: response.access_token, + refreshToken: response.refresh_token, signupToken: response.signup_token, }); - refreshTokenMemory = response.refresh_token || null; return response; }, + refresh: refreshAccessToken, agreeTerms: async ({ serviceTermsAgreed, privacyPolicyAgreed, marketingAgreed }) => { const { signupToken } = AuthSession.get(); return request(AUTH_API_BASE_URL, "/api/v1/auth/merchant/terms/agree", { @@ -146,35 +199,36 @@ const AuthApi = { }, logout: async () => { const session = AuthSession.get(); - const refreshToken = refreshTokenMemory || session.refreshToken; - if (refreshToken) { - await request(AUTH_API_BASE_URL, "/api/v1/auth/merchant/logout", { - method: "POST", - headers: { Authorization: `Bearer ${refreshToken}` }, - body: JSON.stringify({ - refresh_token: refreshToken, - access_token: session.accessToken, - }), - }); + try { + if (session.refreshToken) { + await request(AUTH_API_BASE_URL, "/api/v1/auth/merchant/logout", { + method: "POST", + headers: { Authorization: `Bearer ${session.refreshToken}` }, + body: JSON.stringify({ + refresh_token: session.refreshToken, + access_token: session.accessToken, + }), + }); + } + } finally { + AuthSession.clear(); } - refreshTokenMemory = null; - AuthSession.clear(); }, }; const MerchantBackendApi = { getMerchant: async (merchantId = AuthSession.get().merchantId) => { if (!merchantId) return null; - const profile = await request(MERCHANT_API_BASE_URL, `/api/v1/pg-admin/merchants/${merchantId}`, { - headers: getAuthHeaders(), - }); + const profile = await authorizedRequest( + MERCHANT_API_BASE_URL, + `/api/v1/pg-admin/merchants/${merchantId}` + ); AuthSession.setProfile(profile); return profile; }, updateMerchant: async (merchantId, payload) => { - const profile = await request(MERCHANT_API_BASE_URL, `/api/v1/pg-admin/merchants/${merchantId}`, { + const profile = await authorizedRequest(MERCHANT_API_BASE_URL, `/api/v1/pg-admin/merchants/${merchantId}`, { method: "PUT", - headers: getAuthHeaders(), body: JSON.stringify(payload), }); AuthSession.setProfile(profile); From b4b2180f8d2cf8321dad5e843082313229dbf115 Mon Sep 17 00:00:00 2001 From: nahyebin Date: Thu, 11 Jun 2026 15:28:13 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[KAN-1372]=20feat:=20PG=20=EA=B0=80?= =?UTF-8?q?=EB=A7=B9=EC=A0=90=20=EC=8A=B9=EC=9D=B8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=8F=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- src/main.jsx | 2 + src/web-pg/api/backend-api.jsx | 117 +++++++++++++++++++++++++ src/web-pg/app.jsx | 24 +++++- src/web-pg/components.jsx | 9 +- src/web-pg/layout.jsx | 1 + src/web-pg/screens/approvals.jsx | 141 +++++++++++++++++++++++++++++++ src/web-pg/screens/other.jsx | 132 ++++++++++++++++------------- src/web-pg/styles.css | 43 ++++++++++ 9 files changed, 408 insertions(+), 64 deletions(-) create mode 100644 src/web-pg/api/backend-api.jsx create mode 100644 src/web-pg/screens/approvals.jsx diff --git a/README.md b/README.md index 067270e..2706759 100644 --- a/README.md +++ b/README.md @@ -35,5 +35,6 @@ VITE_KAKAO_REDIRECT_URI=http://localhost:5173/ 현재 연동 범위: - `pg-auth-service`: 카카오 로그인, 약관 동의, 가맹점 가입, 로그아웃 -- `merchant-service`: 로그인 가맹점 기본정보 조회 +- `merchant-service`: 로그인 가맹점 기본정보 조회/수정 +- PG 관리자 웹: 관리자 로그인, 감사로그 조회, 가입 대기 가맹점 조회/승인 - 매출, 거래, 정산: 대응 백엔드 API가 준비될 때까지 목 데이터 사용 diff --git a/src/main.jsx b/src/main.jsx index d2e2791..10c46fe 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -12,7 +12,9 @@ const importPg = () => import("./web-pg/styles.css") .then(() => import("./web-pg/components.jsx")) .then(() => import("./web-pg/layout.jsx")) .then(() => import("./web-pg/data.jsx")) + .then(() => import("./web-pg/api/backend-api.jsx")) .then(() => import("./web-pg/screens/dashboard.jsx")) + .then(() => import("./web-pg/screens/approvals.jsx")) .then(() => import("./web-pg/screens/merchants.jsx")) .then(() => import("./web-pg/screens/transactions.jsx")) .then(() => import("./web-pg/screens/settlements.jsx")) diff --git a/src/web-pg/api/backend-api.jsx b/src/web-pg/api/backend-api.jsx new file mode 100644 index 0000000..21c61da --- /dev/null +++ b/src/web-pg/api/backend-api.jsx @@ -0,0 +1,117 @@ +/* Backend API clients for PG admin web */ + +const PG_AUTH_API_BASE_URL = import.meta.env.VITE_AUTH_API_BASE_URL || "/backend/auth"; +const PG_MERCHANT_API_BASE_URL = import.meta.env.VITE_MERCHANT_API_BASE_URL || "/backend/merchant"; +const PG_SESSION_KEY = "erumpay.pg.session"; + +class PgApiError extends Error { + constructor(message, status, code) { + super(message); + this.name = "PgApiError"; + this.status = status; + this.code = code; + } +} + +const pgReadBody = async response => { + if (response.status === 204) return null; + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return response.ok ? text : { message: text }; + } +}; + +const pgRequest = async (baseUrl, path, options = {}) => { + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + const body = await pgReadBody(response); + if (!response.ok) { + throw new PgApiError( + body?.message || "요청 처리 중 오류가 발생했습니다.", + response.status, + body?.code + ); + } + return body; +}; + +const PgSession = { + get: () => { + try { + return JSON.parse(sessionStorage.getItem(PG_SESSION_KEY)) || {}; + } catch { + return {}; + } + }, + set: value => sessionStorage.setItem(PG_SESSION_KEY, JSON.stringify(value)), + clear: () => sessionStorage.removeItem(PG_SESSION_KEY), + isAuthenticated: () => Boolean(PgSession.get().accessToken), + authHeaders: () => { + const { accessToken } = PgSession.get(); + return accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; + }, +}; + +const PgAdminApi = { + login: async ({ loginId, password, totpCode }) => { + const response = await pgRequest(PG_AUTH_API_BASE_URL, "/api/v1/auth/admin/login", { + method: "POST", + body: JSON.stringify({ + login_id: loginId, + password, + totp_code: totpCode, + }), + }); + PgSession.set({ + loginId, + accessToken: response.access_token, + refreshToken: response.refresh_token, + role: response.role, + }); + return response; + }, + logout: async () => { + const { refreshToken } = PgSession.get(); + try { + if (refreshToken) { + await pgRequest(PG_AUTH_API_BASE_URL, "/api/v1/auth/admin/logout", { + method: "POST", + headers: { Authorization: `Bearer ${refreshToken}` }, + }); + } + } finally { + PgSession.clear(); + } + }, + getPendingMerchants: async () => { + const response = await pgRequest( + PG_MERCHANT_API_BASE_URL, + "/api/v1/pg-admin/merchants?page=0&size=100&sort=createdAt,desc", + { headers: PgSession.authHeaders() } + ); + return (response?.content || []).filter(merchant => merchant.status === "PENDING"); + }, + approveMerchant: merchantId => pgRequest( + PG_AUTH_API_BASE_URL, + `/api/v1/pg-admin/merchants/${merchantId}/approve`, + { + method: "PATCH", + headers: PgSession.authHeaders(), + } + ), + getAuditLogs: async (page = 0, size = 20) => pgRequest( + PG_AUTH_API_BASE_URL, + `/api/v1/pg-admin/audit-logs?page=${page}&size=${size}`, + { headers: PgSession.authHeaders() } + ), +}; + +Object.assign(window, { PgApiError, PgSession, PgAdminApi }); diff --git a/src/web-pg/app.jsx b/src/web-pg/app.jsx index 0a1f20c..3b7c773 100644 --- a/src/web-pg/app.jsx +++ b/src/web-pg/app.jsx @@ -2,6 +2,7 @@ const ROUTES = { dashboard: { title: "대시보드", crumbs: ["대시보드"] }, + approvals: { title: "가맹점 가입 대기", crumbs: ["가맹점 가입 대기"] }, merchants: { title: "가맹점 관리", crumbs: ["가맹점 관리"] }, transactions: { title: "결제관리", crumbs: ["결제관리"] }, settlements: { title: "정산관리", crumbs: ["정산관리"] }, @@ -15,7 +16,7 @@ const ROUTES = { const App = () => { const [page, setPage] = React.useState("dashboard"); const [collapsed, setCollapsed] = React.useState(false); - const [authed, setAuthed] = React.useState(true); + const [authed, setAuthed] = React.useState(() => window.PgSession.isAuthenticated()); const [merchant, setMerchant] = React.useState(null); const [tx, setTx] = React.useState(null); @@ -30,15 +31,29 @@ const App = () => { return () => window.removeEventListener("hashchange", onHash); }, []); - const nav = (id) => { - if (id === "login") { setAuthed(false); window.location.hash = "login"; return; } + const nav = async (id) => { + if (id === "login") { + try { + await window.PgAdminApi.logout(); + } catch (error) { + console.error("PG logout failed", error); + } + setAuthed(false); + window.location.hash = "login"; + return; + } setPage(id); setMerchant(null); setTx(null); window.location.hash = id; }; if (!authed) { - return { setAuthed(true); window.location.hash = "dashboard"; }}/>; + return { + await window.PgAdminApi.login(form); + setAuthed(true); + setPage("approvals"); + window.location.hash = "approvals"; + }}/>; } const route = ROUTES[page] || ROUTES.dashboard; @@ -55,6 +70,7 @@ const App = () => { unread={3} /> {page === "dashboard" && } + {page === "approvals" && } {page === "merchants" && } {page === "transactions" && } {page === "settlements" && } diff --git a/src/web-pg/components.jsx b/src/web-pg/components.jsx index d61ba03..58044ba 100644 --- a/src/web-pg/components.jsx +++ b/src/web-pg/components.jsx @@ -4,8 +4,13 @@ const Tag = ({ kind = "neutral", dot = false, children }) => ( {children} ); -const Button = ({ kind = "ghost", size, icon, children, onClick, type = "button" }) => ( - diff --git a/src/web-pg/layout.jsx b/src/web-pg/layout.jsx index dea84b6..d6973d6 100644 --- a/src/web-pg/layout.jsx +++ b/src/web-pg/layout.jsx @@ -2,6 +2,7 @@ const NAV_MAIN = [ { id: "dashboard", label: "대시보드", icon: "Dashboard" }, + { id: "approvals", label: "가맹점 가입 대기", icon: "Users" }, { id: "merchants", label: "가맹점 관리", icon: "Store" }, { id: "transactions", label: "결제관리", icon: "Card" }, { id: "settlements", label: "정산관리", icon: "Calc" }, diff --git a/src/web-pg/screens/approvals.jsx b/src/web-pg/screens/approvals.jsx new file mode 100644 index 0000000..b4b1ac7 --- /dev/null +++ b/src/web-pg/screens/approvals.jsx @@ -0,0 +1,141 @@ +/* Merchant approval queue */ + +const MerchantApprovals = () => { + const [merchants, setMerchants] = React.useState([]); + const [query, setQuery] = React.useState(""); + const [loading, setLoading] = React.useState(true); + const [approvingId, setApprovingId] = React.useState(null); + const [error, setError] = React.useState(""); + + const load = React.useCallback(async () => { + setLoading(true); + setError(""); + try { + setMerchants(await window.PgAdminApi.getPendingMerchants()); + } catch (loadError) { + setError(loadError.message); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + load(); + }, [load]); + + const filtered = merchants.filter(merchant => { + const keyword = query.trim().toLowerCase(); + if (!keyword) return true; + return [ + merchant.merchantName, + merchant.businessNumber, + merchant.ownerName, + merchant.contactPhone, + ].some(value => String(value || "").toLowerCase().includes(keyword)); + }); + + const approve = async merchant => { + if (!window.confirm(`${merchant.merchantName} 가맹점을 승인할까요?`)) return; + setApprovingId(merchant.merchantId); + setError(""); + try { + await window.PgAdminApi.approveMerchant(merchant.merchantId); + setMerchants(current => + current.filter(item => item.merchantId !== merchant.merchantId) + ); + } catch (approveError) { + setError(approveError.message); + } finally { + setApprovingId(null); + } + }; + + return ( +
+
+

가맹점 가입 대기

+

가맹점 가입 신청을 확인하고 승인합니다.

+
+ + +
+ + + + + + + + + + + +
+
+ + {error &&
{error}
} + + +
+

가입 신청 목록

+ {filtered.length}건 +
+
+ + + + + + + + + + + + + + + + {loading && ( + + )} + {!loading && filtered.length === 0 && ( + + )} + {!loading && filtered.map((merchant, index) => ( + + + + + + + + + + + + ))} + +
순번신청일시가맹점명사업자등록번호대표자명연락처주소상태기능
가입 신청을 불러오는 중입니다.
대기 중인 가입 신청이 없습니다.
{index + 1}{merchant.createdAt?.replace("T", " ").slice(0, 16) || "-"}{merchant.merchantName}{merchant.businessNumber}{merchant.ownerName}{merchant.contactPhone}{merchant.businessAddress}대기 + +
+
+
+
+ ); +}; + +window.MerchantApprovals = MerchantApprovals; diff --git a/src/web-pg/screens/other.jsx b/src/web-pg/screens/other.jsx index dcd15c1..2f55f28 100644 --- a/src/web-pg/screens/other.jsx +++ b/src/web-pg/screens/other.jsx @@ -91,84 +91,80 @@ const Notices = () => { /* ============= Audit ============= */ const AuditLog = () => { const [query, setQuery] = React.useState(""); - const [user, setUser] = React.useState("all"); - const [result, setResult] = React.useState("all"); - const filtered = AUDITS.filter(a => - (!query || a.action.includes(query) || (a.target && a.target.includes(query))) && - (user === "all" || a.user === user) && - (result === "all" || a.result === result) + const [logs, setLogs] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(""); + + React.useEffect(() => { + window.PgAdminApi.getAuditLogs() + .then(response => setLogs(response?.content || [])) + .catch(loadError => setError(loadError.message)) + .finally(() => setLoading(false)); + }, []); + + const filtered = logs.filter(log => + !query + || log.action?.toLowerCase().includes(query.toLowerCase()) + || log.target_id?.toLowerCase().includes(query.toLowerCase()) + || log.ip_address?.toLowerCase().includes(query.toLowerCase()) ); - const users = ["all", ...new Set(AUDITS.map(a => a.user))]; return (

감사 로그

-

관리자 행위와 시스템 이벤트의 변경 이력을 추적합니다.

+

관리자 로그인, 로그아웃, 가맹점 승인 이력을 조회합니다.

-
- - - +
- +
- +
-
+ {error &&
{error}
}
- - + - + - {filtered.map((a, i) => ( - - - - - - - - + {loading && } + {!loading && filtered.length === 0 && ( + + )} + {!loading && filtered.map(log => ( + + + + + + + ))}
발생 시각사용자역할관리자 ID 행위 대상 IP결과변경 내용
{a.ts}{a.user}{a.role}{a.action}{a.target}{a.ip} - {a.result === "성공" - ? 성공 - : 실패} -
감사로그를 불러오는 중입니다.
조회된 감사로그가 없습니다.
{log.created_at?.replace("T", " ").slice(0, 19)}{log.admin_id}{log.action}{log.target_id || "-"}{log.ip_address}{log.change_detail || "-"}
-
총 {filtered.length}건 (최근 12건 표시)
- {}}/> +
총 {filtered.length}건
@@ -477,9 +473,25 @@ const DesignSystem = () => { /* ============= Login ============= */ const Login = ({ onLogin }) => { - const [email, setEmail] = React.useState("kim@erumpay.kr"); - const [password, setPassword] = React.useState("••••••••"); - const [remember, setRemember] = React.useState(true); + const [loginId, setLoginId] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [totpCode, setTotpCode] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(""); + + const submit = async event => { + event.preventDefault(); + setLoading(true); + setError(""); + try { + await onLogin({ loginId, password, totpCode }); + } catch (loginError) { + setError(loginError.message); + } finally { + setLoading(false); + } + }; + return (
@@ -493,29 +505,35 @@ const Login = ({ onLogin }) => {
© 2024 ErumPay. All rights reserved.
-
+

로그인

-

슈퍼바이저 계정으로 로그인하세요.

+

PG 관리자 계정과 OTP로 로그인하세요.

- - setEmail(e.target.value)}/> + + setLoginId(e.target.value)} required/> - setPassword(e.target.value)}/> + setPassword(e.target.value)} required/> -
- - 비밀번호 찾기 -
- + + setTotpCode(event.target.value.replace(/\D/g, ""))} + /> + + {error &&
{error}
} +
5회 이상 로그인 실패 시 계정이 일시 잠금됩니다.
-
+
); }; diff --git a/src/web-pg/styles.css b/src/web-pg/styles.css index fc43281..b268e5c 100644 --- a/src/web-pg/styles.css +++ b/src/web-pg/styles.css @@ -570,6 +570,49 @@ table.tbl { padding: var(--s-12); gap: var(--s-5); } +/* ---------- PG merchant approvals ---------- */ +.approval-page { gap: var(--s-5); } +.approval-filter { + display: grid; + grid-template-columns: 180px 180px minmax(280px, 1fr) auto auto; + gap: var(--s-3); + align-items: end; +} +.approval-list-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--s-4) var(--s-5); + border-bottom: 1px solid var(--border); +} +.approval-list-head h2 { + margin: 0; + font-size: 15px; +} +.approval-list-head span { + color: var(--text-tertiary); +} +.approval-list-head strong { + color: var(--c-secondary); +} +.pg-api-error { + padding: var(--s-3) var(--s-4); + border: 1px solid #f3b5b3; + border-radius: var(--r-md); + background: var(--c-danger-bg); + color: var(--c-danger); + font-size: 13px; +} + +@media (max-width: 1100px) { + .approval-filter { + grid-template-columns: 1fr 1fr; + } + .approval-filter .field:nth-child(3) { + grid-column: 1 / -1; + } +} + /* ---------- Empty state ---------- */ .empty { padding: var(--s-12) var(--s-4); From 569a7d587ca910773c460855cfdbffd961a8ba90 Mon Sep 17 00:00:00 2001 From: nahyebin Date: Thu, 11 Jun 2026 15:53:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=9E=98=EB=B9=97=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web-merchant/api/backend-api.jsx | 31 +++++++++++++++++++++------- src/web-pg/api/backend-api.jsx | 25 ++++++++++++++++------ src/web-pg/screens/approvals.jsx | 24 +++++++++++++++++---- src/web-pg/screens/other.jsx | 6 +++--- 4 files changed, 66 insertions(+), 20 deletions(-) diff --git a/src/web-merchant/api/backend-api.jsx b/src/web-merchant/api/backend-api.jsx index a77e68b..c6cbbb8 100644 --- a/src/web-merchant/api/backend-api.jsx +++ b/src/web-merchant/api/backend-api.jsx @@ -107,21 +107,38 @@ const refreshAccessToken = async () => { return response.access_token; }; +let refreshPromise = null; + +const getRefreshedAccessToken = () => { + if (!refreshPromise) { + refreshPromise = refreshAccessToken() + .catch(error => { + AuthSession.clear(); + throw error; + }) + .finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +}; + const authorizedRequest = async (baseUrl, path, options = {}) => { + const accessTokenAtRequest = AuthSession.get().accessToken; try { return await request(baseUrl, path, { ...options, headers: { ...getAuthHeaders(), ...options.headers }, }); } catch (error) { - if (error.status !== 401 || !AuthSession.get().refreshToken) throw error; - let accessToken; - try { - accessToken = await refreshAccessToken(); - } catch (refreshError) { - AuthSession.clear(); - throw refreshError; + const currentSession = AuthSession.get(); + if (error.status !== 401 || !currentSession.refreshToken) throw error; + + let accessToken = currentSession.accessToken; + if (!accessToken || accessToken === accessTokenAtRequest) { + accessToken = await getRefreshedAccessToken(); } + return request(baseUrl, path, { ...options, headers: { diff --git a/src/web-pg/api/backend-api.jsx b/src/web-pg/api/backend-api.jsx index 21c61da..d71d866 100644 --- a/src/web-pg/api/backend-api.jsx +++ b/src/web-pg/api/backend-api.jsx @@ -92,12 +92,25 @@ const PgAdminApi = { } }, getPendingMerchants: async () => { - const response = await pgRequest( - PG_MERCHANT_API_BASE_URL, - "/api/v1/pg-admin/merchants?page=0&size=100&sort=createdAt,desc", - { headers: PgSession.authHeaders() } - ); - return (response?.content || []).filter(merchant => merchant.status === "PENDING"); + const size = 100; + const pendingMerchants = []; + let page = 0; + let totalPages = 1; + + do { + const response = await pgRequest( + PG_MERCHANT_API_BASE_URL, + `/api/v1/pg-admin/merchants?page=${page}&size=${size}&sort=createdAt,desc`, + { headers: PgSession.authHeaders() } + ); + pendingMerchants.push( + ...(response?.content || []).filter(merchant => merchant.status === "PENDING") + ); + totalPages = response?.totalPages || 0; + page += 1; + } while (page < totalPages); + + return pendingMerchants; }, approveMerchant: merchantId => pgRequest( PG_AUTH_API_BASE_URL, diff --git a/src/web-pg/screens/approvals.jsx b/src/web-pg/screens/approvals.jsx index b4b1ac7..a62ab8e 100644 --- a/src/web-pg/screens/approvals.jsx +++ b/src/web-pg/screens/approvals.jsx @@ -3,6 +3,7 @@ const MerchantApprovals = () => { const [merchants, setMerchants] = React.useState([]); const [query, setQuery] = React.useState(""); + const [applicationDate, setApplicationDate] = React.useState(""); const [loading, setLoading] = React.useState(true); const [approvingId, setApprovingId] = React.useState(null); const [error, setError] = React.useState(""); @@ -25,13 +26,15 @@ const MerchantApprovals = () => { const filtered = merchants.filter(merchant => { const keyword = query.trim().toLowerCase(); - if (!keyword) return true; - return [ + const matchesDate = !applicationDate + || merchant.createdAt?.slice(0, 10) === applicationDate; + const matchesKeyword = !keyword || [ merchant.merchantName, merchant.businessNumber, merchant.ownerName, merchant.contactPhone, ].some(value => String(value || "").toLowerCase().includes(keyword)); + return matchesDate && matchesKeyword; }); const approve = async merchant => { @@ -60,7 +63,12 @@ const MerchantApprovals = () => {
- + setApplicationDate(event.target.value)} + /> + + {error && {error}}
- -); + ); +}; const ReviewComplete = ({ onEnterMain, onCheckReview = onEnterMain }) => (
diff --git a/src/web-merchant/styles.css b/src/web-merchant/styles.css index fb081a7..2845939 100644 --- a/src/web-merchant/styles.css +++ b/src/web-merchant/styles.css @@ -1883,11 +1883,26 @@ table.tbl { background: #FBFCFD; color: var(--text-secondary); text-align: center; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.upload-box:hover, +.upload-box:focus-within { + border-color: var(--c-main); + background: #F2FBF8; +} + +.upload-box.selected { + border-color: var(--c-main); + background: #F2FBF8; + color: var(--c-main); } .upload-box strong { color: var(--text-secondary); font-size: 12px; + overflow-wrap: anywhere; } .upload-box small { @@ -1895,6 +1910,23 @@ table.tbl { color: var(--text-tertiary); } +.upload-input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.upload-error { + color: var(--c-danger); + font-size: 10px; +} + .review-complete-copy { text-align: center; }