Compare commits
84
Commits
c692f7c647
...
68a800cdfc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68a800cdfc | ||
|
|
ff45d1a875 | ||
|
|
49000c6140 | ||
|
|
24bba484ca | ||
|
|
c8d0d85937 | ||
|
|
ad1e517472 | ||
|
|
17ed5eb46b | ||
|
|
1a2faf02da | ||
|
|
d6522338aa | ||
|
|
df154a45b6 | ||
|
|
d438755616 | ||
|
|
4ee8d5a099 | ||
|
|
538f656640 | ||
|
|
8df79fd624 | ||
|
|
730be25948 | ||
|
|
9c38165afb | ||
|
|
9242e1fb47 | ||
|
|
271b5d445d | ||
|
|
2457d14ae9 | ||
|
|
9739609de4 | ||
|
|
c799c199c5 | ||
|
|
5b3aaa463d | ||
|
|
947dba5934 | ||
|
|
4816a512bf | ||
|
|
4fad163b14 | ||
|
|
da2191f365 | ||
|
|
55ab2b8562 | ||
|
|
526392c370 | ||
|
|
6020c80a7a | ||
|
|
32b778f961 | ||
|
|
3946fda013 | ||
|
|
db30b45267 | ||
|
|
6cc2a02df4 | ||
|
|
0f107ff29d | ||
|
|
5e69a9b6f0 | ||
|
|
ec9311c76b | ||
|
|
4c85a7d249 | ||
|
|
09d4c2d26e | ||
|
|
b8dac5dc72 | ||
|
|
73cafc9786 | ||
|
|
c276ae0c5e | ||
|
|
b4ed061a53 | ||
|
|
ddaaa8fc92 | ||
|
|
3a253df58a | ||
|
|
21debe2f77 | ||
|
|
0e4f662dfd | ||
|
|
4a797808c1 | ||
|
|
3a81b822f2 | ||
|
|
f5a7efb324 | ||
|
|
abc3ca184d | ||
|
|
267c022d58 | ||
|
|
0fc2844928 | ||
|
|
7f1fe42ace | ||
|
|
c83a13d680 | ||
|
|
3d7dcb6d0e | ||
|
|
a46f24d6d7 | ||
|
|
2e4f2428f0 | ||
|
|
706cebfeae | ||
|
|
ee7f5e3a07 | ||
|
|
3d0160f21f | ||
|
|
0aa0126caf | ||
|
|
5ecdd37187 | ||
|
|
20cc2ca248 | ||
|
|
5cfd7d65ec | ||
|
|
3b8307b674 | ||
|
|
2f800c960d | ||
|
|
2728b4058b | ||
|
|
b842905572 | ||
|
|
85fa9a8658 | ||
|
|
5ae56481c2 | ||
|
|
8079d7d6af | ||
|
|
7c8eef1715 | ||
|
|
f10b3179c2 | ||
|
|
2e0bb5aa84 | ||
|
|
7ae8d42731 | ||
|
|
a9e65af77b | ||
|
|
d21ecc975f | ||
|
|
452509281a | ||
|
|
2e3602eee6 | ||
|
|
7dfd535841 | ||
|
|
39d928b5b1 | ||
|
|
525695072a | ||
|
|
972a4ce789 | ||
|
|
879aadc068 |
@@ -0,0 +1,3 @@
|
||||
import { NtitlkSndngPage } from "@/pages/ntitlk-sndng";
|
||||
|
||||
export default NtitlkSndngPage;
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DashboardMainResponseDto,
|
||||
DashboardMainSearchDto,
|
||||
DashboardService,
|
||||
InPrgrsDocPicResponseDto,
|
||||
} from "../model/dashboard-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.DASHBOARD;
|
||||
@@ -18,6 +19,14 @@ async function getMainDashboard(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getInPrgrsDocPicList(searchYear?: number): Promise<InPrgrsDocPicResponseDto[]> {
|
||||
const response = await client.get<InPrgrsDocPicResponseDto[]>(`${BASE}/in-progress-docs`, {
|
||||
params: { searchYear },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const dashboardService = {
|
||||
getMainDashboard,
|
||||
getInPrgrsDocPicList,
|
||||
} satisfies DashboardService;
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { DashboardMainSearchDto } from "../model/dashboard-interface";
|
||||
import type {
|
||||
DashboardMainSearchDto,
|
||||
InPrgrsDocPicResponseDto,
|
||||
} from "../model/dashboard-interface";
|
||||
|
||||
import { dashboardService } from "./dashboard-service";
|
||||
|
||||
@@ -16,3 +19,14 @@ export function useGetMainDashboard(params: DashboardMainSearchDto = EMPTY_SEARC
|
||||
queryFn: () => dashboardService.getMainDashboard(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetInPrgrsDocPicList<TData = InPrgrsDocPicResponseDto[]>(
|
||||
searchYear?: number,
|
||||
options?: { select?: (data: InPrgrsDocPicResponseDto[]) => TData },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...dashboardBaseKey, "getInPrgrsDocPicList", searchYear],
|
||||
queryFn: () => dashboardService.getInPrgrsDocPicList(searchYear),
|
||||
select: options?.select,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export { dashboardService } from "./api/dashboard-service";
|
||||
export { dashboardBaseKey, useGetMainDashboard } from "./api/queries";
|
||||
export { dashboardBaseKey, useGetInPrgrsDocPicList, useGetMainDashboard } from "./api/queries";
|
||||
export type {
|
||||
BasinDocPrgrsResponseDto,
|
||||
DashboardMainResponseDto,
|
||||
DashboardMainSearchDto,
|
||||
DashboardService,
|
||||
DocPrgrsSttsResponseDto,
|
||||
InPrgrsDocPicResponseDto,
|
||||
NotiAsStatResponseDto,
|
||||
NotiAsUserResponseDto,
|
||||
} from "./model/dashboard-interface";
|
||||
|
||||
@@ -65,7 +65,22 @@ export interface DashboardMainResponseDto {
|
||||
notiStats: NotiAsStatResponseDto;
|
||||
}
|
||||
|
||||
/** 백엔드 InPrgrsDocPicResponseDto */
|
||||
export interface InPrgrsDocPicResponseDto {
|
||||
docNo: string;
|
||||
docNm?: string;
|
||||
docSeComCd: string;
|
||||
docSeNm?: string;
|
||||
aplcntNm?: string;
|
||||
aplcntMblTelno?: string;
|
||||
prgrsSttsComCd?: string;
|
||||
prgrsSttsNm?: string;
|
||||
regDt?: string;
|
||||
prcsDdln?: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface DashboardService {
|
||||
getMainDashboard(searchDto: DashboardMainSearchDto): Promise<DashboardMainResponseDto>;
|
||||
getInPrgrsDocPicList(searchYear?: number): Promise<InPrgrsDocPicResponseDto[]>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
BusinessSendRequest,
|
||||
BusinessSendResponse,
|
||||
NtftalkService,
|
||||
NtftalkTemplate,
|
||||
NtftalkTemplateSearchDto,
|
||||
TemplateVariablesResponse,
|
||||
} from "../model/ntftalk-interface";
|
||||
|
||||
const { TEMP, ATA_SEND } = ENDPOINT.NTFTALK_SERVICE;
|
||||
|
||||
async function findTemplatePage(
|
||||
params: NtftalkTemplateSearchDto,
|
||||
): Promise<PageResponse<NtftalkTemplate>> {
|
||||
const response = await client.get<PageResponse<NtftalkTemplate>>(TEMP, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTemplateVariables(templateCode: string): Promise<TemplateVariablesResponse> {
|
||||
const response = await client.get<TemplateVariablesResponse>(
|
||||
`${ATA_SEND}/template-variables/${templateCode}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function sendBusiness(body: BusinessSendRequest): Promise<BusinessSendResponse> {
|
||||
const response = await client.post<BusinessSendResponse>(`${ATA_SEND}/business`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const ntftalkService = {
|
||||
findTemplatePage,
|
||||
findTemplateVariables,
|
||||
sendBusiness,
|
||||
} satisfies NtftalkService;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useMutationWrapper } from "@/shared/lib";
|
||||
|
||||
import type { BusinessSendRequest, NtftalkTemplateSearchDto } from "../model/ntftalk-interface";
|
||||
|
||||
import { ntftalkService } from "./ntftalk-service";
|
||||
|
||||
/** 발송 화면에서 선택 가능한 템플릿: 검수 승인(APR)된 기본형(BA) */
|
||||
const SNDNG_TEMPLATE_SEARCH: NtftalkTemplateSearchDto = {
|
||||
sort: "createdAt,desc",
|
||||
senderKey: "",
|
||||
inspectionStatus: "APR",
|
||||
templateMessageType: "BA",
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
export function useNtftalkTemplateList() {
|
||||
return useQuery({
|
||||
queryKey: [ENDPOINT.NTFTALK_SERVICE.TEMP, "findTemplatePage", SNDNG_TEMPLATE_SEARCH],
|
||||
queryFn: () => ntftalkService.findTemplatePage(SNDNG_TEMPLATE_SEARCH),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNtftalkTemplateVariables(templateCode: string) {
|
||||
return useQuery({
|
||||
queryKey: [ENDPOINT.NTFTALK_SERVICE.ATA_SEND, "findTemplateVariables", templateCode],
|
||||
queryFn: () => ntftalkService.findTemplateVariables(templateCode),
|
||||
enabled: Boolean(templateCode),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendNtftalkBusiness() {
|
||||
return useMutationWrapper({
|
||||
mutationFn: (body: BusinessSendRequest) => ntftalkService.sendBusiness(body),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { ntftalkService } from "./api/ntftalk-service";
|
||||
export {
|
||||
useNtftalkTemplateList,
|
||||
useNtftalkTemplateVariables,
|
||||
useSendNtftalkBusiness,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
BusinessSendRequest,
|
||||
BusinessSendResponse,
|
||||
NtftalkService,
|
||||
NtftalkTemplate,
|
||||
NtftalkTemplateSearchDto,
|
||||
TemplateVariablesResponse,
|
||||
} from "./model/ntftalk-interface";
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
/** 백엔드 알림톡 템플릿 응답 */
|
||||
export interface NtftalkTemplate {
|
||||
templateCode: string;
|
||||
templateName: string;
|
||||
templateContent: string;
|
||||
}
|
||||
|
||||
/** 템플릿 목록 검색 조건 + Pageable */
|
||||
export interface NtftalkTemplateSearchDto extends PageableParams {
|
||||
senderKey?: string;
|
||||
inspectionStatus?: string;
|
||||
templateMessageType?: string;
|
||||
}
|
||||
|
||||
export interface TemplateVariablesResponse {
|
||||
templateCode: string;
|
||||
variables: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BusinessSendRequest {
|
||||
templateCode: string;
|
||||
callback?: string;
|
||||
fixedRecipientNum?: string;
|
||||
recipients: Record<string, string>[];
|
||||
}
|
||||
|
||||
export interface BusinessSendResponse {
|
||||
success: boolean;
|
||||
sentCount: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface NtftalkService {
|
||||
findTemplatePage(params: NtftalkTemplateSearchDto): Promise<PageResponse<NtftalkTemplate>>;
|
||||
findTemplateVariables(templateCode: string): Promise<TemplateVariablesResponse>;
|
||||
sendBusiness(body: BusinessSendRequest): Promise<BusinessSendResponse>;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
RvwMnpwMngPageSearchDto,
|
||||
RvwMnpwMngResponseDto,
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
@@ -13,14 +15,18 @@ import { rvwMnpwMngService } from "./rvw-mnpw-service";
|
||||
|
||||
const rvwMnpwMngBaseKey = [ENDPOINT.AS_SERVICE.RVW_MNPW] as const;
|
||||
|
||||
export function useRvwMnpwMngPage(
|
||||
export function useRvwMnpwMngPage<TData = PageResponse<RvwMnpwMngResponseDto>>(
|
||||
params: RvwMnpwMngPageSearchDto,
|
||||
options?: { enabled?: boolean },
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
select?: (data: PageResponse<RvwMnpwMngResponseDto>) => TData;
|
||||
},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
|
||||
queryFn: () => rvwMnpwMngService.findPage(params),
|
||||
enabled: options?.enabled,
|
||||
select: options?.select,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface RvwMnpwMngResponseDto {
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
userUuidNm?: string;
|
||||
mblTelno?: string;
|
||||
ognzNm?: string;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
|
||||
@@ -78,6 +78,15 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.svgContainer :global(svg) :global(.basin:focus-visible) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.svgContainer :global(svg) :global(.basin:focus-visible) :global(path) {
|
||||
stroke: #111827;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.svgContainer :global(svg) :global(.basin) :global(path),
|
||||
.svgContainer :global(svg) :global(.basin) :global(polygon),
|
||||
.svgContainer :global(svg) :global(.basin) :global(polyline) {
|
||||
|
||||
@@ -265,20 +265,44 @@ export function useNationalAsMap(basinDocPrgrsList: BasinDocPrgrsResponseDto[] |
|
||||
};
|
||||
|
||||
groups.forEach((group) => {
|
||||
addListener(group, "mouseenter", () => {
|
||||
group.setAttribute("role", "button");
|
||||
group.setAttribute("tabindex", "0");
|
||||
group.setAttribute(
|
||||
"aria-label",
|
||||
`${resolveBasinName(group)} 행정지원 현황 보기`,
|
||||
);
|
||||
|
||||
const activateGroup = () => {
|
||||
clearActive?.();
|
||||
group.classList.add("active");
|
||||
revealGroup(group);
|
||||
};
|
||||
const clearGroupIfInactive = () => {
|
||||
if (!group.matches(":hover") && document.activeElement !== group) {
|
||||
clearActive?.();
|
||||
}
|
||||
};
|
||||
|
||||
addListener(group, "mouseenter", () => {
|
||||
activateGroup();
|
||||
});
|
||||
addListener(group, "mousemove", () => {
|
||||
if (group.classList.contains("active")) revealGroup(group);
|
||||
});
|
||||
addListener(group, "mouseleave", () => clearActive?.());
|
||||
addListener(group, "mouseleave", clearGroupIfInactive);
|
||||
addListener(group, "focus", activateGroup);
|
||||
addListener(group, "blur", clearGroupIfInactive);
|
||||
addListener(group, "click", (event) => {
|
||||
event.stopPropagation();
|
||||
clearActive?.();
|
||||
group.classList.add("active");
|
||||
revealGroup(group);
|
||||
activateGroup();
|
||||
});
|
||||
addListener(group, "keydown", (event) => {
|
||||
const keyboardEvent = event as KeyboardEvent;
|
||||
if (keyboardEvent.key !== "Enter" && keyboardEvent.key !== " ") {
|
||||
return;
|
||||
}
|
||||
keyboardEvent.preventDefault();
|
||||
activateGroup();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
|
||||
},
|
||||
ROLE_USER04: {
|
||||
ST: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct],
|
||||
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv, FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
|
||||
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv],
|
||||
},
|
||||
ROLE_USER03: {
|
||||
LE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { NtitlkSndngPage } from "./ui/ntitlk-sndng-page";
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
|
||||
|
||||
/** 템플릿 본문의 `#{변수}`를 값으로 치환한다. 값이 없는 변수는 빈 문자열이 된다. */
|
||||
export function buildTemplateMessage(
|
||||
templateContent: string,
|
||||
variables: Record<string, string>,
|
||||
): string {
|
||||
return templateContent.replace(/#?\{([^}]+)\}/g, (_, key: string) => variables[key.trim()] ?? "");
|
||||
}
|
||||
|
||||
export function getMergedVariables(
|
||||
recipient: SelectedNtitlkRcvr,
|
||||
commonValues: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...commonValues,
|
||||
...recipient.variableOverrides,
|
||||
TEL_NUM: recipient.telNo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
import type { InPrgrsDocPicResponseDto } from "@/entities/dashboard";
|
||||
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
|
||||
|
||||
import type { NtitlkRcvr } from "../model/ntitlk-rcvr";
|
||||
|
||||
/** 검토인력은 결재구분별로 행이 나뉘어 내려오므로 사용자 기준으로 한 명씩만 남긴다. */
|
||||
export function selectRvwMnpwRcvrList(page: PageResponse<RvwMnpwMngResponseDto>): NtitlkRcvr[] {
|
||||
const rcvrByUserUuid = new Map<string, NtitlkRcvr>();
|
||||
|
||||
page.content.forEach((row) => {
|
||||
if (rcvrByUserUuid.has(row.userUuid)) return;
|
||||
|
||||
rcvrByUserUuid.set(row.userUuid, {
|
||||
rcvrKey: `rvw:${row.userUuid}`,
|
||||
userNm: row.userUuidNm ?? "",
|
||||
telNo: row.mblTelno ?? "",
|
||||
ognzNm: row.ognzNm,
|
||||
});
|
||||
});
|
||||
|
||||
return [...rcvrByUserUuid.values()];
|
||||
}
|
||||
|
||||
export function selectInPrgrsDocRcvrList(rows: InPrgrsDocPicResponseDto[]): NtitlkRcvr[] {
|
||||
return rows.map((row) => ({
|
||||
rcvrKey: `doc:${row.docSeComCd}:${row.docNo}`,
|
||||
userNm: row.aplcntNm ?? "",
|
||||
telNo: row.aplcntMblTelno ?? "",
|
||||
docNo: row.docNo,
|
||||
docNm: row.docNm,
|
||||
docSeNm: row.docSeNm,
|
||||
prgrsSttsNm: row.prgrsSttsNm,
|
||||
prcsDdln: row.prcsDdln?.slice(0, 10),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
|
||||
|
||||
interface UseRcvrRowSelectionOptions {
|
||||
rcvrList: NtitlkRcvr[];
|
||||
selectedRcvrKeys: Set<string>;
|
||||
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 목록(페이지 상태)을 수신자 테이블의 행 선택 상태로 연결한다.
|
||||
* 선택 상태를 테이블이 따로 들고 있지 않아, 발송 목록에서 제거하면 체크도 함께 풀린다.
|
||||
*/
|
||||
export function useRcvrRowSelection({
|
||||
rcvrList,
|
||||
selectedRcvrKeys,
|
||||
onRcvrSelectionChange,
|
||||
}: UseRcvrRowSelectionOptions) {
|
||||
const rowSelection = useMemo(() => {
|
||||
const selection: RowSelectionState = {};
|
||||
rcvrList.forEach((rcvr) => {
|
||||
if (selectedRcvrKeys.has(rcvr.rcvrKey)) selection[rcvr.rcvrKey] = true;
|
||||
});
|
||||
return selection;
|
||||
}, [rcvrList, selectedRcvrKeys]);
|
||||
|
||||
const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {
|
||||
const next = typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
|
||||
onRcvrSelectionChange({
|
||||
added: rcvrList.filter((rcvr) => next[rcvr.rcvrKey] && !rowSelection[rcvr.rcvrKey]),
|
||||
removedKeys: rcvrList
|
||||
.filter((rcvr) => !next[rcvr.rcvrKey] && rowSelection[rcvr.rcvrKey])
|
||||
.map((rcvr) => rcvr.rcvrKey),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
selectedCount: Object.keys(rowSelection).length,
|
||||
rowSelectionOptions: {
|
||||
state: { rowSelection },
|
||||
// 전화번호가 없으면 발송할 수 없으므로 선택 대상에서 제외한다.
|
||||
enableRowSelection: (row: Row<NtitlkRcvr>) => Boolean(row.original.telNo),
|
||||
enableMultiRowSelection: true,
|
||||
getRowId: (rcvr: NtitlkRcvr) => rcvr.rcvrKey,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 검토인력·문서 담당자 목록을 발송 목록 하나로 합치기 위한 수신자 모델 */
|
||||
export interface NtitlkRcvr {
|
||||
/** 행 식별자: 검토인력 `rvw:{userUuid}`, 문서 담당자 `doc:{docSeComCd}:{docNo}` */
|
||||
rcvrKey: string;
|
||||
userNm: string;
|
||||
telNo: string;
|
||||
ognzNm?: string;
|
||||
docNo?: string;
|
||||
docNm?: string;
|
||||
docSeNm?: string;
|
||||
prgrsSttsNm?: string;
|
||||
prcsDdln?: string;
|
||||
}
|
||||
|
||||
export interface SelectedNtitlkRcvr extends NtitlkRcvr {
|
||||
variableOverrides: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 수신자 목록에서 선택 상태가 바뀐 행 */
|
||||
export interface RcvrSelectionChange {
|
||||
added: NtitlkRcvr[];
|
||||
removedKeys: string[];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { Checkbox } from "@common/components";
|
||||
|
||||
import type { NtitlkRcvr } from "./ntitlk-rcvr";
|
||||
|
||||
const columnHelper = createColumnHelper<NtitlkRcvr>();
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
const selectColumn = columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllRowsSelected()
|
||||
? true
|
||||
: table.getIsSomeRowsSelected()
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
),
|
||||
meta: { align: "center", width: 48 },
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
|
||||
aria-label="행 선택"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const rvwMnpwRcvrColumns = [
|
||||
selectColumn,
|
||||
columnHelper.accessor("userNm", {
|
||||
header: "이름",
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("ognzNm", {
|
||||
header: "소속",
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("telNo", {
|
||||
header: "전화번호",
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
];
|
||||
|
||||
export const inPrgrsDocRcvrColumns = [
|
||||
selectColumn,
|
||||
columnHelper.accessor("docNo", {
|
||||
header: "문서번호",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("docNm", {
|
||||
header: "문서명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("userNm", {
|
||||
header: "신청자",
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("prgrsSttsNm", {
|
||||
header: "상태",
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NtitlkRcvr } from "./ntitlk-rcvr";
|
||||
|
||||
export const CUSTOM_VRBL_MAPPING = "custom";
|
||||
|
||||
export type VrblMappingKey = Exclude<keyof NtitlkRcvr, "rcvrKey"> | typeof CUSTOM_VRBL_MAPPING;
|
||||
|
||||
interface VrblMappingOption {
|
||||
value: VrblMappingKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 변수 일괄 적용 시 템플릿 변수에 채울 수신자 필드 */
|
||||
export const VRBL_MAPPING_OPTIONS: readonly VrblMappingOption[] = [
|
||||
{ value: "userNm", label: "이름" },
|
||||
{ value: "telNo", label: "전화번호" },
|
||||
{ value: "ognzNm", label: "소속" },
|
||||
{ value: "docNo", label: "문서번호" },
|
||||
{ value: "docNm", label: "문서명" },
|
||||
{ value: "docSeNm", label: "문서구분" },
|
||||
{ value: "prgrsSttsNm", label: "진행상태" },
|
||||
{ value: "prcsDdln", label: "처리기한" },
|
||||
{ value: CUSTOM_VRBL_MAPPING, label: "직접입력" },
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getCoreRowModel } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useGetInPrgrsDocPicList } from "@/entities/dashboard";
|
||||
|
||||
import { selectInPrgrsDocRcvrList } from "../lib/to-ntitlk-rcvr";
|
||||
import { useRcvrRowSelection } from "../lib/use-rcvr-row-selection";
|
||||
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
|
||||
import { inPrgrsDocRcvrColumns } from "../model/rcvr-table-columns";
|
||||
|
||||
import { RcvrListCard } from "./rcvr-list-card";
|
||||
|
||||
const EMPTY_RCVR_LIST: NtitlkRcvr[] = [];
|
||||
|
||||
interface InPrgrsDocRcvrListProps {
|
||||
selectedRcvrKeys: Set<string>;
|
||||
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
|
||||
}
|
||||
|
||||
function InPrgrsDocRcvrList({ selectedRcvrKeys, onRcvrSelectionChange }: InPrgrsDocRcvrListProps) {
|
||||
const rcvrQuery = useGetInPrgrsDocPicList(undefined, { select: selectInPrgrsDocRcvrList });
|
||||
|
||||
const { selectedCount, rowSelectionOptions } = useRcvrRowSelection({
|
||||
rcvrList: rcvrQuery.data ?? EMPTY_RCVR_LIST,
|
||||
selectedRcvrKeys,
|
||||
onRcvrSelectionChange,
|
||||
});
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: rcvrQuery,
|
||||
tableOptions: {
|
||||
columns: inPrgrsDocRcvrColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...rowSelectionOptions,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<RcvrListCard title="현재 진행중 문서 담당자(신청자)" selectedCount={selectedCount}>
|
||||
{tableComponent}
|
||||
</RcvrListCard>
|
||||
);
|
||||
}
|
||||
|
||||
export { InPrgrsDocRcvrList };
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@common/components";
|
||||
|
||||
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
|
||||
|
||||
import { NtitlkPrvw } from "./ntitlk-prvw";
|
||||
|
||||
interface NtitlkPrvwDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
recipient: SelectedNtitlkRcvr | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function NtitlkPrvwDialog({ open, onOpenChange, recipient, message }: NtitlkPrvwDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[36rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>알림톡 미리보기</DialogTitle>
|
||||
<DialogDescription className="text-[1.1rem] tracking-tight">
|
||||
수신자: {recipient?.userNm || "-"} / {recipient?.telNo || "-"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<NtitlkPrvw message={message || "메시지를 생성할 수 없습니다."} />
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" color="white">
|
||||
닫기
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { NtitlkPrvwDialog };
|
||||
@@ -0,0 +1,25 @@
|
||||
const SNDR_NM = "디지털 행정지원 시스템";
|
||||
|
||||
interface NtitlkPrvwProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
function NtitlkPrvw({ message }: NtitlkPrvwProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="h-[32rem] overflow-auto rounded-[20px] rounded-b-none bg-[rgb(176,192,207)] p-8 pb-0 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-full before:rounded-[20px] before:rounded-b-none before:border-[10px] before:border-b-0 before:border-black before:content-['']">
|
||||
<div className="w-[calc(100%-2rem)] pb-5">
|
||||
<span className="text-[1.1rem] text-black">{SNDR_NM}</span>
|
||||
<div className="mt-2 rounded-t-[12px] bg-[rgb(247,228,54)] p-4 text-[1.2rem] font-medium text-black">
|
||||
알림톡 도착
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap rounded-b-[12px] bg-white p-4 text-[1.2rem] leading-normal tracking-tight text-black">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NtitlkPrvw };
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
type NtftalkTemplate,
|
||||
useNtftalkTemplateList,
|
||||
useNtftalkTemplateVariables,
|
||||
useSendNtftalkBusiness,
|
||||
} from "@/entities/ntftalk";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { buildTemplateMessage, getMergedVariables } from "../lib/build-tmplt-msg";
|
||||
import type { RcvrSelectionChange, SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
|
||||
import { CUSTOM_VRBL_MAPPING, type VrblMappingKey } from "../model/vrbl-mapping-options";
|
||||
|
||||
import { InPrgrsDocRcvrList } from "./in-prgrs-doc-rcvr-list";
|
||||
import { NtitlkPrvwDialog } from "./ntitlk-prvw-dialog";
|
||||
import { RvwMnpwRcvrList } from "./rvw-mnpw-rcvr-list";
|
||||
import { SndngList } from "./sndng-list";
|
||||
import { TmpltSelectPanel } from "./tmplt-select-panel";
|
||||
import { VrblBulkApply } from "./vrbl-bulk-apply";
|
||||
|
||||
const SNDNG_CALLBACK_TELNO = "032-590-4000";
|
||||
const EMPTY_TEMPLATES: NtftalkTemplate[] = [];
|
||||
const EMPTY_VARIABLE_NAMES: string[] = [];
|
||||
|
||||
function NtitlkSndngPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const [selectedTemplateCode, setSelectedTemplateCode] = useState("");
|
||||
const [selectedRecipients, setSelectedRecipients] = useState<SelectedNtitlkRcvr[]>([]);
|
||||
const [commonVariableValues, setCommonVariableValues] = useState<Record<string, string>>({});
|
||||
const [commonVariableMapping, setCommonVariableMapping] = useState<
|
||||
Record<string, VrblMappingKey>
|
||||
>({});
|
||||
const [previewRecipient, setPreviewRecipient] = useState<SelectedNtitlkRcvr | null>(null);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
|
||||
const { data: templatePage } = useNtftalkTemplateList();
|
||||
const templates = templatePage?.content ?? EMPTY_TEMPLATES;
|
||||
const selectedTemplate = templates.find(
|
||||
(template) => template.templateCode === selectedTemplateCode,
|
||||
);
|
||||
|
||||
const { data: templateVariables } = useNtftalkTemplateVariables(selectedTemplateCode);
|
||||
const variableNames = templateVariables?.variables ?? EMPTY_VARIABLE_NAMES;
|
||||
|
||||
const { mutate: sendNtftalk, isPending: isSending } = useSendNtftalkBusiness();
|
||||
|
||||
const selectedRcvrKeys = useMemo(
|
||||
() => new Set(selectedRecipients.map((recipient) => recipient.rcvrKey)),
|
||||
[selectedRecipients],
|
||||
);
|
||||
|
||||
const previewMessage =
|
||||
previewRecipient && selectedTemplate?.templateContent
|
||||
? buildTemplateMessage(
|
||||
selectedTemplate.templateContent,
|
||||
getMergedVariables(previewRecipient, commonVariableValues),
|
||||
)
|
||||
: "";
|
||||
|
||||
const handleTemplateChange = (templateCode: string) => {
|
||||
setSelectedTemplateCode(templateCode);
|
||||
setCommonVariableValues({});
|
||||
setCommonVariableMapping({});
|
||||
setSelectedRecipients((prev) =>
|
||||
prev.map((recipient) => ({ ...recipient, variableOverrides: {} })),
|
||||
);
|
||||
};
|
||||
|
||||
const handleRcvrSelectionChange = ({ added, removedKeys }: RcvrSelectionChange) => {
|
||||
setSelectedRecipients((prev) => {
|
||||
const removedKeySet = new Set(removedKeys);
|
||||
const existingKeySet = new Set(prev.map((recipient) => recipient.rcvrKey));
|
||||
|
||||
return [
|
||||
...prev.filter((recipient) => !removedKeySet.has(recipient.rcvrKey)),
|
||||
...added
|
||||
.filter((rcvr) => !existingKeySet.has(rcvr.rcvrKey))
|
||||
.map((rcvr) => ({ ...rcvr, variableOverrides: {} })),
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveRecipient = (rcvrKey: string) => {
|
||||
setSelectedRecipients((prev) => prev.filter((recipient) => recipient.rcvrKey !== rcvrKey));
|
||||
};
|
||||
|
||||
const handleApplyCommonValues = () => {
|
||||
setSelectedRecipients((prev) =>
|
||||
prev.map((recipient) => {
|
||||
const newOverrides: Record<string, string> = {};
|
||||
|
||||
variableNames.forEach((variableName) => {
|
||||
const mappingKey = commonVariableMapping[variableName];
|
||||
if (!mappingKey) return;
|
||||
|
||||
newOverrides[variableName] =
|
||||
mappingKey === CUSTOM_VRBL_MAPPING
|
||||
? (commonVariableValues[variableName] ?? "")
|
||||
: (recipient[mappingKey] ?? "");
|
||||
});
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
variableOverrides: { ...recipient.variableOverrides, ...newOverrides },
|
||||
};
|
||||
}),
|
||||
);
|
||||
setCommonVariableMapping({});
|
||||
};
|
||||
|
||||
const handleResetCommonValues = () => {
|
||||
setCommonVariableMapping({});
|
||||
setCommonVariableValues({});
|
||||
};
|
||||
|
||||
const handleRecipientVariableChange = (rcvrKey: string, variableName: string, value: string) => {
|
||||
setSelectedRecipients((prev) =>
|
||||
prev.map((recipient) =>
|
||||
recipient.rcvrKey === rcvrKey
|
||||
? {
|
||||
...recipient,
|
||||
variableOverrides: { ...recipient.variableOverrides, [variableName]: value },
|
||||
}
|
||||
: recipient,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handlePreview = (recipient: SelectedNtitlkRcvr) => {
|
||||
if (!selectedTemplate) {
|
||||
alert("알림톡 템플릿을 먼저 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
setPreviewRecipient(recipient);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!selectedTemplateCode) {
|
||||
alert("알림톡 템플릿을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMissingVariable = selectedRecipients.some((recipient) =>
|
||||
variableNames.some((variableName) => {
|
||||
const mergedValue =
|
||||
recipient.variableOverrides[variableName] ?? commonVariableValues[variableName] ?? "";
|
||||
return mergedValue.trim() === "";
|
||||
}),
|
||||
);
|
||||
if (hasMissingVariable) {
|
||||
alert("변수값이 입력되지 않은 발송 대상이 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await confirm(`${selectedRecipients.length}명에게 발송하시겠습니까?`))) return;
|
||||
|
||||
sendNtftalk(
|
||||
{
|
||||
templateCode: selectedTemplateCode,
|
||||
callback: SNDNG_CALLBACK_TELNO,
|
||||
recipients: selectedRecipients.map((recipient) =>
|
||||
getMergedVariables(recipient, commonVariableValues),
|
||||
),
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
alert(data.message || "알림톡 발송 요청에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
alert(data.message || "알림톡 발송이 요청되었습니다.");
|
||||
setSelectedRecipients([]);
|
||||
setCommonVariableValues({});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-[1.8rem] pb-6">
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[32rem_minmax(0,1fr)]">
|
||||
<TmpltSelectPanel
|
||||
templates={templates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
onTemplateChange={handleTemplateChange}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<RvwMnpwRcvrList
|
||||
selectedRcvrKeys={selectedRcvrKeys}
|
||||
onRcvrSelectionChange={handleRcvrSelectionChange}
|
||||
/>
|
||||
<InPrgrsDocRcvrList
|
||||
selectedRcvrKeys={selectedRcvrKeys}
|
||||
onRcvrSelectionChange={handleRcvrSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="card card-border m-0 bg-background p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">발송 목록</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={() => setSelectedRecipients([])}
|
||||
disabled={selectedRecipients.length === 0}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
onClick={handleSend}
|
||||
disabled={isSending || selectedRecipients.length === 0}
|
||||
>
|
||||
발송
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{variableNames.length > 0 && (
|
||||
<VrblBulkApply
|
||||
variableNames={variableNames}
|
||||
mapping={commonVariableMapping}
|
||||
customValues={commonVariableValues}
|
||||
onMappingChange={(variableName, mappingKey) =>
|
||||
setCommonVariableMapping((prev) => ({ ...prev, [variableName]: mappingKey }))
|
||||
}
|
||||
onCustomValueChange={(variableName, value) =>
|
||||
setCommonVariableValues((prev) => ({ ...prev, [variableName]: value }))
|
||||
}
|
||||
onApply={handleApplyCommonValues}
|
||||
onReset={handleResetCommonValues}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SndngList
|
||||
recipients={selectedRecipients}
|
||||
variableNames={variableNames}
|
||||
onVariableChange={handleRecipientVariableChange}
|
||||
onPreview={handlePreview}
|
||||
onRemove={handleRemoveRecipient}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<NtitlkPrvwDialog
|
||||
open={isPreviewOpen}
|
||||
onOpenChange={setIsPreviewOpen}
|
||||
recipient={previewRecipient}
|
||||
message={previewMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NtitlkSndngPage };
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface RcvrListCardProps {
|
||||
title: string;
|
||||
selectedCount: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function RcvrListCard({ title, selectedCount, children }: RcvrListCardProps) {
|
||||
return (
|
||||
<section className="card card-border m-0 bg-background p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">{title}</h3>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{selectedCount}명</span> 선택
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-[16rem] overflow-auto rounded-md border">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { RcvrListCard };
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getCoreRowModel } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
|
||||
|
||||
import { selectRvwMnpwRcvrList } from "../lib/to-ntitlk-rcvr";
|
||||
import { useRcvrRowSelection } from "../lib/use-rcvr-row-selection";
|
||||
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
|
||||
import { rvwMnpwRcvrColumns } from "../model/rcvr-table-columns";
|
||||
|
||||
import { RcvrListCard } from "./rcvr-list-card";
|
||||
|
||||
const RVW_MNPW_SEARCH = { page: 0, size: 1000, sort: "userUuidNm,asc" };
|
||||
const EMPTY_RCVR_LIST: NtitlkRcvr[] = [];
|
||||
|
||||
interface RvwMnpwRcvrListProps {
|
||||
selectedRcvrKeys: Set<string>;
|
||||
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
|
||||
}
|
||||
|
||||
function RvwMnpwRcvrList({ selectedRcvrKeys, onRcvrSelectionChange }: RvwMnpwRcvrListProps) {
|
||||
const rcvrQuery = useRvwMnpwMngPage(RVW_MNPW_SEARCH, { select: selectRvwMnpwRcvrList });
|
||||
|
||||
const { selectedCount, rowSelectionOptions } = useRcvrRowSelection({
|
||||
rcvrList: rcvrQuery.data ?? EMPTY_RCVR_LIST,
|
||||
selectedRcvrKeys,
|
||||
onRcvrSelectionChange,
|
||||
});
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: rcvrQuery,
|
||||
tableOptions: {
|
||||
columns: rvwMnpwRcvrColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...rowSelectionOptions,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<RcvrListCard title="검토인력 목록" selectedCount={selectedCount}>
|
||||
{tableComponent}
|
||||
</RcvrListCard>
|
||||
);
|
||||
}
|
||||
|
||||
export { RvwMnpwRcvrList };
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
|
||||
|
||||
interface SndngListProps {
|
||||
recipients: SelectedNtitlkRcvr[];
|
||||
variableNames: string[];
|
||||
onVariableChange: (rcvrKey: string, variableName: string, value: string) => void;
|
||||
onPreview: (recipient: SelectedNtitlkRcvr) => void;
|
||||
onRemove: (rcvrKey: string) => void;
|
||||
}
|
||||
|
||||
function SndngList({
|
||||
recipients,
|
||||
variableNames,
|
||||
onVariableChange,
|
||||
onPreview,
|
||||
onRemove,
|
||||
}: SndngListProps) {
|
||||
if (recipients.length === 0)
|
||||
return (
|
||||
<p className="rounded-md border p-6 text-center text-muted-foreground">
|
||||
선택된 발송 대상이 없습니다.
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-h-[40rem] overflow-auto rounded-md border">
|
||||
<Table variant="line" className="text-sm">
|
||||
<TableHeader className="top-0">
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[100px]">이름</TableHead>
|
||||
<TableHead className="min-w-[120px]">전화번호</TableHead>
|
||||
{variableNames.map((variableName) => (
|
||||
<TableHead key={variableName} className="min-w-[180px]">
|
||||
#{variableName}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="min-w-[160px] text-center">버튼</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recipients.map((recipient) => (
|
||||
<TableRow key={recipient.rcvrKey}>
|
||||
<TableCell align="center">{recipient.userNm}</TableCell>
|
||||
<TableCell align="center">{recipient.telNo}</TableCell>
|
||||
{variableNames.map((variableName) => (
|
||||
<TableCell key={variableName}>
|
||||
<Input
|
||||
value={recipient.variableOverrides[variableName] ?? ""}
|
||||
onChange={(event) =>
|
||||
onVariableChange(recipient.rcvrKey, variableName, event.target.value)
|
||||
}
|
||||
className="w-full min-w-[150px]"
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button type="button" size="sm" color="gray" onClick={() => onPreview(recipient)}>
|
||||
미리보기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="red"
|
||||
onClick={() => onRemove(recipient.rcvrKey)}
|
||||
>
|
||||
제거
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SndngList };
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { NtftalkTemplate } from "@/entities/ntftalk";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@common/components";
|
||||
|
||||
import { NtitlkPrvw } from "./ntitlk-prvw";
|
||||
|
||||
interface TmpltSelectPanelProps {
|
||||
templates: NtftalkTemplate[];
|
||||
selectedTemplate: NtftalkTemplate | undefined;
|
||||
onTemplateChange: (templateCode: string) => void;
|
||||
}
|
||||
|
||||
function TmpltSelectPanel({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
onTemplateChange,
|
||||
}: TmpltSelectPanelProps) {
|
||||
return (
|
||||
<section className="card card-border m-0 space-y-6 bg-background p-4 pb-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold">알림톡 템플릿 선택</h3>
|
||||
<Select value={selectedTemplate?.templateCode ?? ""} onValueChange={onTemplateChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="템플릿 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((template) => (
|
||||
<SelectItem key={template.templateCode} value={template.templateCode}>
|
||||
{template.templateName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-lg font-medium">미리보기</p>
|
||||
<NtitlkPrvw
|
||||
message={
|
||||
selectedTemplate?.templateContent ??
|
||||
"알림톡 템플릿을 선택하면 미리보기 메시지가 표시됩니다."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { TmpltSelectPanel };
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
|
||||
import {
|
||||
CUSTOM_VRBL_MAPPING,
|
||||
VRBL_MAPPING_OPTIONS,
|
||||
type VrblMappingKey,
|
||||
} from "../model/vrbl-mapping-options";
|
||||
|
||||
interface VrblBulkApplyProps {
|
||||
variableNames: string[];
|
||||
mapping: Record<string, VrblMappingKey>;
|
||||
customValues: Record<string, string>;
|
||||
onMappingChange: (variableName: string, mappingKey: VrblMappingKey) => void;
|
||||
onCustomValueChange: (variableName: string, value: string) => void;
|
||||
onApply: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function VrblBulkApply({
|
||||
variableNames,
|
||||
mapping,
|
||||
customValues,
|
||||
onMappingChange,
|
||||
onCustomValueChange,
|
||||
onApply,
|
||||
onReset,
|
||||
}: VrblBulkApplyProps) {
|
||||
return (
|
||||
<div className="mb-4 space-y-4 rounded-md border p-3">
|
||||
<p className="text-lg font-medium">변수 일괄 적용</p>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-6">
|
||||
{variableNames.map((variableName) => (
|
||||
<div key={variableName} className="space-y-2">
|
||||
<Label className="whitespace-nowrap">{`#{${variableName}}`}</Label>
|
||||
<Select
|
||||
value={mapping[variableName] ?? ""}
|
||||
onValueChange={(value) => onMappingChange(variableName, value as VrblMappingKey)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VRBL_MAPPING_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mapping[variableName] === CUSTOM_VRBL_MAPPING && (
|
||||
<Input
|
||||
placeholder="값을 입력하세요"
|
||||
value={customValues[variableName] ?? ""}
|
||||
onChange={(event) => onCustomValueChange(variableName, event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-end gap-2">
|
||||
<Button type="button" color="white" onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
<Button type="button" onClick={onApply}>
|
||||
적용하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { VrblBulkApply };
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib";
|
||||
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
@@ -51,7 +51,7 @@ function BizCstMatrixFormSection() {
|
||||
];
|
||||
|
||||
function handleChange(acntgSeCd: string, planStpNo: number, value: string) {
|
||||
const normalized = truncateDecimals(stripMinusSign(value), 3);
|
||||
const normalized = truncateDecimals(sanitizeNumberInput(value), 3);
|
||||
const numValue = Number(normalized.replace(/,/g, "")) || 0;
|
||||
const next = recalculateBizCstMatrix(
|
||||
setAmt(getValues("bizCstList"), acntgSeCd, planStpNo, numValue),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib";
|
||||
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
@@ -59,7 +59,7 @@ function FnrsCstFormSection() {
|
||||
);
|
||||
|
||||
function handleChange(planStpNo: number, fieldKey: EditableFnrsKey, value: string) {
|
||||
const numValue = Number(stripMinusSign(value).replace(/,/g, "")) || 0;
|
||||
const numValue = Number(sanitizeNumberInput(value).replace(/,/g, "")) || 0;
|
||||
const draft = [...getValues("fnrsCstList")];
|
||||
const index = draft.findIndex((item) => item.planStpNo === planStpNo);
|
||||
if (index < 0) return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib";
|
||||
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { truncateDecimals } from "@/widgets/pbadms-sprt";
|
||||
@@ -62,8 +62,8 @@ function OutlineFormSection() {
|
||||
value: string,
|
||||
decimals?: number,
|
||||
) {
|
||||
const unsigned = stripMinusSign(value);
|
||||
const normalized = decimals === undefined ? unsigned : truncateDecimals(unsigned, decimals);
|
||||
const sanitized = sanitizeNumberInput(value);
|
||||
const normalized = decimals === undefined ? sanitized : truncateDecimals(sanitized, decimals);
|
||||
const numValue = Number(normalized.replace(/,/g, "")) || 0;
|
||||
const draft = [...getValues("sewerBscPlanOtlnList")];
|
||||
const index = draft.findIndex((item) => item.planStpNo === planStpNo);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import {
|
||||
getDefaultFileKey,
|
||||
NON_NEGATIVE_INPUT_PROPS,
|
||||
stripMinusSign,
|
||||
sanitizeNumberInput,
|
||||
useTus,
|
||||
} from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
@@ -107,7 +107,7 @@ function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey, decimals }: Tmplnt
|
||||
className="text-right"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = stripMinusSign(e.target.value.replace(/,/g, ""));
|
||||
const value = sanitizeNumberInput(e.target.value.replace(/,/g, ""));
|
||||
const normalized = hasDecimals ? truncateDecimals(value, decimals) : value;
|
||||
field.onChange(Number(normalized) || 0);
|
||||
}}
|
||||
|
||||
@@ -53,6 +53,10 @@ export const ENDPOINT = {
|
||||
STATVERIFY_SERVICE: {
|
||||
ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst",
|
||||
},
|
||||
NTFTALK_SERVICE: {
|
||||
TEMP: "/ntftalk-service/api/v1/ntftalk-temp",
|
||||
ATA_SEND: "/ntftalk-service/api/v1/ata/send",
|
||||
},
|
||||
DC_SERVICE: "/dc-service/api/v1",
|
||||
BOARD_SERVICE: {
|
||||
POSTS: "/board-service/api/v1/posts",
|
||||
|
||||
@@ -2,7 +2,7 @@ export { getCookie, removeCookie, setCookie } from "./cookie";
|
||||
export { dayjs } from "./dayjs";
|
||||
export { getListHref, withListQs } from "./list-return-href";
|
||||
export { matchQueryStatus } from "./match-query-status";
|
||||
export { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "./number-input";
|
||||
export { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "./number-input";
|
||||
export { queryClient } from "./query-client";
|
||||
export { useAlertStore } from "./use-alert-store";
|
||||
export { useDataTable } from "./use-data-table";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
const MAX_INTEGER_DIGITS = 10;
|
||||
|
||||
function preventMinusKey(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "-") e.preventDefault();
|
||||
}
|
||||
@@ -7,7 +9,14 @@ function preventMinusKey(e: KeyboardEvent<HTMLInputElement>) {
|
||||
/** type="number" Input에 스프레드해 음수 입력(키·스피너)을 막는다 */
|
||||
export const NON_NEGATIVE_INPUT_PROPS = { min: 0, onKeyDown: preventMinusKey } as const;
|
||||
|
||||
/** 붙여넣기 등으로 들어온 음수 부호를 제거한다 */
|
||||
export function stripMinusSign(value: string): string {
|
||||
return value.replace(/-/g, "");
|
||||
/** 정수부를 maxDigits 자리까지 잘라낸다 (소수부는 유지) */
|
||||
function truncateIntegerDigits(value: string, maxDigits: number): string {
|
||||
const dotIndex = value.indexOf(".");
|
||||
if (dotIndex < 0) return value.slice(0, maxDigits);
|
||||
return value.slice(0, Math.min(dotIndex, maxDigits)) + value.slice(dotIndex);
|
||||
}
|
||||
|
||||
/** 숫자 입력 문자열에서 음수 부호를 제거하고 정수부를 최대 10자리로 제한한다 */
|
||||
export function sanitizeNumberInput(value: string): string {
|
||||
return truncateIntegerDigits(value.replace(/-/g, ""), MAX_INTEGER_DIGITS);
|
||||
}
|
||||
|
||||
@@ -19,16 +19,23 @@ interface DataTableOptions<T> {
|
||||
}
|
||||
|
||||
interface UseDataTableOptions<T> {
|
||||
query: UseQueryResult<PageResponse<T>>;
|
||||
/** 페이징 응답(PageResponse) 또는 페이징 없는 목록(T[]) */
|
||||
query: UseQueryResult<PageResponse<T> | T[]>;
|
||||
tableOptions: Omit<TableOptions<T>, "data" | "rowCount">;
|
||||
dataTableOptions?: DataTableOptions<T>;
|
||||
}
|
||||
|
||||
const EMPTY_ROWS: never[] = [];
|
||||
|
||||
export function useDataTable<T>({ query, tableOptions, dataTableOptions }: UseDataTableOptions<T>) {
|
||||
const { data } = query;
|
||||
const rows = Array.isArray(data) ? data : (data?.content ?? EMPTY_ROWS);
|
||||
const rowCount = Array.isArray(data) ? data.length : (data?.totalElements ?? 0);
|
||||
|
||||
const table = useReactTable({
|
||||
...tableOptions,
|
||||
data: query.data?.content ?? [],
|
||||
rowCount: query.data?.totalElements ?? 0,
|
||||
data: rows,
|
||||
rowCount,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib";
|
||||
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
@@ -80,7 +80,7 @@ function BusinessCostFormSection() {
|
||||
}, [costItems, getValues, setValue]);
|
||||
|
||||
function handleCostChange(index: number, fieldKey: CostFieldKey, value: string) {
|
||||
const numValue = Number(truncateDecimals(stripMinusSign(value), COST_DECIMAL_PLACES)) || 0;
|
||||
const numValue = Number(truncateDecimals(sanitizeNumberInput(value), COST_DECIMAL_PLACES)) || 0;
|
||||
const draft = [...getValues("bizCstList")];
|
||||
draft[index] = { ...draft[index], [fieldKey]: numValue };
|
||||
const next = recalculateBizCstTotals(draft, costItems);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib";
|
||||
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { useCnstpSeMngTree } from "@/entities/cnstp";
|
||||
@@ -78,8 +78,8 @@ function BusinessScaleFormSection() {
|
||||
|
||||
function handleScaleChange(index: number, fieldKey: ScaleFieldKey, value: string) {
|
||||
const item = scaleItems[index];
|
||||
const unsigned = stripMinusSign(value);
|
||||
const normalized = item?.numYn ? truncateDecimals(unsigned, item.dcptUnitCnt) : unsigned;
|
||||
const sanitized = sanitizeNumberInput(value);
|
||||
const normalized = item?.numYn ? truncateDecimals(sanitized, item.dcptUnitCnt) : sanitized;
|
||||
const numValue = Number(normalized) || 0;
|
||||
const draft = [...getValues("bzScaleList")];
|
||||
draft[index] = { ...draft[index], [fieldKey]: numValue };
|
||||
@@ -101,6 +101,7 @@ function BusinessScaleFormSection() {
|
||||
return (
|
||||
<SectionCollapsible title="사업규모">
|
||||
<div className="overflow-x-auto">
|
||||
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="20%" />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useFormContext, useWatch } from "react-hook-form";
|
||||
import {
|
||||
getDefaultFileKey,
|
||||
NON_NEGATIVE_INPUT_PROPS,
|
||||
stripMinusSign,
|
||||
sanitizeNumberInput,
|
||||
useTus,
|
||||
} from "@/shared/lib";
|
||||
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
|
||||
@@ -147,7 +147,7 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
|
||||
if (fieldKey === "rmrkCn") {
|
||||
draft[index] = { ...row, rmrkCn: value };
|
||||
} else {
|
||||
const numValue = Number(stripMinusSign(value)) || 0;
|
||||
const numValue = Number(sanitizeNumberInput(value)) || 0;
|
||||
draft[index] = { ...row, [fieldKey]: numValue };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { MenuApi } from "@/entities/menu";
|
||||
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useMenuStore } from "@/shared/lib";
|
||||
import { useAlertStore, useMenuStore } from "@/shared/lib";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
@@ -11,6 +14,9 @@ export default function PostLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { bbsInfo, currentMenuInfo } = useMenuStore((state) => state);
|
||||
const { data } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, currentMenuInfo?.menuCd],
|
||||
@@ -18,11 +24,78 @@ export default function PostLayout({
|
||||
MenuApi.getMenuInfo(currentMenuInfo?.menuCd.toString() ?? ""),
|
||||
enabled: !!currentMenuInfo,
|
||||
});
|
||||
|
||||
const isRegisterPage =
|
||||
pathname?.endsWith("/register") || pathname?.endsWith("/answer");
|
||||
const isModifyPage = pathname?.endsWith("/modify");
|
||||
const requiredAuthority = isRegisterPage
|
||||
? "regAuthrtYn"
|
||||
: isModifyPage
|
||||
? "mdfcnAuthrtYn"
|
||||
: undefined;
|
||||
const {
|
||||
data: pageAuthorities,
|
||||
isError: isAuthorityError,
|
||||
isLoading: isAuthorityLoading,
|
||||
} = useQuery({
|
||||
queryKey: ["page-menu-authority", currentMenuInfo?.menuCd],
|
||||
queryFn: () =>
|
||||
MenuAuthorityApi.getPageMenuAuthority(currentMenuInfo!.menuCd),
|
||||
enabled: Boolean(requiredAuthority && currentMenuInfo?.menuCd),
|
||||
});
|
||||
const hasRequiredAuthority =
|
||||
!requiredAuthority ||
|
||||
pageAuthorities?.[0]?.[requiredAuthority] === "Y";
|
||||
const canRenderChildren =
|
||||
!requiredAuthority || (hasRequiredAuthority && !isAuthorityLoading);
|
||||
const handledDeniedRoute = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiredAuthority) {
|
||||
handledDeniedRoute.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!pathname ||
|
||||
!currentMenuInfo?.menuCd ||
|
||||
isAuthorityLoading ||
|
||||
(!isAuthorityError && hasRequiredAuthority)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deniedRouteKey = `${pathname}:${currentMenuInfo.menuCd}:${requiredAuthority}`;
|
||||
if (handledDeniedRoute.current === deniedRouteKey) return;
|
||||
handledDeniedRoute.current = deniedRouteKey;
|
||||
|
||||
const listPath = pathname.replace(
|
||||
/\/(?:register|[^/]+\/(?:modify|answer))\/?$/,
|
||||
"",
|
||||
);
|
||||
const message = isAuthorityError
|
||||
? "권한 정보를 확인할 수 없습니다."
|
||||
: requiredAuthority === "regAuthrtYn"
|
||||
? "등록 권한이 없습니다."
|
||||
: "수정 권한이 없습니다.";
|
||||
|
||||
alert(message, () => router.replace(listPath));
|
||||
}, [
|
||||
alert,
|
||||
currentMenuInfo?.menuCd,
|
||||
hasRequiredAuthority,
|
||||
isAuthorityError,
|
||||
isAuthorityLoading,
|
||||
pathname,
|
||||
requiredAuthority,
|
||||
router,
|
||||
]);
|
||||
|
||||
const menuPaths = data?.menuPath?.split(">").map((path) => path.trim());
|
||||
|
||||
return (
|
||||
<PageLayout title={bbsInfo?.bbsNm ?? ""} menuPaths={menuPaths ?? []}>
|
||||
{children}
|
||||
{canRenderChildren && children}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@common/components";
|
||||
import { AttachmentApi } from "@/entities/attachment";
|
||||
import { PostApi, PostPageGuard, type PostParams } from "@/entities/post";
|
||||
@@ -40,6 +43,7 @@ function FaqPage({ bbsId }: { bbsId: string }) {
|
||||
keyword: "",
|
||||
});
|
||||
const [selectedPostNumbers, setSelectedPostNumbers] = useState<number[]>([]);
|
||||
const [expandedFaqs, setExpandedFaqs] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
data: postList,
|
||||
@@ -174,6 +178,8 @@ function FaqPage({ bbsId }: { bbsId: string }) {
|
||||
{isFetched ? (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={expandedFaqs}
|
||||
onValueChange={setExpandedFaqs}
|
||||
className="secondary flex flex-col gap-4"
|
||||
>
|
||||
{postList?.content.map((item) => (
|
||||
@@ -191,17 +197,30 @@ function FaqPage({ bbsId }: { bbsId: string }) {
|
||||
value={item.pstNo.toString()}
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionTrigger>
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-primary">Q</span>
|
||||
<span>{item.pstTtl}</span>
|
||||
</div>
|
||||
<span className="px-4 text-[1.2rem] font-normal text-label">
|
||||
등록일: {dayjs(item.regDt).format("YYYY-MM-DD")}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AccordionTrigger
|
||||
aria-expanded={expandedFaqs.includes(
|
||||
item.pstNo.toString(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-primary">Q</span>
|
||||
<span>{item.pstTtl}</span>
|
||||
</div>
|
||||
<span className="px-4 text-[1.2rem] font-normal text-label">
|
||||
등록일: {dayjs(item.regDt).format("YYYY-MM-DD")}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{expandedFaqs.includes(item.pstNo.toString())
|
||||
? "접기"
|
||||
: "펼치기"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AccordionContent>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-[1.4rem] font-bold text-label">
|
||||
|
||||
@@ -58,8 +58,16 @@ function DataTable<T>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onRowClick?.(row.original);
|
||||
}}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer hover:bg-accent",
|
||||
onRowClick &&
|
||||
"cursor-pointer hover:bg-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary",
|
||||
isSelectedRow && "!bg-primary-transparent",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -270,7 +270,11 @@ export function Header({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 box-border flex h-[6rem] w-full flex-col items-center justify-between border-b border-solid border-border bg-background sm:flex-row">
|
||||
<header
|
||||
id="gnb"
|
||||
tabIndex={-1}
|
||||
className="sticky top-0 z-30 box-border flex h-[6rem] w-full flex-col items-center justify-between border-b border-solid border-border bg-background sm:flex-row"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between px-2 sm:w-auto sm:justify-start sm:px-4 md:px-6">
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<SidebarTrigger className="size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SidebarProvider } from "../sidebar";
|
||||
import { LNB } from "../lnb";
|
||||
import { Header } from "../header";
|
||||
import { Footer } from "../footer";
|
||||
import { SkipNavigation } from "./skip-navigation";
|
||||
import "./common.css";
|
||||
|
||||
export function BaseLayout({
|
||||
@@ -30,6 +31,7 @@ export function BaseLayout({
|
||||
<PopupProvider>
|
||||
<SidebarProvider>
|
||||
<div className="flex-1">
|
||||
<SkipNavigation />
|
||||
<Header
|
||||
authService={authService}
|
||||
tokenManager={authService ? undefined : tokenManager}
|
||||
@@ -40,7 +42,9 @@ export function BaseLayout({
|
||||
handleMenuClick={handleMenuClick}
|
||||
/>
|
||||
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<main id="main-content" tabIndex={-1} className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MenuItem } from "../../api";
|
||||
import { SidebarProvider } from "../sidebar";
|
||||
import { LNB } from "../lnb";
|
||||
import { Header } from "../header";
|
||||
import { SkipNavigation } from "./skip-navigation";
|
||||
import "./common.css";
|
||||
import type { AuthService } from "@common/auth";
|
||||
|
||||
@@ -26,6 +27,7 @@ export function NoFooterLayout({
|
||||
<PopupProvider>
|
||||
<SidebarProvider>
|
||||
<div className="flex-1">
|
||||
<SkipNavigation />
|
||||
<Header authService={authService} />
|
||||
<div className="flex">
|
||||
<LNB
|
||||
@@ -33,7 +35,9 @@ export function NoFooterLayout({
|
||||
handleMenuClick={handleMenuClick}
|
||||
/>
|
||||
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col overflow-auto">
|
||||
<div className="flex-1">{children}</div>
|
||||
<main id="main-content" tabIndex={-1} className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { MenuItem } from "../../api";
|
||||
import { SidebarProvider } from "../sidebar";
|
||||
import { Header } from "../header";
|
||||
import { Footer } from "../footer";
|
||||
import { SkipNavigation } from "./skip-navigation";
|
||||
import "./common.css";
|
||||
|
||||
export function NoLNBLayout({
|
||||
@@ -24,10 +25,13 @@ export function NoLNBLayout({
|
||||
<PopupProvider>
|
||||
<SidebarProvider>
|
||||
<div className="flex-1">
|
||||
<SkipNavigation includeLnb={false} />
|
||||
<Header authService={authService} />
|
||||
<div className="flex">
|
||||
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col overflow-auto">
|
||||
<div className="flex-1">{children}</div>
|
||||
<main id="main-content" tabIndex={-1} className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSidebar } from "../sidebar";
|
||||
|
||||
type SkipNavigationProps = {
|
||||
includeLnb?: boolean;
|
||||
};
|
||||
|
||||
const linkClassName =
|
||||
"sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-sm focus:bg-background focus:px-4 focus:py-3 focus:text-foreground focus:shadow-lg focus:outline focus:outline-2 focus:outline-primary";
|
||||
|
||||
export function SkipNavigation({
|
||||
includeLnb = true,
|
||||
}: SkipNavigationProps) {
|
||||
const { isMobile, open, openMobile, setOpen, setOpenMobile } = useSidebar();
|
||||
const shouldFocusLnb = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isLnbOpen = isMobile ? openMobile : open;
|
||||
if (!shouldFocusLnb.current || !isLnbOpen) return;
|
||||
|
||||
document.getElementById("lnb")?.focus();
|
||||
shouldFocusLnb.current = false;
|
||||
}, [isMobile, open, openMobile]);
|
||||
|
||||
const handleLnbClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
const isLnbOpen = isMobile ? openMobile : open;
|
||||
|
||||
if (isLnbOpen) {
|
||||
document.getElementById("lnb")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
shouldFocusLnb.current = true;
|
||||
if (isMobile) {
|
||||
setOpenMobile(true);
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav aria-label="건너뛰기">
|
||||
<a className={linkClassName} href="#main-content">
|
||||
본문 바로가기
|
||||
</a>
|
||||
<a className={linkClassName} href="#gnb">
|
||||
주메뉴 바로가기
|
||||
</a>
|
||||
{includeLnb && (
|
||||
<a className={linkClassName} href="#lnb" onClick={handleLnbClick}>
|
||||
부메뉴 바로가기
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { SidebarProvider } from "../sidebar";
|
||||
import { LNB } from "../lnb";
|
||||
import { Header } from "../header";
|
||||
import { Footer } from "../footer";
|
||||
import { SkipNavigation } from "./skip-navigation";
|
||||
|
||||
import "./common.css";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
@@ -18,13 +19,18 @@ export function VBaseLayout({
|
||||
<QueryProvider queryClient={queryClient}>
|
||||
<SidebarProvider>
|
||||
<div className="flex-1">
|
||||
<SkipNavigation />
|
||||
<Header viewOnly />
|
||||
<div className="flex h-[calc(100vh-50px)]">
|
||||
<LNB viewOnly />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="max-h-[calc(100%-50px)] flex-1 overflow-auto"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +187,6 @@ export function LNB({
|
||||
}) {
|
||||
const {
|
||||
open: isSidebarOpen,
|
||||
setOpen,
|
||||
isMobile: isMobileView,
|
||||
setOpenMobile: closeMobileSidebar,
|
||||
} = useSidebar();
|
||||
@@ -348,6 +347,10 @@ export function LNB({
|
||||
)}
|
||||
>
|
||||
<SidebarContent
|
||||
id="lnb"
|
||||
role="navigation"
|
||||
aria-label="부메뉴"
|
||||
tabIndex={-1}
|
||||
className="bg-background"
|
||||
onKeyDown={handleSidebarKeyDown}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user