Merge branch 'iipbs/as/dev' into feature/0612_sentry_fr_merge

# Conflicts:
#	apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx
#	apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx
This commit is contained in:
2026-07-16 14:44:45 +09:00
18 changed files with 260 additions and 163 deletions
+17 -23
View File
@@ -5,6 +5,7 @@ import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
@@ -20,6 +21,10 @@ async function handler(
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
const deviceId = request.headers.get("X-Device-Id");
if (deviceId) {
headers["X-Device-Id"] = deviceId;
}
let body = {};
const isRefreshHandler = params.url.includes("refresh");
@@ -39,27 +44,13 @@ async function handler(
}
const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`;
console.log(`[Proxy API] ${method} ${params.url.join("/")} -> ${targetUrl}`);
console.log(`[Proxy API] Headers:`, headers);
if (!isRefreshHandler) {
console.log(`[Proxy API] Body:`, body);
}
const result = await fetch(targetUrl, {
method,
headers,
body: JSON.stringify(body),
});
console.log(`[Proxy API] Response Status: ${result.status}`);
console.log(`[Proxy API] Response Headers:`, {
[ACCESS_TOKEN]: result.headers.get(ACCESS_TOKEN),
[REFRESH_TOKEN]: result.headers.get(REFRESH_TOKEN) ? '***hidden***' : undefined,
[AUTH_USER_ID]: result.headers.get(AUTH_USER_ID),
});
if (!result.ok) {
console.error(`[Proxy API] Request failed with status ${result.status}`);
return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 });
}
@@ -68,24 +59,16 @@ async function handler(
const userId = result.headers.get(AUTH_USER_ID);
if (accessToken && userId) {
console.log(`[Proxy API] Login successful for user: ${userId}`);
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
});
const forwardedHost = request.headers.get("x-forwarded-host");
const host = request.headers.get("host");
const actualHost = forwardedHost || host || new URL(request.url).hostname.toString();
// 운영 환경에서 domain 설정
const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost;
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "strict",
domain: domain,
domain: getCookieDomain(),
path: "/",
});
}
@@ -109,3 +92,14 @@ export async function PUT(request: Request, props: { params: Promise<{ url: stri
const params = await props.params;
return handler(request, params, "PUT");
}
function getCookieDomain(): string | undefined {
if (typeof window === "undefined") return undefined;
if (COOKIE_DOMAIN) return COOKIE_DOMAIN;
const { hostname } = window.location;
if (hostname === "localhost" || hostname === "127.0.0.1") return undefined;
return hostname;
}
+1
View File
@@ -39,6 +39,7 @@
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"tus-js-client": "^4.3.1",
"uuid": "^14.0.1",
"zod": "^3.24.2"
},
"devDependencies": {
@@ -1,6 +1,7 @@
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
import { type AuthPayload, type AuthService, getRemainingMs } from "@common/auth";
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
import {
clearRefreshTimer,
removeToken,
@@ -12,7 +13,10 @@ import {
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-Device-Id": getOrCreateDeviceId(),
},
body: JSON.stringify(payload),
});
@@ -39,6 +43,9 @@ async function silentRefresh(): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
method: "PUT",
headers: {
"X-Device-Id": getOrCreateDeviceId(),
},
});
const data = await response.json();
@@ -74,6 +81,7 @@ async function logout(): Promise<void> {
tokenManager.clearAuthToken();
removeToken("site-id-key");
removeToken("site-theme-key");
clearDeviceId();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
@@ -0,0 +1,17 @@
import { v4 as uuidv4 } from "uuid";
const DEVICE_ID_KEY = "x-device-id";
export function getOrCreateDeviceId(): string {
if (typeof window === "undefined") return "";
const existing = localStorage.getItem(DEVICE_ID_KEY);
if (existing) return existing;
const id = uuidv4();
localStorage.setItem(DEVICE_ID_KEY, id);
return id;
}
export function clearDeviceId(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(DEVICE_ID_KEY);
}
@@ -49,21 +49,15 @@ export function formatTreeBranchPrefix(depth: number): string {
return "";
}
function compareSortSeq(a: CklstRvwRow, b: CklstRvwRow): number {
return (a.sortSeq ?? 0) - (b.sortSeq ?? 0);
}
export function buildRvwDisplayRows(rows: CklstRvwRow[]): CklstRvwDisplayRow[] {
const result: CklstRvwDisplayRow[] = [];
const roots = rows
.filter((row) => (row.rvwCnLvNo ?? 1) === 1 && row.upCklstRvwSn == null)
.sort(compareSortSeq);
const roots = rows.filter((row) => (row.rvwCnLvNo ?? 1) === 1 && row.upCklstRvwSn == null);
function appendSubtree(parent: CklstRvwRow, depth: number) {
const children = rows
.filter((row) => row.upCklstRvwSn != null && row.upCklstRvwSn === parent.cklstRvwSn)
.sort(compareSortSeq);
const children = rows.filter(
(row) => row.upCklstRvwSn != null && row.upCklstRvwSn === parent.cklstRvwSn,
);
for (const child of children) {
result.push({ row: child, depth });
@@ -82,7 +76,7 @@ export function buildRvwDisplayRows(rows: CklstRvwRow[]): CklstRvwDisplayRow[] {
(row) => !result.some((display) => display.row.rowKey === row.rowKey),
);
for (const orphan of orphans.sort(compareSortSeq)) {
for (const orphan of orphans) {
result.push({ row: orphan, depth: Math.max(0, (orphan.rvwCnLvNo ?? 1) - 1) });
}
@@ -279,13 +279,13 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
) : (
<Table className="w-full">
<colgroup>
<col width="18%" />
<col width="37%" />
<col width="16%" />
<col width="36%" />
<col width="10%" />
<col width="10%" />
<col width="8%" />
<col width="6%" />
<col width="11%" />
<col width="5%" />
<col width="15%" />
</colgroup>
<TableHeader>
<TableRow>
@@ -293,7 +293,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
<TableHead>주요 검토내용</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>체크 사용</TableHead>
<TableHead className="text-center">순서</TableHead>
<TableHead>정렬 순서</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
</TableRow>
@@ -107,19 +107,18 @@ function CklstRvwRowComponent({
</div>
</RadioGroup>
</TableCell>
<TableCell>
<TableCell align="center">
<Input
type="number"
min={0}
value={row.sortSeq ?? ""}
onChange={(e) =>
onChange({
sortSeq: e.target.value === "" ? undefined : Number(e.target.value),
sortSeq: e.target.value ? Number(e.target.value) : undefined,
isDirty: true,
})
}
placeholder="순서"
className="w-full text-center"
min={0}
className="w-20"
/>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
@@ -10,7 +10,10 @@ export const fnrsCnsltnRegistSchema = z.object({
cfmtnBgtBizNo: z.string().optional(),
bgtAplyYr: z.string().optional(),
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
dsgnrTelno: z.string().min(1, "설계자 전화번호는 필수입니다"),
dsgnrTelno: z
.string()
.min(1, "설계자 전화번호는 필수입니다")
.regex(/^[0-9-]+$/, "숫자와 하이픈(-)만 입력 가능합니다"),
dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
dsgnrEmlAddr: z
.string()
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
import {
Button,
cn,
Table,
TableBody,
@@ -36,6 +37,24 @@ function ChecklistTaskPanel({
const currentUserReviewStep = userRoleCode ? ROLE_TO_REVIEW_STEP[userRoleCode] : undefined;
const isStepEditable = currentUserReviewStep === selectedReviewStep;
function handleSelectAll(value: "Y" | "N") {
if (!isStepEditable) return;
setFormState((prev) => {
const nextResults = { ...prev.results };
selectedRows
.filter((row) => row.bchckUseYn === "Y")
.forEach((row) => {
nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value;
});
return patchChecklistFormState(
prev,
{ results: nextResults },
allRows,
selectedReviewStep,
);
});
}
return (
<>
<div className="overflow-x-auto">
@@ -54,8 +73,27 @@ function ChecklistTaskPanel({
) : null}
</TableRow>
<TableRow>
<TableHead className="w-[9rem]">예</TableHead>
<TableHead className="w-[9rem]">아니오</TableHead>
<TableHead className="w-[12rem]">
<Button
type="button"
size="sm"
disabled={!isStepEditable}
onClick={() => handleSelectAll("Y")}
>
전체 예
</Button>
</TableHead>
<TableHead className="w-[12rem]">
<Button
type="button"
size="sm"
disabled={!isStepEditable}
onClick={() => handleSelectAll("N")}
className="!bg-destructive"
>
전체 아니오
</Button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -131,7 +169,7 @@ function ChecklistTaskPanel({
<TableCell className="text-left">
<Textarea
className={cn(
"min-h-[6rem] !text-xl",
"min-h-[6rem] w-[32rem] !text-xl",
isStepEditable ? "cursor-pointer" : "cursor-not-allowed",
)}
value={formState.comments[key] ?? ""}
@@ -9,7 +9,10 @@ export const instlSncnRegistSchema = z.object({
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
sncnSeComCd: z.string().min(1, "사업구분은 필수입니다"),
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
dsgnrTelno: z.string().min(1, "설계자 전화번호는 필수입니다"),
dsgnrTelno: z
.string()
.min(1, "설계자 전화번호는 필수입니다")
.regex(/^[0-9-]+$/, "숫자와 하이픈(-)만 입력 가능합니다"),
dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
dsgnrEmlAddr: z
.string()
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
import type { CklstRvwRsltTreeDto, ReviewStepCode } from "@/entities/instl-sncn";
import {
Button,
cn,
Table,
TableBody,
@@ -35,6 +36,24 @@ function ChecklistTaskPanel({
const currentUserReviewStep = userRoleCode ? ROLE_TO_REVIEW_STEP[userRoleCode] : undefined;
const isStepEditable = currentUserReviewStep === selectedReviewStep;
function handleSelectAll(value: "Y" | "N") {
if (!isStepEditable) return;
setFormState((prev) => {
const nextResults = { ...prev.results };
selectedRows
.filter((row) => row.bchckUseYn === "Y")
.forEach((row) => {
nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value;
});
return patchChecklistFormState(
prev,
{ results: nextResults },
allRows,
selectedReviewStep,
);
});
}
return (
<>
<div className="overflow-x-auto">
@@ -53,8 +72,27 @@ function ChecklistTaskPanel({
) : null}
</TableRow>
<TableRow>
<TableHead className="w-[9rem]">예</TableHead>
<TableHead className="w-[9rem]">아니오</TableHead>
<TableHead className="w-[12rem]">
<Button
type="button"
size="sm"
disabled={!isStepEditable}
onClick={() => handleSelectAll("Y")}
>
전체 예
</Button>
</TableHead>
<TableHead className="w-[12rem]">
<Button
type="button"
size="sm"
disabled={!isStepEditable}
onClick={() => handleSelectAll("N")}
className="!bg-destructive"
>
전체 아니오
</Button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -130,7 +168,7 @@ function ChecklistTaskPanel({
<TableCell className="text-left">
<Textarea
className={cn(
"min-h-[6rem] !text-xl",
"min-h-[6rem] w-[32rem] !text-xl",
isStepEditable ? "cursor-pointer" : "cursor-not-allowed",
)}
value={formState.comments[key] ?? ""}
-1
View File
@@ -9,7 +9,6 @@ export { buildCnstpScaleItems, recalculateBzScaleTotals } from "./lib/business-s
export {
buildChecklistTabs,
isFormChecklistComplete,
orderCklstTreeNodes,
syncCklstResultListFromTree,
} from "./lib/checklist-form-utils";
export type { FnrsSharItemDef, FnrsSharListItem } from "./lib/fnrs-shar-form-utils";
@@ -19,3 +19,14 @@ export function createEtcUploadKey(file: File): string {
export function isEtcFileKey(fileKey: string): boolean {
return fileKey.startsWith(ETC_FILE_KEY_PREFIX);
}
export function toFileAccept(rmrk: string | null | undefined): string {
if (!rmrk?.trim()) return "";
return rmrk
.split(",")
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean)
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
.join(",");
}
@@ -26,41 +26,6 @@ interface CklstResultListItem {
picRvwCn?: string | null;
}
function compareCklstSiblings(a: CklstTreeNode, b: CklstTreeNode): number {
if (a.cklstArtclSn !== b.cklstArtclSn) {
return a.cklstArtclSn - b.cklstArtclSn;
}
if (a.sortSeq !== b.sortSeq) {
return b.sortSeq - a.sortSeq;
}
return a.cklstRvwSn - b.cklstRvwSn;
}
export function orderCklstTreeNodes(rows: CklstTreeNode[]): CklstTreeNode[] {
const childMap = new Map<number | null, CklstTreeNode[]>();
rows.forEach((row) => {
const parent = row.upCklstRvwSn ?? null;
if (!childMap.has(parent)) childMap.set(parent, []);
childMap.get(parent)!.push(row);
});
childMap.forEach((siblings) => siblings.sort(compareCklstSiblings));
const ordered: CklstTreeNode[] = [];
function walk(parent: number | null) {
(childMap.get(parent) ?? []).forEach((child) => {
ordered.push(child);
walk(child.cklstRvwSn);
});
}
walk(null);
return ordered;
}
function groupCklstTreeByTaskSeComCd(cklstTree: CklstTreeNode[]): Map<string, CklstTreeNode[]> {
const grouped = new Map<string, CklstTreeNode[]>();
@@ -81,7 +46,7 @@ function getOrderedSelectableNodes(
const grouped = groupCklstTreeByTaskSeComCd(cklstTree);
return taskSeComCdArray.flatMap((code) =>
orderCklstTreeNodes(grouped.get(code) ?? []).filter((node) => node.bchckUseYn === "Y"),
(grouped.get(code) ?? []).filter((node) => node.bchckUseYn === "Y"),
);
}
@@ -98,7 +63,7 @@ export function buildChecklistTabs(
return orderedCodes.map((code) => ({
code,
label: taskCodeLabels[code] || code,
items: orderCklstTreeNodes(grouped.get(code) ?? []).map((node) => ({
items: (grouped.get(code) ?? []).map((node) => ({
cklstSn: node.cklstSn,
cklstArtclSn: node.cklstArtclSn,
cklstRvwSn: node.cklstRvwSn,
@@ -27,6 +27,7 @@ import {
createUploadKey,
type FieldPrefix,
isEtcFileKey,
toFileAccept,
} from "../lib/attachments-form-utils";
import { SectionCollapsible } from "./section-collapsible";
@@ -373,7 +374,8 @@ function AttachmentsFormSection({
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
<Table variant="secondary">
<colgroup>
<col width="60%" />
<col width="50%" />
<col width="10%" />
<col width="40%" />
</colgroup>
<TableHeader>
@@ -381,6 +383,7 @@ function AttachmentsFormSection({
<TableHead className="w-[30rem]" align="left">
파일명
</TableHead>
<TableHead>파일 확장자</TableHead>
<TableHead>업로드된 파일</TableHead>
</TableRow>
</TableHeader>
@@ -401,6 +404,7 @@ function AttachmentsFormSection({
>
{file.esntlAtchFileNm}
</TableHead>
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
<TableCell className="space-y-2">
{uploadedFiles[file.esntlAtchFileId]?.length > 0 ? (
<div className="space-y-1">
@@ -432,6 +436,7 @@ function AttachmentsFormSection({
<input
type="file"
className="hidden"
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
onChange={(e) => {
void handleEssentialFileUpload(
file.esntlAtchFileId,
@@ -5,7 +5,7 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { Field, FieldLabel } from "@/shared/ui/field";
import { cklstMngService, type CklstTreeNode } from "@/entities/cklst";
import { useCodeList } from "@/entities/code";
import {
@@ -196,12 +196,12 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
검토항목
</TableHead>
<TableHead rowSpan={2}>주요 검토내용</TableHead>
<TableHead className="w-[16rem]">검토</TableHead>
<TableHead colSpan={2}>검토 결과</TableHead>
</TableRow>
<TableRow>
<TableHead>
<div className="flex gap-2">
<TableHead className="w-[12rem]">
<Button
type="button"
size="sm"
onClick={(e) => {
e.preventDefault();
@@ -210,7 +210,10 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
>
전체 예
</Button>
</TableHead>
<TableHead className="w-[12rem]">
<Button
type="button"
size="sm"
onClick={(e) => {
e.preventDefault();
@@ -220,7 +223,6 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
>
전체 아니오
</Button>
</div>
</TableHead>
</TableRow>
</TableHeader>
@@ -236,27 +238,27 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
style={{
paddingLeft: `${Math.max(0, item.treeLevel - 1) * 16 + 8}px`,
}}
colSpan={showRadios ? 0 : 2}
colSpan={showRadios ? 0 : 3}
>
{item.treeLevel > 1 ? "└ " : ""}
{item.content}
</TableCell>
{showRadios && resultIdx >= 0 ? (
<TableCell>
<Controller
name={`cklstResultList.${resultIdx}.selected`}
control={control}
render={({ field, fieldState }) => (
<FieldSet>
<>
<TableCell className="text-center">
<RadioGroup
name={field.name}
value={field.value || undefined}
value={field.value === "Y" ? "Y" : undefined}
onValueChange={field.onChange}
className="flex justify-center"
>
<div className="flex w-full justify-center gap-6">
<FieldLabel
htmlFor={`check-${item.cklstRvwSn}-Y`}
className="!w-auto border-none"
className="!w-auto justify-center border-none"
>
<Field
orientation="horizontal"
@@ -267,13 +269,22 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
value="Y"
id={`check-${item.cklstRvwSn}-Y`}
aria-invalid={fieldState.invalid}
aria-label="예"
/>
<span className="font-normal">예</span>
</Field>
</FieldLabel>
</RadioGroup>
</TableCell>
<TableCell className="text-center">
<RadioGroup
name={field.name}
value={field.value === "N" ? "N" : undefined}
onValueChange={field.onChange}
className="flex justify-center"
>
<FieldLabel
htmlFor={`check-${item.cklstRvwSn}-N`}
className="!w-auto border-none"
className="!w-auto justify-center border-none"
>
<Field
orientation="horizontal"
@@ -284,16 +295,15 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
value="N"
id={`check-${item.cklstRvwSn}-N`}
aria-invalid={fieldState.invalid}
aria-label="아니오"
/>
<span className="font-normal">아니오</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</>
)}
/>
</TableCell>
) : null}
</TableRow>
);
@@ -70,6 +70,8 @@ function ContactInfoFormSection({ fieldPrefix }: ContactInfoFormSectionProps) {
id={field.name}
placeholder="설계자 전화번호"
aria-invalid={fieldState.invalid}
maxLength={13}
onChange={(e) => field.onChange(e.target.value.replace(/[^0-9-]/g, ""))}
/>
<FieldError errors={[fieldState.error]} />
</Field>
+10
View File
@@ -213,6 +213,9 @@ importers:
tus-js-client:
specifier: ^4.3.1
version: 4.3.1
uuid:
specifier: ^14.0.1
version: 14.0.1
zod:
specifier: ^3.24.2
version: 3.25.76
@@ -8114,8 +8117,13 @@ packages:
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
uuid@14.0.1:
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
victory-vendor@36.9.2:
@@ -15046,6 +15054,8 @@ snapshots:
dependencies:
base64-arraybuffer: 1.0.2
uuid@14.0.1: {}
uuid@9.0.1: {}
victory-vendor@36.9.2: