Files
smartservice_web/smartservice_frontend/src/lib/_AG.ts
T
2026-09-16 13:35:29 +09:00

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);
}