478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
// App Global functions
|
|
"use client";
|
|
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
|
|
|
import { create } from "zustand"; //JWT
|
|
import { jwtDecode } from "jwt-decode";//JWT
|
|
|
|
|
|
const api = axios.create({
|
|
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
|
|
timeout: 7000,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
withCredentials: true, //JWT for access token
|
|
});
|
|
|
|
export default api;
|
|
|
|
|
|
export const api2 = axios.create({
|
|
baseURL: "http://localhost:17800",
|
|
timeout: 7000,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=UTF-8',
|
|
},
|
|
});
|
|
|
|
|
|
|
|
|
|
//JWT access token part
|
|
export const SIGN_IN_PAGE_URI = "/signin";
|
|
export const TOK_REFRESH_URI = "/api/sval2.do"; // security value 2(token refresh)
|
|
|
|
type JwtPayload = {
|
|
sub: string; // gid
|
|
user_id: string;
|
|
nick_name: string;
|
|
biz_group_id: string;
|
|
permission: number;
|
|
}
|
|
|
|
// "idle": not restored yet, "loading": restoring from the refresh cookie,
|
|
// "authed": access token available, "guest": no valid session
|
|
export type AuthStatus = "idle" | "loading" | "authed" | "guest";
|
|
|
|
type AuthState = {
|
|
accessToken: string | null;
|
|
tokenPayload: JwtPayload | null;
|
|
authStatus: AuthStatus;
|
|
setAccessToken: (token: string) => void;
|
|
clearAuth: () => void;
|
|
setAuthStatus: (status: AuthStatus) => void;
|
|
};
|
|
|
|
export const useAuthStore = create<AuthState>((set: any) => ({
|
|
accessToken: null,
|
|
tokenPayload: null,
|
|
authStatus: "idle",
|
|
|
|
setAccessToken: (token: string) => {
|
|
set({ accessToken: token, tokenPayload: jwtDecode<JwtPayload>(token), authStatus: "authed" });
|
|
},
|
|
clearAuth: () => set({ accessToken: null, tokenPayload: null, authStatus: "guest" }),
|
|
setAuthStatus: (status: AuthStatus) => set({ authStatus: status }),
|
|
}));
|
|
|
|
// The temporary ID and password typed on the sign in page, handed to the account change page.
|
|
// Kept in memory only: a reload loses it and sends the user back to sign in.
|
|
type InitAccountState = {
|
|
user_id: string;
|
|
user_pw: string;
|
|
setInitAccount: (user_id: string, user_pw: string) => void;
|
|
clearInitAccount: () => void;
|
|
};
|
|
|
|
export const useInitAccountStore = create<InitAccountState>((set) => ({
|
|
user_id: "",
|
|
user_pw: "",
|
|
setInitAccount: (user_id: string, user_pw: string) => set({ user_id, user_pw }),
|
|
clearInitAccount: () => set({ user_id: "", user_pw: "" }),
|
|
}));
|
|
|
|
type UiLoadingState = {
|
|
pendingCount: number;
|
|
treePendingCount: number;
|
|
tablePendingCount: number;
|
|
isLoading: boolean;
|
|
isTreeLoading: boolean;
|
|
isTableLoading: boolean;
|
|
startLoading: (scope?: "tree" | "table" | "global") => void;
|
|
endLoading: (scope?: "tree" | "table" | "global") => void;
|
|
};
|
|
|
|
export const useUiLoadingStore = create<UiLoadingState>((set, get) => ({
|
|
pendingCount: 0,
|
|
treePendingCount: 0,
|
|
tablePendingCount: 0,
|
|
isLoading: false,
|
|
isTreeLoading: false,
|
|
isTableLoading: false,
|
|
startLoading: (scope = "global") => {
|
|
const pendingNext = get().pendingCount + 1;
|
|
const treeNext = scope === "tree" ? get().treePendingCount + 1 : get().treePendingCount;
|
|
const tableNext = scope === "table" ? get().tablePendingCount + 1 : get().tablePendingCount;
|
|
set({
|
|
pendingCount: pendingNext,
|
|
treePendingCount: treeNext,
|
|
tablePendingCount: tableNext,
|
|
isLoading: pendingNext > 0,
|
|
isTreeLoading: treeNext > 0,
|
|
isTableLoading: tableNext > 0,
|
|
});
|
|
},
|
|
endLoading: (scope = "global") => {
|
|
const pendingNext = Math.max(0, get().pendingCount - 1);
|
|
const treeNext = scope === "tree" ? Math.max(0, get().treePendingCount - 1) : get().treePendingCount;
|
|
const tableNext = scope === "table" ? Math.max(0, get().tablePendingCount - 1) : get().tablePendingCount;
|
|
set({
|
|
pendingCount: pendingNext,
|
|
treePendingCount: treeNext,
|
|
tablePendingCount: tableNext,
|
|
isLoading: pendingNext > 0,
|
|
isTreeLoading: treeNext > 0,
|
|
isTableLoading: tableNext > 0,
|
|
});
|
|
},
|
|
}));
|
|
|
|
const TREE_LOADING_ENDPOINTS = [
|
|
"/api/get-biz-group2.do",
|
|
"/api/get-biz-group3.do",
|
|
];
|
|
|
|
const TABLE_LOADING_ENDPOINTS = [
|
|
"/api/get-account.do",
|
|
"/api/get-device.do",
|
|
"/api/get-goods.do",
|
|
"/api/get-goods-templates.do",
|
|
"/api/get-terminal.do",
|
|
"/api/get-transaction.do",
|
|
"/api/get-u-transaction.do",
|
|
"/api/get-sales.do",
|
|
"/api/get-notice.do",
|
|
"/api/get-voc.do",
|
|
"/api/get-resource-board.do",
|
|
];
|
|
|
|
const resolveLoadingScope = (url?: string): "tree" | "table" | "global" => {
|
|
if (!url) return "global";
|
|
if (TREE_LOADING_ENDPOINTS.some((x) => url.includes(x))) return "tree";
|
|
if (TABLE_LOADING_ENDPOINTS.some((x) => url.includes(x))) return "table";
|
|
return "global";
|
|
};
|
|
|
|
api.interceptors.request.use((config) => {
|
|
const token = useAuthStore.getState().accessToken;
|
|
if (token) {
|
|
config.headers = config.headers ?? {};
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
const loadingScope = resolveLoadingScope(config.url);
|
|
(config as AxiosRequestConfig & { __loadingScope?: "tree" | "table" | "global" }).__loadingScope = loadingScope;
|
|
useUiLoadingStore.getState().startLoading(loadingScope);
|
|
return config;
|
|
});
|
|
|
|
// refresh token part
|
|
// A single in-flight refresh call is shared by the 401 retry path and by the
|
|
// startup restore, so concurrent 401s never hit /api/sval2.do more than once.
|
|
let g_refreshPromise: Promise<string> | null = null;
|
|
|
|
const requestNewAccessToken = async (): Promise<string> => {
|
|
const resp = await api.post(TOK_REFRESH_URI);
|
|
|
|
// The refresh endpoint answers 200 with an error code when the account is gone or stopped
|
|
const newToken = resp?.data?.result?.sval1;
|
|
if (resp?.data?.errCode != 0 || !newToken)
|
|
throw new Error("token refresh failed, errCode: " + resp?.data?.errCode);
|
|
|
|
useAuthStore.getState().setAccessToken(newToken);
|
|
return newToken;
|
|
};
|
|
|
|
export const refreshAccessToken = (): Promise<string> => {
|
|
if (g_refreshPromise == null) {
|
|
g_refreshPromise = requestNewAccessToken().finally(() => {
|
|
g_refreshPromise = null;
|
|
});
|
|
}
|
|
|
|
return g_refreshPromise;
|
|
};
|
|
|
|
api.interceptors.response.use(
|
|
(response) => {
|
|
const loadingScope = (response.config as AxiosRequestConfig & { __loadingScope?: "tree" | "table" | "global" }).__loadingScope ?? resolveLoadingScope(response.config?.url);
|
|
useUiLoadingStore.getState().endLoading(loadingScope);
|
|
return response;
|
|
},
|
|
|
|
async (error: AxiosError) => {
|
|
const loadingScope = (error.config as AxiosRequestConfig & { __loadingScope?: "tree" | "table" | "global" } | undefined)?.__loadingScope ?? resolveLoadingScope(error.config?.url);
|
|
useUiLoadingStore.getState().endLoading(loadingScope);
|
|
const original = error.config as AxiosRequestConfig & { _retry?: boolean };
|
|
|
|
if (!original) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
const status = error.response?.status;
|
|
|
|
if (status !== 401) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
// The refresh call itself failed: drop the session and let the caller decide where to go
|
|
if (original.url === TOK_REFRESH_URI) {
|
|
useAuthStore.getState().clearAuth();
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
// Already retried once with a fresh token, do not loop
|
|
if (original._retry) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
original._retry = true;
|
|
|
|
try {
|
|
const newToken = await refreshAccessToken();
|
|
|
|
original.headers = original.headers ?? {};
|
|
original.headers.Authorization = `Bearer ${newToken}`;
|
|
return api(original);
|
|
|
|
} catch (refreshError) {
|
|
useAuthStore.getState().clearAuth();
|
|
window.location.href = SIGN_IN_PAGE_URI;
|
|
return Promise.reject(refreshError);
|
|
}
|
|
}
|
|
);
|
|
|
|
export const clearAuthStore = async () => {
|
|
useAuthStore.getState().clearAuth();
|
|
};
|
|
|
|
/**
|
|
* Restores the session from the sval2 refresh cookie on a full page load.
|
|
* Returns true when an access token is available afterwards.
|
|
*/
|
|
export const bootstrapAuth = async (): Promise<boolean> => {
|
|
if (useAuthStore.getState().accessToken != null)
|
|
return true;
|
|
|
|
useAuthStore.getState().setAuthStatus("loading");
|
|
|
|
try {
|
|
await refreshAccessToken();
|
|
return true;
|
|
}
|
|
catch (e) {
|
|
useAuthStore.getState().clearAuth();
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const refreshAuthStore = async () => {
|
|
await bootstrapAuth();
|
|
};
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
|
|
|
|
export const postFileDownload = (url: string, params: any) => {
|
|
api.post(url, params, {responseType: 'blob'}).then((resp) => {
|
|
const disposition = resp.headers['content-disposition'];
|
|
let filename : string = "";
|
|
if (disposition && disposition.indexOf('attachment') !== -1) {
|
|
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
|
|
const matches = filenameRegex.exec(disposition);
|
|
if (matches != null && matches[1])
|
|
filename = matches[1].replace(/['"]/g, '');
|
|
}
|
|
const blob = new Blob([resp.data], {type: resp.headers['content-type'] as string | undefined});
|
|
const link = document.createElement('a');
|
|
link.href = window.URL.createObjectURL(blob);
|
|
//link.download = filename;
|
|
link.download = decodeURIComponent(filename);
|
|
//link.download = unespace(filename);
|
|
link.click();
|
|
|
|
}).catch((err) => {
|
|
console.log(err);
|
|
alert(err.message);
|
|
});
|
|
}
|
|
|
|
// YYYY-MM-DD
|
|
export const todayYMD = () => {
|
|
const d = new Date();
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
export const addDaysYMD = (days: number) => {
|
|
const now = new Date();
|
|
const d = new Date(now);
|
|
d.setDate(d.getDate() + days);
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
// DB TIMESTAMP DATETIME modify (yyyy-MM-dd HH:mm:ss.0 -> yyyy-MM-dd HH:mm)
|
|
export const convertDateTime = (tsDateTime: string) => {
|
|
if (tsDateTime == undefined)
|
|
return "";
|
|
|
|
return tsDateTime.replace(".0", "");
|
|
}
|
|
|
|
// DB TIMESTAMP DATETIME modify (UTC time -> yyyy-MM-dd HH:mm:ss)
|
|
export const convertDateTime2 = (tsDateTime: string) => {
|
|
if (tsDateTime == undefined)
|
|
return "";
|
|
|
|
const localDate = new Date(tsDateTime);
|
|
const formatted = localDate.toLocaleString("ko-KR", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: false, // 0~23hour
|
|
});
|
|
|
|
return formatted;
|
|
}
|
|
|
|
// DB TIMESTAMP DATETIME modify (UTC time -> yyyy-MM-dd HH:mm)
|
|
export const convertDateTime3 = (tsDateTime: string) => {
|
|
if (tsDateTime == undefined || tsDateTime == "")
|
|
return "";
|
|
|
|
const localDate = new Date(tsDateTime);
|
|
const formatted = localDate.toLocaleString("ko-KR", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
//second: "2-digit",
|
|
hour12: false, // 0~23hour
|
|
});
|
|
|
|
return formatted;
|
|
}
|
|
|
|
// yyyyMMddHHmmss => yyyy-MM-dd HH:mm:ss
|
|
export const convertDateTime4 = (tsDateTime: string) => {
|
|
if (tsDateTime == undefined || tsDateTime == "")
|
|
return "";
|
|
|
|
const yyyy = tsDateTime.slice(0, 4);
|
|
const mm = tsDateTime.slice(4, 6);
|
|
const dd = tsDateTime.slice(6, 8);
|
|
const HH = tsDateTime.slice(8, 10);
|
|
const MM = tsDateTime.slice(10, 12);
|
|
const SS = tsDateTime.slice(12, 14);
|
|
|
|
return `${yyyy}-${mm}-${dd} ${HH}:${MM}:${SS}`;
|
|
}
|
|
|
|
// yyyyMMddHHmmss => yyyy-MM-dd HH:mm
|
|
export const convertDateTime5 = (tsDateTime: string) => {
|
|
if (tsDateTime == undefined || tsDateTime == "")
|
|
return "";
|
|
|
|
const yyyy = tsDateTime.slice(0, 4);
|
|
const mm = tsDateTime.slice(4, 6);
|
|
const dd = tsDateTime.slice(6, 8);
|
|
const HH = tsDateTime.slice(8, 10);
|
|
const MM = tsDateTime.slice(10, 12);
|
|
|
|
return `${yyyy}-${mm}-${dd} ${HH}:${MM}`;
|
|
}
|
|
|
|
// yyyyMMddHHmmss => yyyy-MM-dd HH:mm
|
|
export const convertDateTime6 = (tsDateTime: string) => {
|
|
if (tsDateTime == undefined || tsDateTime == "")
|
|
return "";
|
|
|
|
let startPos = 0;
|
|
let yy = tsDateTime.slice(startPos, startPos + 2);
|
|
if (yy == "20") {
|
|
startPos += 2;
|
|
yy = tsDateTime.slice(startPos, startPos + 2);
|
|
}
|
|
|
|
startPos += 2;
|
|
const mm = tsDateTime.slice(startPos, startPos + 2);
|
|
|
|
startPos += 2;
|
|
const dd = tsDateTime.slice(startPos, startPos + 2);
|
|
|
|
startPos += 2;
|
|
const HH = tsDateTime.slice(startPos, startPos + 2);
|
|
|
|
startPos += 2;
|
|
const MM = tsDateTime.slice(startPos, startPos + 2);
|
|
|
|
return `20${yy}-${mm}-${dd} ${HH}:${MM}`;
|
|
}
|
|
|
|
export const getUrlParam = (key: string) => {
|
|
if (window == undefined)
|
|
return "";
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
const value = params.get(key);
|
|
if (value == null)
|
|
return "";
|
|
|
|
return value;
|
|
}
|
|
|
|
// 사업자등록번호 하이픈(-) 자동삽입
|
|
//const formatted = formatBusinessNumber("1234567890"); // "123-45-67890"
|
|
export function formatBusinessNumber(input: string): string {
|
|
const allowed = input.replace(/[^0-9-]/g, "");
|
|
const digits = allowed.replace(/-/g, "").substring(0, 10);
|
|
|
|
let formatted = digits;
|
|
if (digits.length > 3 && digits.length <= 5) {
|
|
formatted = digits.replace(/^(\d{3})(\d{1,2})$/, "$1-$2");
|
|
} else if (digits.length > 5) {
|
|
formatted = digits.replace(/^(\d{3})(\d{2})(\d{1,5})$/, "$1-$2-$3");
|
|
}
|
|
|
|
if (allowed.endsWith("-") && !formatted.endsWith("-")) {
|
|
formatted += "-";
|
|
}
|
|
|
|
return formatted;
|
|
}
|
|
|
|
// 사업자등록번호 유효성 검사
|
|
//console.log(isValidBusinessNumber("123-45-67890")); // false
|
|
//console.log(isValidBusinessNumber("220-81-62517")); // true (실제 유효 번호 예시)
|
|
export function isValidBusinessNumber(input: string): boolean {
|
|
|
|
//return true;
|
|
|
|
const digits = input.replace(/\D/g, "");
|
|
if (digits.length !== 10)
|
|
return false;
|
|
|
|
const weights = [1, 3, 7, 1, 3, 7, 1, 3, 5];
|
|
let sum = 0;
|
|
|
|
for (let i = 0; i < 9; i++) {
|
|
const num = parseInt(digits.charAt(i), 10);
|
|
sum += num * weights[i];
|
|
}
|
|
|
|
sum += Math.floor((parseInt(digits.charAt(8), 10) * 5) / 10);
|
|
|
|
const checkDigit = (10 - (sum % 10)) % 10;
|
|
return checkDigit === parseInt(digits.charAt(9), 10);
|
|
}
|
|
|