68f85f3736
- 첫 로그인 비번 강제 변경 (RUN_082): FORCE_PASSWORD_CHANGE 컬럼, ForcePasswordChangeGuardFilter, /auth/change-password API + 페이지 - 테넌트 일관성 가드: TenantConsistencyGuardFilter 로 JWT.company_code ↔ 서브도메인 company_code 대조, CompanyResolver 가 (db_name, company_code) 동시 반환 - 회사 관리 확장 (RUN_083 audit log, RUN_084 lifecycle 컬럼): CompanyAdmin/Members/Templates/Lifecycle/AuditLog 서비스 + CompanyMgmtController + SuperAdminGuard - 회사 관리 UI: CompanyAccordionRow 탭화 + 모달 4종 (AdminInfo/Deactivate/Delete/RecopyTemplates) + AuditLogDrawer + csvExport - 프로비저닝 마법사: force_password_change 토글 반영 - 프론트 인증: storage 이벤트 멀티탭 동기화, 403 errorCode (PASSWORD_CHANGE_REQUIRED / CROSS_TENANT_REJECTED / TENANT_NOT_RESOLVED) 전역 리다이렉트 - 기타: StartupSchemaMigrator, OS별 도커 기동 스크립트, CLAUDE.md 트래킹 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
5.5 KiB
TypeScript
187 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { X } from "lucide-react";
|
|
|
|
/**
|
|
* 회사 관리 모달 공용 셸 — v5 토큰/글로우 따름. 반투명/blur 금지.
|
|
*
|
|
* ⚠ createPortal 로 document.body 에 렌더링.
|
|
* 이유: accordion 의 부모에 transform 이 걸려 있으면 position:fixed 가 viewport 가 아니라
|
|
* 그 transform 부모 기준으로 포지셔닝됨 (CSS containing-block 규칙). Portal 로 탈출.
|
|
*/
|
|
export default function ModalShell({
|
|
title,
|
|
subtitle,
|
|
onClose,
|
|
width = 520,
|
|
children,
|
|
footer,
|
|
}: {
|
|
title: React.ReactNode;
|
|
subtitle?: React.ReactNode;
|
|
onClose: () => void;
|
|
width?: number;
|
|
children: React.ReactNode;
|
|
footer?: React.ReactNode;
|
|
}) {
|
|
const [mounted, setMounted] = useState(false);
|
|
useEffect(() => setMounted(true), []);
|
|
if (!mounted) return null;
|
|
|
|
// 헤더는 항상 테마 primary 색의 아주 연한 그라데이션 (variant 구분 없음).
|
|
const headerBg =
|
|
"linear-gradient(90deg, rgba(var(--v5-primary-rgb), 0.05), transparent 70%)";
|
|
const shell = (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
zIndex: 90,
|
|
background: "rgba(6, 5, 14, 0.55)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: 24,
|
|
animation: "modalOverlayIn 0.18s ease-out",
|
|
}}
|
|
>
|
|
<style>{`
|
|
@keyframes modalOverlayIn { from { opacity: 0; } to { opacity: 1; } }
|
|
@keyframes modalShellIn {
|
|
from { opacity: 0; transform: translateY(10px) scale(0.97); }
|
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
}
|
|
`}</style>
|
|
<div
|
|
style={{
|
|
width: `min(${width}px, 100%)`,
|
|
maxHeight: "calc(100vh - 48px)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
background: "var(--v5-surface-solid)",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 14,
|
|
overflow: "hidden",
|
|
boxShadow: "0 12px 32px rgba(0, 0, 0, 0.28), 0 0 18px rgba(var(--v5-primary-rgb), 0.06)",
|
|
animation: "modalShellIn 0.28s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
padding: "0.95rem 1.2rem",
|
|
background: headerBg,
|
|
borderBottom: "1px solid var(--v5-border)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: "1.05rem", fontWeight: 800, letterSpacing: "-0.01em", color: "var(--v5-text)" }}>
|
|
{title}
|
|
</div>
|
|
{subtitle && (
|
|
<div style={{ fontSize: "0.75rem", color: "var(--v5-text-sec)", marginTop: 3, fontWeight: 500 }}>
|
|
{subtitle}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
aria-label="닫기"
|
|
style={{
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: 7,
|
|
border: "1px solid var(--v5-border)",
|
|
background: "transparent",
|
|
color: "var(--v5-text-sec)",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
<div style={{ flex: 1, overflow: "auto", padding: "1.1rem 1.2rem" }}>{children}</div>
|
|
{footer && (
|
|
<div
|
|
style={{
|
|
padding: "0.8rem 1.2rem",
|
|
borderTop: "1px solid var(--v5-border)",
|
|
background: "var(--v5-surface-solid)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.55rem",
|
|
justifyContent: "flex-end",
|
|
}}
|
|
>
|
|
{footer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
return createPortal(shell, document.body);
|
|
}
|
|
|
|
export function ModalBtn({
|
|
children,
|
|
onClick,
|
|
variant = "secondary",
|
|
disabled,
|
|
icon,
|
|
}: {
|
|
children: React.ReactNode;
|
|
onClick?: () => void;
|
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
|
disabled?: boolean;
|
|
icon?: React.ReactNode;
|
|
}) {
|
|
const map: Record<string, React.CSSProperties> = {
|
|
primary: { background: "var(--v5-primary)", color: "#fff", borderColor: "var(--v5-primary)" },
|
|
danger: { background: "var(--v5-red)", color: "#fff", borderColor: "var(--v5-red)" },
|
|
secondary: {
|
|
background: "var(--v5-surface-solid)",
|
|
color: "var(--v5-text)",
|
|
borderColor: "var(--v5-border)",
|
|
},
|
|
ghost: { background: "transparent", color: "var(--v5-text-sec)", borderColor: "transparent" },
|
|
};
|
|
return (
|
|
<button
|
|
onClick={disabled ? undefined : onClick}
|
|
disabled={disabled}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "0.4rem",
|
|
height: 34,
|
|
padding: "0 0.9rem",
|
|
borderRadius: 8,
|
|
fontSize: "0.8125rem",
|
|
fontWeight: 600,
|
|
border: "1px solid transparent",
|
|
cursor: disabled ? "not-allowed" : "pointer",
|
|
opacity: disabled ? 0.5 : 1,
|
|
fontFamily: "inherit",
|
|
whiteSpace: "nowrap",
|
|
transition: "all 0.15s ease",
|
|
...map[variant],
|
|
}}
|
|
>
|
|
{icon}
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|