+
{r.db_size || "—"}
@@ -260,7 +275,7 @@ export default function CompanyAccordionRow({
-
+
- {l}
+ {l}
{soon && (
@@ -365,11 +380,11 @@ export default function CompanyAccordionRow({
{tab === "overview" && (
-
+
{/* 기본정보 */}
기본 정보
-
+
{(
[
["회사 코드", r.company_code, true],
@@ -383,12 +398,12 @@ export default function CompanyAccordionRow({
] as const
).map(([l, v, mono], i) => (
-
{l}
+
{l}
@@ -422,9 +437,9 @@ export default function CompanyAccordionRow({
>
회사 사이트 열기
-
} soon>관리자 계정
-
} soon>템플릿 재복제
+
}
+ onClick={(e) => {
+ e.stopPropagation();
+ setAdminModal("view");
+ }}
+ >
+ 관리자 계정
+
+
}
+ onClick={(e) => {
+ e.stopPropagation();
+ setRecopyModal(true);
+ }}
+ >
+ 템플릿 재복제
+
)}
{tab === "members" && (
-
- 구성원 목록은 회사별 tenant DB 에서 실시간 조회로 표시 예정 (Phase 4). 현재
- 총 {users}명.
-
+
)}
{tab === "templates" && (
-
- 이 회사에 설치된 템플릿 그룹 {r.templates || 0}개. 상세 보기는 Phase 4 에서.
-
+
+ )}
+
+ {tab === "audit" && (
+
setAuditDrawer(true)} />
)}
{tab === "danger" && (
- {[
- {
- t: "회사 비활성화",
- d: "사용자 로그인 차단 · 데이터 보존 · 언제든 재활성 가능",
- b: "비활성화",
- c: "var(--v5-amber)",
- Icon: PauseCircle,
- },
- {
- t: "관리자 비밀번호 재설정",
- d: "무작위 비밀번호 재설정 · 1회 표시",
- b: "재설정",
- c: "var(--v5-primary)",
- Icon: KeyRound,
- },
- {
- t: "회사 영구 삭제",
- d: "회사 + 테넌트 DB 영구 삭제 · 복구 불가",
- b: "삭제 예약",
- c: "var(--v5-red)",
- Icon: Trash2,
- },
- ].map((row, i) => {
- const IconC = row.Icon;
- return (
-
-
-
-
-
-
{
+ const isSuspended = (r.db_status || r.status) === "suspended";
+ const rows: Array<{ t: string; d: string; b: string; c: string; Icon: any; onClick: () => void }> = [
+ {
+ t: isSuspended ? "회사 재활성화" : "회사 비활성화",
+ d: isSuspended
+ ? "사용자 로그인 재개 · 기록은 감사 로그에 남음"
+ : "사용자 로그인 차단 · 데이터 보존 · 언제든 재활성 가능",
+ b: isSuspended ? "재활성화" : "비활성화",
+ c: isSuspended ? "rgb(var(--v5-green-rgb))" : "var(--v5-amber)",
+ Icon: isSuspended ? PlayCircle : PauseCircle,
+ onClick: () => setDeactivateModal(true),
+ },
+ {
+ t: "관리자 비밀번호 재설정",
+ d: "무작위 임시 비밀번호 발급 · 1회 표시 · 첫 로그인 시 변경 강제",
+ b: "재설정",
+ c: "var(--v5-primary)",
+ Icon: KeyRound,
+ onClick: () => setAdminModal("reset-warn"),
+ },
+ {
+ t: "회사 영구 삭제",
+ d: "회사 + 테넌트 DB 영구 삭제 · 복구 불가",
+ b: "삭제",
+ c: "var(--v5-red)",
+ Icon: Trash2,
+ onClick: () => setDeleteModal(true),
+ },
+ ];
+ return rows.map((row, i) => {
+ const IconC = row.Icon;
+ return (
+
- {row.b}
-
-
- );
- })}
+
+
+
+
+ {
+ e.stopPropagation();
+ row.onClick();
+ }}
+ style={{
+ height: 30,
+ padding: "0 0.75rem",
+ borderRadius: 5,
+ border: `1px solid ${row.c}`,
+ background: "transparent",
+ color: row.c,
+ fontSize: "0.72rem",
+ fontWeight: 700,
+ cursor: "pointer",
+ fontFamily: "inherit",
+ transition: "all 0.15s ease",
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = row.c;
+ (e.currentTarget as HTMLButtonElement).style.color = "#fff";
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.background = "transparent";
+ (e.currentTarget as HTMLButtonElement).style.color = row.c;
+ }}
+ >
+ {row.b}
+
+
+ );
+ });
+ })()}
)}
+
+ {/* ───── 모달 / 드로어 ───── */}
+ {adminModal && (
+
setAdminModal(false)}
+ />
+ )}
+ {recopyModal && (
+ setRecopyModal(false)}
+ />
+ )}
+ {deactivateModal && (
+ setDeactivateModal(false)}
+ />
+ )}
+ {deleteModal && (
+ setDeleteModal(false)}
+ />
+ )}
+ {auditDrawer && (
+ setAuditDrawer(false)}
+ />
+ )}
);
}
+/** 재복제 모달은 installed groups 가 필요하므로 wrapper 에서 fetch */
+function RecopyTemplatesModalWrapper({
+ companyCode,
+ companyName,
+ dbName,
+ onClose,
+}: {
+ companyCode: string;
+ companyName?: string;
+ dbName: string;
+ onClose: () => void;
+}) {
+ const { data = [], isLoading } = useQuery({
+ queryKey: ["installed-groups", companyCode],
+ queryFn: () => getInstalledGroups(companyCode),
+ staleTime: 30_000,
+ });
+ if (isLoading) return null;
+ return (
+
+ );
+}
+
+const ACTION_META: Record
= {
+ COMPANY_CREATE: { label: "회사 생성", color: "rgb(var(--v5-green-rgb))" },
+ COMPANY_CREATE_FAILED: { label: "생성 실패", color: "var(--v5-red)" },
+ COMPANY_DEACTIVATE: { label: "비활성화", color: "var(--v5-amber)" },
+ COMPANY_REACTIVATE: { label: "재활성화", color: "rgb(var(--v5-green-rgb))" },
+ COMPANY_DELETE: { label: "영구 삭제", color: "var(--v5-red)" },
+ ADMIN_PASSWORD_RESET: { label: "비번 재설정", color: "var(--v5-primary)" },
+ TEMPLATES_RECOPY: { label: "템플릿 재복제", color: "var(--v5-cyan)" },
+};
+
+/** 감사 탭 내 인라인 미니 리스트 (최근 10건) + 드로어 열기 링크 */
+function CompanyAuditMini({
+ companyCode,
+ open,
+ onOpenFull,
+}: {
+ companyCode: string;
+ open: boolean;
+ onOpenFull: () => void;
+}) {
+ const { data, isLoading } = useQuery({
+ queryKey: ["company-audit-log", companyCode, "mini"],
+ queryFn: () => getCompanyAuditLog(companyCode, 1, 10),
+ enabled: open,
+ staleTime: 5_000,
+ });
+ const rows: Record[] = data?.data || [];
+ return (
+
+
+
+ 최근 10건 · 전체 {data?.total ?? 0}
+
+
{ e.stopPropagation(); onOpenFull(); }}
+ style={{
+ background: "transparent",
+ border: "1px solid var(--v5-border)",
+ color: "var(--v5-primary)",
+ fontSize: "0.72rem",
+ fontWeight: 600,
+ padding: "4px 10px",
+ borderRadius: 5,
+ cursor: "pointer",
+ fontFamily: "inherit",
+ }}
+ >
+ 전체 보기 →
+
+
+ {isLoading && (
+
조회 중...
+ )}
+ {!isLoading && rows.length === 0 && (
+
+ 기록이 없습니다.
+
+ )}
+ {!isLoading && rows.length > 0 && (
+
+ {/* 컬럼 헤더 */}
+
+ 액션
+ 수행자
+ 대상
+ 시각
+
+ {rows.map((r) => {
+ const meta = ACTION_META[r.action] || { label: r.action, color: "var(--v5-text-sec)" };
+ const failed = r.success === false || r.success === "false";
+ return (
+
+
+ {meta.label}
+
+
+ {r.actor_user_id || "—"}
+
+
+ {r.target || "—"}
+
+
+ {formatDateTime(r.created_at)}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function formatDateTime(v: any): string {
+ if (v == null) return "—";
+ try {
+ let d: Date;
+ if (typeof v === "number") d = new Date(v);
+ else {
+ const s = String(v);
+ d = /^\d{10,}$/.test(s) ? new Date(Number(s)) : new Date(s);
+ }
+ if (isNaN(d.getTime())) return String(v);
+ return d.toISOString().replace("T", " ").slice(0, 19);
+ } catch {
+ return String(v);
+ }
+}
+
const labelSm: React.CSSProperties = {
- fontSize: "0.58rem",
+ fontSize: "0.65rem",
color: "var(--v5-text-sec)",
letterSpacing: "0.06em",
textTransform: "uppercase",
fontWeight: 700,
- marginBottom: 2,
+ marginBottom: 3,
fontFamily: "var(--v5-font-mono)",
};
const sectionTitle: React.CSSProperties = {
- fontSize: "0.62rem",
+ fontSize: "0.72rem",
color: "var(--v5-text-sec)",
letterSpacing: "0.06em",
textTransform: "uppercase",
fontWeight: 700,
- marginBottom: "0.55rem",
+ marginBottom: "0.7rem",
fontFamily: "var(--v5-font-mono)",
};
@@ -608,11 +882,11 @@ function ABtn({
style={{
display: "inline-flex",
alignItems: "center",
- gap: "0.35rem",
- height: 26,
- padding: "0 0.6rem",
+ gap: "0.4rem",
+ height: 30,
+ padding: "0 0.7rem",
borderRadius: 6,
- fontSize: "0.64rem",
+ fontSize: "0.75rem",
fontWeight: 600,
border: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
@@ -628,9 +902,9 @@ function ABtn({
{soon && (
- {children}
-
- );
-}
-
function formatDate(v: any): string {
if (!v) return "—";
try {
@@ -729,9 +981,9 @@ function PlanBadge({ plan }: { plan: string }) {
style={{
display: "inline-flex",
alignItems: "center",
- fontSize: "0.6rem",
+ fontSize: "0.7rem",
fontWeight: 700,
- padding: "3px 8px",
+ padding: "4px 9px",
borderRadius: 4,
background: s.bg,
color: s.color,
diff --git a/frontend/components/admin/provisioning/CompanyStatsStrip.tsx b/frontend/components/admin/provisioning/CompanyStatsStrip.tsx
index 1f93c807..03d650ab 100644
--- a/frontend/components/admin/provisioning/CompanyStatsStrip.tsx
+++ b/frontend/components/admin/provisioning/CompanyStatsStrip.tsx
@@ -23,16 +23,16 @@ export default function CompanyStatsStrip({ rows }: { rows: Record
[
const pctDB = Math.round((dbGB / dbQuotaGB) * 100);
const cardStyle: React.CSSProperties = {
- padding: "0.75rem 0.85rem",
+ padding: "0.85rem 0.95rem",
display: "grid",
- gridTemplateRows: "16px 32px 6px 16px",
- rowGap: 8,
+ gridTemplateRows: "18px 34px 6px 18px",
+ rowGap: 9,
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 10,
};
const label: React.CSSProperties = {
- fontSize: "0.68rem",
+ fontSize: "0.75rem",
color: "var(--v5-text-sec)",
fontWeight: 700,
display: "flex",
@@ -41,7 +41,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[
};
const bigRow: React.CSSProperties = { display: "flex", alignItems: "baseline", gap: 6 };
const bigNum: React.CSSProperties = {
- fontSize: "1.75rem",
+ fontSize: "1.85rem",
fontWeight: 800,
color: "var(--v5-text)",
fontFamily: "var(--v5-font-mono)",
@@ -49,7 +49,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[
letterSpacing: "-0.03em",
lineHeight: 1,
};
- const unit: React.CSSProperties = { fontSize: "0.68rem", color: "var(--v5-text-sec)", fontWeight: 500 };
+ const unit: React.CSSProperties = { fontSize: "0.75rem", color: "var(--v5-text-sec)", fontWeight: 500 };
const bar: React.CSSProperties = {
height: 4,
borderRadius: 2,
@@ -58,7 +58,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[
alignSelf: "center",
};
const sub: React.CSSProperties = {
- fontSize: "0.64rem",
+ fontSize: "0.72rem",
color: "var(--v5-text-sec)",
fontWeight: 500,
display: "flex",
@@ -104,19 +104,19 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[
활성률
{pctActive}
- %
+ %
-
+
기준 30일
@@ -136,7 +136,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[
명
[
GB
void;
+}) {
+ const [stage, setStage] = useState(initialStage);
+ const [result, setResult] = useState<{ admin_user_id: string; new_password: string } | null>(null);
+ const [showPw, setShowPw] = useState(false);
+ const [pwCopied, setPwCopied] = useState(false);
+ const qc = useQueryClient();
+
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ["company-admin", companyCode],
+ queryFn: () => getCompanyAdmin(companyCode),
+ staleTime: 5_000,
+ enabled: stage === "view",
+ });
+
+ const mutation = useMutation({
+ mutationFn: () => resetAdminPassword(companyCode),
+ onSuccess: (d) => {
+ if (d.error) return;
+ setResult({ admin_user_id: d.admin_user_id!, new_password: d.new_password! });
+ setStage("reset-done");
+ qc.invalidateQueries({ queryKey: ["company-admin", companyCode] });
+ qc.invalidateQueries({ queryKey: ["company-audit-log", companyCode] });
+ },
+ });
+
+ const admin = data || {};
+
+ // ─── 헤더 ───
+ const titleNode = (() => {
+ if (stage === "reset-warn" || stage === "reset-done") {
+ return (
+
+ 관리자 비밀번호 재설정
+
+ );
+ }
+ return (
+
+ 관리자 계정
+
+ );
+ })();
+
+ // ─── 본문 ───
+ let body: React.ReactNode = null;
+ if (stage === "view") {
+ if (isLoading) {
+ body = 조회 중... ;
+ } else if (!admin.found) {
+ body = 해당 회사에 COMPANY_ADMIN 계정을 찾을 수 없습니다. ;
+ } else {
+ body = (
+
+ 관리자 ID
+
+ {admin.user_id}
+
+
+ 이름
+ {admin.user_name || "—"}
+ 상태
+
+ {admin.status || "—"}
+
+ 최초 비번 변경
+
+
+ {admin.force_password_change ? "필요 (미완료)" : "완료됨"}
+
+
+ 생성일
+ {formatDate(admin.created_date)}
+
+ );
+ }
+ }
+
+ if (stage === "reset-warn") {
+ body = (
+ <>
+
+
+
+ 기존 관리자 비밀번호가 즉시 무효화 됩니다. 새 임시 비밀번호가 1회 표시되며, 첫 로그인 시 비밀번호 변경이 강제됩니다.
+
+ 진행하기 전에 해당 회사 관리자에게 먼저 공지하세요.
+
+
+ {mutation.isError && (
+
+ 오류: {(mutation.error as Error)?.message || "재설정 실패"}
+
+ )}
+ >
+ );
+ }
+
+ if (stage === "reset-done" && result) {
+ body = (
+ <>
+
+ 새 임시 비밀번호가 발급되었습니다. 이 창을 닫기 전에 반드시 복사 하세요. 다시 표시되지 않습니다.
+
+
+
관리자 ID
+
{result.admin_user_id}
+
새 임시 비밀번호
+
+
+ {showPw ? result.new_password : "•".repeat(result.new_password.length)}
+
+ setShowPw(!showPw)} title={showPw ? "숨기기" : "표시"} style={iconBtnStyle}>
+ {showPw ? : }
+
+ {
+ navigator.clipboard?.writeText(result.new_password);
+ setPwCopied(true);
+ }}
+ title="복사"
+ style={{ ...iconBtnStyle, color: pwCopied ? "rgb(var(--v5-green-rgb))" : "var(--v5-text-sec)" }}
+ >
+ {pwCopied ? : }
+
+
+
다음 로그인 시
+
비밀번호 변경을 강제로 요구합니다.
+
+ {!pwCopied && (
+
+ ⚠ 비밀번호를 아직 복사하지 않았습니다. 창 닫기 전에 복사하세요.
+
+ )}
+ >
+ );
+ }
+
+ // ─── 푸터 ───
+ let footer: React.ReactNode = null;
+ if (stage === "view") {
+ footer = (
+ <>
+ 닫기
+ }
+ onClick={() => setStage("reset-warn")}
+ disabled={!admin.found}
+ >
+ 비밀번호 재설정
+
+ >
+ );
+ } else if (stage === "reset-warn") {
+ footer = (
+ <>
+ }
+ onClick={() => (initialStage === "view" ? setStage("view") : onClose())}
+ disabled={mutation.isPending}
+ >
+ {initialStage === "view" ? "뒤로" : "취소"}
+
+ mutation.mutate()}
+ disabled={mutation.isPending}
+ icon={ }
+ >
+ {mutation.isPending ? "재설정 중..." : "새 비밀번호 발급"}
+
+ >
+ );
+ } else if (stage === "reset-done") {
+ footer = (
+ <>
+ {initialStage === "view" && (
+ {
+ setStage("view");
+ setResult(null);
+ setShowPw(false);
+ setPwCopied(false);
+ refetch();
+ }}
+ >
+ 계정 정보로
+
+ )}
+ 닫기
+ >
+ );
+ }
+
+ return (
+
+ {body}
+
+ );
+}
+
+// ─── UI helpers ───
+
+function Label({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
+function Value({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
+function ValueMono({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+function PadCenter({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+function Warn({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+function Badge({ color, children }: { color: "green" | "amber" | "muted"; children: React.ReactNode }) {
+ const palette = {
+ green: { bg: "rgba(var(--v5-green-rgb),0.12)", fg: "rgb(var(--v5-green-rgb))" },
+ amber: { bg: "rgba(var(--v5-amber-rgb),0.15)", fg: "var(--v5-amber)" },
+ muted: { bg: "var(--v5-bg-subtle)", fg: "var(--v5-text-sec)" },
+ }[color];
+ return (
+ {children}
+ );
+}
+
+function CopyInline({ text }: { text: any }) {
+ const [copied, setCopied] = useState(false);
+ if (!text) return null;
+ return (
+ {
+ navigator.clipboard?.writeText(String(text));
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ }}
+ title="복사"
+ style={{
+ background: "transparent",
+ border: "1px solid var(--v5-border)",
+ color: copied ? "rgb(var(--v5-green-rgb))" : "var(--v5-text-muted)",
+ borderRadius: 4,
+ padding: "2px 5px",
+ cursor: "pointer",
+ fontSize: 10,
+ }}
+ >
+ {copied ? : }
+
+ );
+}
+
+function formatDate(v: any): string {
+ if (v == null) return "—";
+ try {
+ let d: Date;
+ if (typeof v === "number") d = new Date(v);
+ else {
+ const s = String(v);
+ d = /^\d{10,}$/.test(s) ? new Date(Number(s)) : new Date(s);
+ }
+ if (isNaN(d.getTime())) return String(v);
+ return d.toISOString().replace("T", " ").slice(0, 19);
+ } catch {
+ return String(v);
+ }
+}
+
+const iconBtnStyle: React.CSSProperties = {
+ background: "transparent",
+ border: "1px solid var(--v5-border)",
+ color: "var(--v5-text-sec)",
+ borderRadius: 4,
+ padding: "3px 6px",
+ cursor: "pointer",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+};
+
+const warnBoxStyle: React.CSSProperties = {
+ display: "flex",
+ gap: 10,
+ padding: "0.85rem 0.95rem",
+ background: "rgba(var(--v5-amber-rgb), 0.1)",
+ border: "1px solid rgba(var(--v5-amber-rgb), 0.35)",
+ borderRadius: 8,
+ marginBottom: "0.2rem",
+ alignItems: "flex-start",
+};
diff --git a/frontend/components/admin/provisioning/modals/DeactivateModal.tsx b/frontend/components/admin/provisioning/modals/DeactivateModal.tsx
new file mode 100644
index 00000000..04859722
--- /dev/null
+++ b/frontend/components/admin/provisioning/modals/DeactivateModal.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { PauseCircle, PlayCircle, AlertTriangle } from "lucide-react";
+import { patchCompanyStatus } from "@/lib/api/provisioning";
+import ModalShell, { ModalBtn } from "./ModalShell";
+
+/**
+ * 비활성화 / 재활성화 공용 모달.
+ * mode="deactivate": 사유 입력 필수 (감사 로그 용)
+ * mode="reactivate": 간단 확인
+ */
+export default function DeactivateModal({
+ companyCode,
+ companyName,
+ currentStatus,
+ onClose,
+}: {
+ companyCode: string;
+ companyName?: string;
+ currentStatus: string;
+ onClose: () => void;
+}) {
+ const mode: "deactivate" | "reactivate" = currentStatus === "suspended" ? "reactivate" : "deactivate";
+ const [reason, setReason] = useState("");
+ const qc = useQueryClient();
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ patchCompanyStatus(
+ companyCode,
+ mode === "deactivate" ? "suspended" : "active",
+ mode === "deactivate" ? reason : undefined,
+ ),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["companies-stats"] });
+ qc.invalidateQueries({ queryKey: ["company-audit-log", companyCode] });
+ onClose();
+ },
+ });
+
+ const isDeactivate = mode === "deactivate";
+
+ return (
+
+ {isDeactivate ? : }
+ {isDeactivate ? "회사 비활성화" : "회사 재활성화"}
+
+ }
+ subtitle={companyName || companyCode}
+ onClose={onClose}
+ width={520}
+ footer={
+ <>
+ 취소
+ mutation.mutate()}
+ disabled={mutation.isPending || (isDeactivate && reason.trim().length < 3)}
+ >
+ {mutation.isPending
+ ? "처리 중..."
+ : isDeactivate
+ ? "비활성화 실행"
+ : "재활성화 실행"}
+
+ >
+ }
+ >
+ {isDeactivate ? (
+ <>
+
+
+
+ 비활성화 시 해당 회사 사용자의 로그인이 즉시 차단 됩니다. DB 와 데이터는 보존되며, 언제든 재활성화 가능합니다.
+
+
+
+ 비활성화 사유 *
+
+ }
+ subtitle={companyName || companyCode}
+ onClose={onClose}
+ width={560}
+ footer={
+ stage === 1 ? (
+ <>
+ 취소
+ setStage(2)} icon={ }>
+ 이해했습니다, 계속
+
+ >
+ ) : (
+ <>
+ 취소
+ mutation.mutate()}
+ disabled={!typedOk || mutation.isPending}
+ icon={ }
+ >
+ {mutation.isPending ? "삭제 중..." : "영구 삭제"}
+
+ >
+ )
+ }
+ >
+ {stage === 1 && (
+
+
+ ⚠ 이 작업은 되돌릴 수 없습니다.
+
+
다음 리소스가 즉시 영구 삭제 됩니다:
+
+ 테넌트 DB {dbName} (DROP DATABASE)
+ 해당 회사의 모든 사용자 · 권한 · 데이터 · 업로드 파일
+ 메타 DB 의 COMPANY_MNG row
+ 서브도메인 {subdomain}.invyone.com 라우팅
+
+
+ 감사 로그에는 삭제 기록이 남지만 데이터 자체는 복구 불가입니다. 백업이 필요하면 먼저 비활성화만 수행하세요.
+
+
+ )}
+
+ {stage === 2 && (
+
+
+ 확인을 위해 아래 입력란에 서브도메인 을 정확히 입력하세요.
+
+
+ 입력할 값: {subdomain}
+
+
setTyped(e.target.value)}
+ placeholder={subdomain}
+ autoFocus
+ style={{
+ width: "100%",
+ padding: "0.6rem 0.75rem",
+ background: "var(--v5-surface-solid)",
+ border: `1px solid ${typed.length === 0 ? "var(--v5-border)" : typedOk ? "rgb(var(--v5-green-rgb))" : "var(--v5-red)"}`,
+ borderRadius: 7,
+ fontSize: "0.9rem",
+ fontFamily: "var(--v5-font-mono)",
+ color: "var(--v5-text)",
+ outline: "none",
+ transition: "all 0.18s ease",
+ }}
+ />
+ {mutation.isError && (
+
+ 오류: {(mutation.error as Error)?.message}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/components/admin/provisioning/modals/ModalShell.tsx b/frontend/components/admin/provisioning/modals/ModalShell.tsx
new file mode 100644
index 00000000..a43ed89a
--- /dev/null
+++ b/frontend/components/admin/provisioning/modals/ModalShell.tsx
@@ -0,0 +1,186 @@
+"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 = (
+ {
+ 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",
+ }}
+ >
+
+
+
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+
+
+
+
{children}
+ {footer && (
+
+ {footer}
+
+ )}
+
+
+ );
+ 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 = {
+ 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 (
+
+ {icon}
+ {children}
+
+ );
+}
diff --git a/frontend/components/admin/provisioning/modals/RecopyTemplatesModal.tsx b/frontend/components/admin/provisioning/modals/RecopyTemplatesModal.tsx
new file mode 100644
index 00000000..89a1af6c
--- /dev/null
+++ b/frontend/components/admin/provisioning/modals/RecopyTemplatesModal.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Copy, AlertTriangle, Check } from "lucide-react";
+import { recopyTemplates } from "@/lib/api/provisioning";
+import ModalShell, { ModalBtn } from "./ModalShell";
+
+/**
+ * 템플릿 재복제 — 설치된 그룹 중 선택해서 메타 DB 에서 공통 템플릿 row 재적용.
+ * INSERT ... ON CONFLICT DO NOTHING 이라 기존 데이터는 그대로, 새 template row 만 추가됨.
+ */
+export default function RecopyTemplatesModal({
+ companyCode,
+ companyName,
+ dbName,
+ installedGroups,
+ onClose,
+ onSuccess,
+}: {
+ companyCode: string;
+ companyName?: string;
+ dbName: string;
+ installedGroups: Record[];
+ onClose: () => void;
+ onSuccess?: () => void;
+}) {
+ const [selected, setSelected] = useState>(new Set());
+ const qc = useQueryClient();
+
+ const mutation = useMutation({
+ mutationFn: () => recopyTemplates(companyCode, Array.from(selected)),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["companies-stats"] });
+ qc.invalidateQueries({ queryKey: ["company-audit-log", companyCode] });
+ },
+ });
+
+ const toggle = (id: string) => {
+ const next = new Set(selected);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ setSelected(next);
+ };
+
+ const result = mutation.data;
+
+ return (
+ 템플릿 재복제 }
+ subtitle={(companyName || companyCode) + " · " + dbName}
+ onClose={mutation.isPending ? () => {} : onClose}
+ width={620}
+ footer={
+ result ? (
+ { onSuccess?.(); onClose(); }}>완료
+ ) : (
+ <>
+ 취소
+ mutation.mutate()}
+ disabled={selected.size === 0 || mutation.isPending}
+ icon={ }
+ >
+ {mutation.isPending ? "복사 중..." : `재복제 (${selected.size}개 그룹)`}
+
+ >
+ )
+ }
+ >
+ {!result && (
+ <>
+
+
+
+ 메타 DB 의 공통 템플릿 row (company_code IN ('*','TEMPLATE')) 를 이 회사 DB 에
+ 추가합니다. 기존 데이터는 변경되지 않습니다 — INSERT ON CONFLICT DO NOTHING 정책.
+
+
+
+
+ 복사할 그룹
+
+
+ {installedGroups.map((g) => {
+ const checked = selected.has(g.id);
+ return (
+
+ toggle(g.id)}
+ style={{ accentColor: "var(--v5-primary)" }}
+ />
+
+
{g.label}
+
+ {g.id} · {(g.tables || []).length} 테이블
+
+
+
+ {g.installed ? "설치됨" : "미설치"}
+
+
+ );
+ })}
+
+
+ {mutation.isError && (
+
+ 오류: {(mutation.error as Error)?.message}
+
+ )}
+ >
+ )}
+
+ {result && (
+ <>
+
+ {result.errors?.length ? (
+ <>⚠ 부분 성공 — 총 {result.total_inserted} row 추가됨, {result.errors.length} 테이블 실패>
+ ) : (
+ <>✅ 완료 — 총 {result.total_inserted} row 추가됨 (중복은 건너뜀)>
+ )}
+
+
+
+ 테이블별 결과
+
+
+ {(result.tables || []).map((t: any) => (
+
+ {t.status === "ok" ? (
+
+ ) : (
+
+ )}
+
+ {t.table}
+
+
+ +{t.inserted}
+
+
+ ))}
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/components/admin/provisioning/tabs/MembersTab.tsx b/frontend/components/admin/provisioning/tabs/MembersTab.tsx
new file mode 100644
index 00000000..d88f35cf
--- /dev/null
+++ b/frontend/components/admin/provisioning/tabs/MembersTab.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { UserCircle2, Shield, User } from "lucide-react";
+import { getCompanyMembers } from "@/lib/api/provisioning";
+
+const TYPE_STYLE: Record = {
+ COMPANY_ADMIN: { color: "var(--v5-primary)", bg: "rgba(var(--v5-primary-rgb), 0.1)", label: "관리자" },
+ ADMIN: { color: "var(--v5-cyan)", bg: "rgba(var(--v5-cyan-rgb), 0.1)", label: "부관리자" },
+ USER: { color: "var(--v5-text-sec)", bg: "var(--v5-bg-subtle)", label: "일반" },
+};
+
+export default function MembersTab({ companyCode, open }: { companyCode: string; open: boolean }) {
+ const { data, isLoading } = useQuery({
+ queryKey: ["company-members", companyCode],
+ queryFn: () => getCompanyMembers(companyCode),
+ enabled: open,
+ staleTime: 10_000,
+ });
+
+ const members: Record[] = data?.members || [];
+
+ if (isLoading) {
+ return 구성원 조회 중... ;
+ }
+ if (members.length === 0) {
+ return 등록된 구성원이 없습니다. ;
+ }
+
+ return (
+
+
+ ID
+ 이름
+ 권한
+ 상태
+ 생성일
+
+ {members.map((m) => {
+ const typeStyle = TYPE_STYLE[m.user_type] || TYPE_STYLE.USER;
+ const Icon = m.user_type === "COMPANY_ADMIN" ? Shield : m.user_type === "ADMIN" ? UserCircle2 : User;
+ return (
+
+
+
+ {m.user_id}
+
+ {m.user_name || "—"}
+
+
+ {typeStyle.label}
+
+
+
+
+ {m.status || "—"}
+
+
+
+ {formatDate(m.created_date)}
+
+
+ );
+ })}
+
+ 총 {members.length} 명
+
+
+ );
+}
+
+function EmptyBox({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function formatDate(v: any): string {
+ if (!v) return "—";
+ try {
+ const d = new Date(String(v));
+ if (isNaN(d.getTime())) return String(v);
+ return d.toISOString().slice(0, 10);
+ } catch {
+ return String(v);
+ }
+}
diff --git a/frontend/components/admin/provisioning/tabs/TemplatesTab.tsx b/frontend/components/admin/provisioning/tabs/TemplatesTab.tsx
new file mode 100644
index 00000000..30a4d29c
--- /dev/null
+++ b/frontend/components/admin/provisioning/tabs/TemplatesTab.tsx
@@ -0,0 +1,219 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { useState } from "react";
+import { Layers, Copy, Check, ChevronDown, ChevronRight } from "lucide-react";
+import { getInstalledGroups } from "@/lib/api/provisioning";
+import RecopyTemplatesModal from "../modals/RecopyTemplatesModal";
+
+export default function TemplatesTab({
+ companyCode,
+ companyName,
+ dbName,
+ open,
+}: {
+ companyCode: string;
+ companyName?: string;
+ dbName: string;
+ open: boolean;
+}) {
+ const { data = [], isLoading, refetch } = useQuery({
+ queryKey: ["installed-groups", companyCode],
+ queryFn: () => getInstalledGroups(companyCode),
+ enabled: open,
+ staleTime: 30_000,
+ });
+ const [recopyOpen, setRecopyOpen] = useState(false);
+ const [expandedKeys, setExpandedKeys] = useState>(new Set());
+
+ if (isLoading) return 조회 중... ;
+
+ const installed = data.filter((g) => g.installed);
+ const notInstalled = data.filter((g) => !g.installed);
+
+ return (
+ <>
+
+
+
+ 설치된 템플릿 그룹 {installed.length} · 전체 {data.length}
+
+ setRecopyOpen(true)}
+ className="prov-hbtn prov-hbtn-secondary"
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 6,
+ height: 30,
+ padding: "0 0.75rem",
+ borderRadius: 6,
+ fontSize: "0.75rem",
+ fontWeight: 600,
+ border: "1px solid var(--v5-border)",
+ background: "var(--v5-surface-solid)",
+ color: "var(--v5-text)",
+ cursor: "pointer",
+ fontFamily: "inherit",
+ }}
+ >
+ 템플릿 재복제
+
+
+
+
+ {data.map((g) => {
+ const expanded = expandedKeys.has(g.id);
+ const tables: string[] = g.tables || [];
+ return (
+
+
{
+ const next = new Set(expandedKeys);
+ if (expanded) next.delete(g.id); else next.add(g.id);
+ setExpandedKeys(next);
+ }}
+ style={{
+ width: "100%",
+ display: "grid",
+ gridTemplateColumns: "16px 1fr 100px 80px",
+ gap: 10,
+ alignItems: "center",
+ padding: "0.55rem 0.75rem",
+ background: "transparent",
+ border: 0,
+ cursor: "pointer",
+ textAlign: "left",
+ fontFamily: "inherit",
+ }}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+ {g.label}
+ {g.required && (
+
+ 필수
+
+ )}
+
+
+ {tables.length} 테이블
+
+
+ {g.installed ? (
+
+ 설치됨
+
+ ) : (
+
+ 미설치
+
+ )}
+
+
+ {expanded && (
+
+ {tables.map((t: string) => (
+
+ {t}
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+
+ {recopyOpen && (
+ setRecopyOpen(false)}
+ onSuccess={() => {
+ setRecopyOpen(false);
+ refetch();
+ }}
+ />
+ )}
+ >
+ );
+}
+
+function EmptyBox({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/components/admin/provisioning/wizard/Step4Run.tsx b/frontend/components/admin/provisioning/wizard/Step4Run.tsx
index 6af58ecd..c829a920 100644
--- a/frontend/components/admin/provisioning/wizard/Step4Run.tsx
+++ b/frontend/components/admin/provisioning/wizard/Step4Run.tsx
@@ -78,6 +78,7 @@ export default function Step4Run({
address: state.address,
selected_groups: state.selected_groups || [],
initial_password: state.initial_password,
+ force_password_change: state.force_password_change !== false,
};
const resp = await createCompany(payload);
setJobId(resp.provisioning_id);
diff --git a/frontend/components/admin/provisioning/wizard/Wizard.tsx b/frontend/components/admin/provisioning/wizard/Wizard.tsx
index 64eabc85..f19749d7 100644
--- a/frontend/components/admin/provisioning/wizard/Wizard.tsx
+++ b/frontend/components/admin/provisioning/wizard/Wizard.tsx
@@ -21,6 +21,7 @@ import Step1Basic from "./Step1Basic";
import Step2Template from "./Step2Template";
import Step3Admin from "./Step3Admin";
import Step4Run from "./Step4Run";
+import AuditLogDrawer from "../AuditLogDrawer";
type RunDone = {
success: boolean;
@@ -46,6 +47,7 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
4: false,
});
const [runDone, setRunDone] = useState(null);
+ const [auditOpen, setAuditOpen] = useState(false);
function setState(patch: Record) {
setStateRaw((s) => ({ ...s, ...patch }));
@@ -230,20 +232,20 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
- 회사 프로비저닝
+ 회사 관리
void }) {
>
생성 완료 · 감사 로그에 기록됨
-
} disabled soon>감사 로그
+
}
+ onClick={() => setAuditOpen(true)}
+ >
+ 감사 로그
+
}
@@ -397,20 +404,17 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
- 실패 · DB 가 미완성 상태일 수 있습니다
+ 실패 · DB 는 서버가 자동 롤백(DROP DATABASE) 처리했습니다
-
} disabled soon>
- 롤백 (DB 삭제)
-
} onClick={tryClose}>
닫기
@@ -443,6 +447,13 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
)}
+ {auditOpen && runDone?.companyCode && (
+
setAuditOpen(false)}
+ />
+ )}
);
}
diff --git a/frontend/components/admin/provisioning/wizard/fields.tsx b/frontend/components/admin/provisioning/wizard/fields.tsx
index 0eca658f..3e67116c 100644
--- a/frontend/components/admin/provisioning/wizard/fields.tsx
+++ b/frontend/components/admin/provisioning/wizard/fields.tsx
@@ -30,12 +30,12 @@ export function Field({
{
if (loading) return;
- // 토큰이 있는데 아직 인증 확인 중이면 대기
- if (typeof window !== "undefined") {
- const token = localStorage.getItem("authToken");
- if (token && !isLoggedIn && !loading) {
- return;
- }
- }
+ // loading=false 인데 토큰만 localStorage 에 남아있고 isLoggedIn=false 라면
+ // useAuth 가 refreshUserData 에서 이미 removeToken 을 실행했어야 함.
+ // 혹시라도 외부가 stale 토큰을 set 해 놓은 경우엔 여기서 stuck 되지 않도록
+ // 그냥 리다이렉트로 진행시킨다. (이전 early-return 은 영구 stuck 을 유발했음)
if (requireAuth && !isLoggedIn) {
AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`);
@@ -47,12 +45,19 @@ export function AuthGuard({
return;
}
+ // 로그인은 됐지만 비밀번호 강제 변경 대기 — /change-password 외 경로는 모두 막음
+ if (isLoggedIn && forcePasswordChange && pathname !== "/change-password") {
+ AuthLogger.log("AUTH_GUARD_BLOCK", `force_password_change=true → /change-password 강제 이동`);
+ router.push("/change-password");
+ return;
+ }
+
if (requireAdmin && !isAdmin) {
AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`);
router.push(redirectTo);
return;
}
- }, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, redirectTo, router]);
+ }, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, forcePasswordChange, pathname, redirectTo, router]);
if (loading) {
return (
diff --git a/frontend/components/layout/TopNavBar.tsx b/frontend/components/layout/TopNavBar.tsx
index 1be9f3b9..c385b6ac 100644
--- a/frontend/components/layout/TopNavBar.tsx
+++ b/frontend/components/layout/TopNavBar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useRef, useState } from "react";
+import { Fragment, useCallback, useRef, useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
type UIMenu = {
@@ -65,8 +65,9 @@ export function TopNavBar({ menus, isMenuActive, onSelect }: TopNavBarProps) {
const isOpen = openId === sec.id;
const isActive = isMenuActive(sec);
return (
+
+ {i > 0 && }
openNow(sec.id)}
onMouseLeave={scheduleClose}
@@ -107,6 +108,7 @@ export function TopNavBar({ menus, isMenuActive, onSelect }: TopNavBarProps) {
)}
+
);
})}
diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts
index 3ab08fee..faaa1f91 100644
--- a/frontend/hooks/useAuth.ts
+++ b/frontend/hooks/useAuth.ts
@@ -25,6 +25,7 @@ interface UserInfo {
sabun?: string;
photo?: string | null;
company_code?: string;
+ force_password_change?: boolean;
}
interface AuthStatus {
@@ -323,6 +324,7 @@ export const useAuth = () => {
isLoggedIn: authStatus.isLoggedIn,
isAdmin: authStatus.isAdmin,
+ forcePasswordChange: user?.force_password_change === true,
userId: user?.user_id,
userName: user?.user_name,
companyCode: user?.company_code,
diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts
index 3c8feb3d..a9351b94 100644
--- a/frontend/lib/api/client.ts
+++ b/frontend/lib/api/client.ts
@@ -152,7 +152,24 @@ const startAutoRefresh = (): void => {
tokenRefreshTimer = setInterval(
async () => {
const token = TokenManager.getToken();
- if (token && TokenManager.isTokenExpiringSoon(token)) {
+ if (!token) {
+ // idle 페이지에서 토큰이 사라진 걸 interval 이 감지.
+ // API 호출이 없으면 401 인터셉터가 발동하지 않으므로 여기서 직접 리다이렉트.
+ // redirectToLogin 내부에서 /login 경로는 스킵.
+ authLog("AUTO_REFRESH_CHECK", "interval 중 토큰 없음 감지 → 로그인 리다이렉트");
+ redirectToLogin();
+ return;
+ }
+ if (TokenManager.isTokenExpired(token)) {
+ // 이미 만료 — 한 번 갱신 시도하고 실패하면 로그인으로
+ const newToken = await refreshToken();
+ if (!newToken) {
+ authLog("REDIRECT_TO_LOGIN", "interval 중 만료 토큰 갱신 실패 → 로그인");
+ redirectToLogin();
+ }
+ return;
+ }
+ if (TokenManager.isTokenExpiringSoon(token)) {
await refreshToken();
}
},
@@ -174,7 +191,12 @@ const setupVisibilityRefresh = (): void => {
if (!document.hidden) {
const token = TokenManager.getToken();
if (!token) {
- authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음");
+ // 탭 복귀 시 토큰이 아예 없다 = 다른 탭 로그아웃 / 저장소 정리 / WebView 리셋 등.
+ // API 호출이 일어나지 않는 idle 페이지라면 401 인터셉터가 안 발동하므로
+ // 여기서 능동적으로 로그인 페이지로 보낸다. (redirectToLogin 내부에서
+ // /login 경로면 no-op 이라 안전.)
+ authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음 → 로그인 리다이렉트");
+ redirectToLogin();
return;
}
@@ -194,6 +216,21 @@ const setupVisibilityRefresh = (): void => {
});
};
+// 다른 탭에서 authToken 을 지우면 storage 이벤트가 발화한다 (같은 탭에서는 발화 X).
+// 멀티탭 환경에서 한 탭의 로그아웃이 즉시 다른 탭으로 전파되도록 한다.
+const setupStorageListener = (): void => {
+ if (typeof window === "undefined") return;
+
+ window.addEventListener("storage", (e) => {
+ if (e.key !== "authToken") return;
+ // newValue 가 null/빈 문자열이면 제거된 것. 새 값이 들어온 경우(다른 탭 로그인/갱신)는 그대로 따라감.
+ if (!e.newValue) {
+ authLog("STORAGE_SYNC", "다른 탭에서 authToken 제거 감지 → 로그인 리다이렉트");
+ redirectToLogin();
+ }
+ });
+};
+
// 사용자 활동 감지 기반 갱신
const setupActivityBasedRefresh = (): void => {
if (typeof window === "undefined") return;
@@ -247,6 +284,7 @@ if (typeof window !== "undefined") {
startAutoRefresh();
setupVisibilityRefresh();
setupActivityBasedRefresh();
+ setupStorageListener();
}
// ============================================
@@ -350,6 +388,33 @@ apiClient.interceptors.response.use(
return Promise.reject(error);
}
+ // 403 — AuthGuard 외 경로에서도 반드시 막히도록 전역 리다이렉트 (backend filter 와 쌍)
+ if (status === 403 && typeof window !== "undefined") {
+ const errorData = error.response?.data as { errorCode?: string };
+ const ec = errorData?.errorCode;
+ if (ec === "PASSWORD_CHANGE_REQUIRED" && window.location.pathname !== "/change-password") {
+ authLog("REDIRECT_TO_CHANGE_PW", `403 PASSWORD_CHANGE_REQUIRED (${url})`);
+ window.location.href = "/change-password";
+ return Promise.reject(error);
+ }
+ if (ec === "CROSS_TENANT_REJECTED") {
+ authLog("REDIRECT_TO_LOGIN", `403 CROSS_TENANT_REJECTED (${url})`);
+ TokenManager.removeToken();
+ if (window.location.pathname !== "/login") {
+ window.location.href = "/login";
+ }
+ return Promise.reject(error);
+ }
+ if (ec === "TENANT_NOT_RESOLVED") {
+ authLog("REDIRECT_TO_LOGIN", `403 TENANT_NOT_RESOLVED (${url})`);
+ TokenManager.removeToken();
+ if (window.location.pathname !== "/login") {
+ window.location.href = "/login";
+ }
+ return Promise.reject(error);
+ }
+ }
+
// 401 에러 처리 (핵심 개선)
if (status === 401 && typeof window !== "undefined") {
const errorData = error.response?.data as { error?: { code?: string; details?: string } };
diff --git a/frontend/lib/api/provisioning.ts b/frontend/lib/api/provisioning.ts
index 49878bb6..94efe3d5 100644
--- a/frontend/lib/api/provisioning.ts
+++ b/frontend/lib/api/provisioning.ts
@@ -49,6 +49,7 @@ export interface CreateCompanyRequest {
address?: string;
selected_groups?: string[];
initial_password?: string;
+ force_password_change?: boolean;
}
export interface CreateCompanyResponse {
@@ -70,3 +71,81 @@ export async function getProvisioningStatus(jobId: string): Promise
> {
+ const { data } = await apiClient.get(`/admin/provisioning/companies/${companyCode}/admin`);
+ return data || {};
+}
+
+export interface ResetAdminPasswordResponse {
+ admin_user_id?: string;
+ new_password?: string;
+ force_password_change?: boolean;
+ error?: string;
+}
+
+export async function resetAdminPassword(companyCode: string): Promise {
+ const { data } = await apiClient.post(`/admin/provisioning/companies/${companyCode}/admin/reset-password`);
+ return data || {};
+}
+
+export async function getCompanyMembers(companyCode: string): Promise> {
+ const { data } = await apiClient.get(`/admin/provisioning/companies/${companyCode}/members`);
+ return data || {};
+}
+
+export async function getInstalledGroups(companyCode: string): Promise[]> {
+ const { data } = await apiClient.get(`/admin/provisioning/companies/${companyCode}/installed-groups`);
+ return Array.isArray(data) ? data : [];
+}
+
+export async function recopyTemplates(companyCode: string, selectedGroups: string[]): Promise> {
+ const { data } = await apiClient.post(`/admin/provisioning/companies/${companyCode}/re-copy`, {
+ selected_groups: selectedGroups,
+ });
+ return data || {};
+}
+
+export async function patchCompanyStatus(
+ companyCode: string,
+ status: "active" | "suspended",
+ reason?: string,
+): Promise> {
+ const { data } = await apiClient.patch(`/admin/provisioning/companies/${companyCode}/status`, {
+ status,
+ reason,
+ });
+ return data || {};
+}
+
+/** 영구 삭제 — 서브도메인 타이핑 확인 필수 */
+export async function deleteCompany(companyCode: string, confirmSubdomain: string): Promise> {
+ const { data } = await apiClient.delete(`/admin/provisioning/companies/${companyCode}`, {
+ data: { confirm_subdomain: confirmSubdomain },
+ });
+ return data || {};
+}
+
+export async function getCompanyAuditLog(
+ companyCode: string,
+ page = 1,
+ limit = 50,
+): Promise> {
+ const { data } = await apiClient.get(`/admin/provisioning/companies/${companyCode}/audit-log`, {
+ params: { page, limit },
+ });
+ return data || {};
+}
+
+export async function getGlobalAuditLog(
+ page = 1,
+ limit = 50,
+ action?: string,
+): Promise> {
+ const { data } = await apiClient.get(`/admin/provisioning/audit-log`, {
+ params: { page, limit, action },
+ });
+ return data || {};
+}
diff --git a/frontend/lib/csvExport.ts b/frontend/lib/csvExport.ts
new file mode 100644
index 00000000..d7dc37eb
--- /dev/null
+++ b/frontend/lib/csvExport.ts
@@ -0,0 +1,43 @@
+/**
+ * 간이 CSV 변환 유틸 — client-side only.
+ * BOM 포함하여 엑셀에서 한글 깨짐 방지.
+ */
+
+export function toCsvString(
+ rows: Record[],
+ columns: { key: string; label: string; format?: (v: any, row: Record) => string }[],
+): string {
+ const header = columns.map((c) => escapeCsv(c.label)).join(",");
+ const body = rows
+ .map((row) =>
+ columns
+ .map((c) => {
+ const raw = c.format ? c.format(row[c.key], row) : row[c.key];
+ return escapeCsv(raw);
+ })
+ .join(","),
+ )
+ .join("\n");
+ return "" + header + "\n" + body;
+}
+
+function escapeCsv(v: any): string {
+ if (v === null || v === undefined) return "";
+ const s = String(v);
+ if (/[",\n\r]/.test(s)) {
+ return `"${s.replace(/"/g, '""')}"`;
+ }
+ return s;
+}
+
+export function downloadCsv(filename: string, content: string) {
+ const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
diff --git a/frontend/styles/v5-atomics.css b/frontend/styles/v5-atomics.css
index 96b9e5f6..cd992a57 100644
--- a/frontend/styles/v5-atomics.css
+++ b/frontend/styles/v5-atomics.css
@@ -483,13 +483,13 @@
}
.v5-tn-item svg{opacity:.55;transition:opacity .2s var(--v5-ease-move),transform .2s var(--v5-ease-move);}
.v5-tn-section:hover .v5-tn-item,
-.v5-tn-section.open .v5-tn-item{color:var(--v5-text);background:var(--v5-surface-hover);}
+.v5-tn-section.open .v5-tn-item{color:var(--v5-primary);}
.v5-tn-section:hover .v5-tn-item svg,
.v5-tn-section.open .v5-tn-item svg{opacity:1;}
/* open 상태에선 chevron 을 살짝 내려서(2px) "펼쳤다" 감만 주기 — 180° 회전은 flyout 방향과 역방향이라 혼동 유발 */
.v5-tn-section.open .v5-tn-item svg{transform:translateY(1px);}
.v5-tn-section.on .v5-tn-item{color:var(--v5-primary);}
-.v5-admin-mode .v5-tn-section.on .v5-tn-item{color:var(--v5-cyan);}
+.v5-admin-mode .v5-tn-section.on .v5-tn-item{color:var(--v5-primary);}
/* Flyout (1단) — 섹션과 border 가 맞닿게(top:100%) + 내부 top 패딩으로 시각 여백만 유지.
→ 2px 공백 구간에서 mouseleave 가 타서 flyout 이 순간 사라지던 문제 제거. */
@@ -499,7 +499,6 @@
border:1px solid var(--v5-border);
border-radius:var(--v5-radius-md);
padding:.6rem .3rem .3rem .3rem;z-index:40;
- box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.12);
animation:v5-tn-flyout-in .2s var(--v5-ease-enter) backwards;
}
/* 섹션의 마지막 1px 과 flyout 의 첫 1px 를 겹쳐 "끊김" 제로. 시각적으론 boundary 가 안 보임(밝은 border 2중). */
@@ -507,7 +506,6 @@
content:'';position:absolute;left:0;right:0;top:-6px;height:6px;
background:transparent;pointer-events:auto;
}
-.dark .v5-tn-flyout{box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.5);}
@keyframes v5-tn-flyout-in{
from{opacity:0;transform:translateY(-6px);}
to {opacity:1;transform:translateY(0);}
@@ -532,7 +530,7 @@
}
.v5-tn-row:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
.v5-tn-row.on{background:rgba(var(--v5-primary-rgb),.1);color:var(--v5-primary);font-weight:600;}
-.v5-admin-mode .v5-tn-row.on{background:rgba(var(--v5-cyan-rgb),.1);color:var(--v5-cyan);}
+.v5-admin-mode .v5-tn-row.on{background:rgba(var(--v5-primary-rgb),.1);color:var(--v5-primary);}
.v5-tn-row .v5-tn-row-label{flex:1;min-width:0;}
.v5-tn-row .v5-tn-ic{display:inline-flex;width:14px;height:14px;color:currentColor;opacity:.7;}
.v5-tn-row svg:last-of-type{opacity:.5;flex-shrink:0;}
@@ -555,10 +553,8 @@
border:1px solid var(--v5-border);
border-radius:var(--v5-radius-md-2);
padding:.3rem;z-index:45;
- box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.12);
animation:v5-tn-flyout-in .2s var(--v5-ease-enter) backwards;
}
-.dark .v5-tn-sub{box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.5);}
/* =================================================================
Tweaks floating panel (디자인시스템 Tweaks UX).
@@ -570,21 +566,16 @@
background:var(--v5-surface-solid);
border:1px solid var(--v5-border);
border-radius:var(--v5-radius-lg-2);
- box-shadow:var(--v5-glow-md), 0 8px 24px rgba(0,0,0,.08);
padding:var(--v5-sp-4);
font-family:var(--v5-font-sans);
opacity:0;transform:translateY(10px) scale(.97);pointer-events:none;
transition:
opacity .25s var(--v5-ease-enter),
- transform .3s var(--v5-ease-enter),
- box-shadow .3s var(--v5-ease-move);
+ transform .3s var(--v5-ease-enter);
}
.v5-tweaks-panel.on{
opacity:1;transform:translateY(0) scale(1);pointer-events:auto;
}
-.dark .v5-tweaks-panel{
- box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.5);
-}
.v5-tweaks-head{
display:flex;align-items:center;justify-content:space-between;
diff --git a/frontend/styles/v5-layout.css b/frontend/styles/v5-layout.css
index fd32eac2..8a677e18 100644
--- a/frontend/styles/v5-layout.css
+++ b/frontend/styles/v5-layout.css
@@ -256,10 +256,9 @@ html:not(.dark) .v5-hdr{
box-shadow:0 0 6px rgba(var(--v5-pink-rgb),.8);
animation:v5-pdot 2s infinite;
}
-/* Admin mode tint: when .v5-admin-mode, the mode-toggle glows cyan. */
.v5-admin-mode .v5-hdr-icon.v5-mode-toggle{
- color:var(--v5-cyan);
- background:rgba(var(--v5-cyan-rgb),.10);
+ color:var(--v5-primary);
+ background:rgba(var(--v5-primary-rgb),.10);
}
/* 대시보드 생성 버튼 (헤더, Light/Dark 토글 왼쪽) */
@@ -302,9 +301,9 @@ html:not(.dark) .v5-hdr{
.v5-admin-btn .ic-home{display:none;}
.v5-admin-mode .v5-admin-btn .ic-gear{display:none;}
.v5-admin-mode .v5-admin-btn .ic-home{display:block;}
-.v5-admin-mode .v5-admin-btn{color:var(--v5-cyan);background:rgba(var(--v5-cyan-rgb),.10);}
-.v5-admin-mode .v5-admin-btn:hover{color:var(--v5-cyan);background:rgba(var(--v5-cyan-rgb),.16);}
-.v5-admin-mode .v5-admin-btn .v5-admin-label{color:var(--v5-cyan);}
+.v5-admin-mode .v5-admin-btn{color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.10);}
+.v5-admin-mode .v5-admin-btn:hover{color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.16);}
+.v5-admin-mode .v5-admin-btn .v5-admin-label{color:var(--v5-primary);}
/* Avatar */
.v5-avatar-w{position:relative;}
@@ -376,10 +375,10 @@ html:not(.dark) .v5-hdr{
/* Admin badge — display:none 대신 opacity/transform 으로 hidden 해서 zoom-in/out 애니메이션 가능 */
.v5-admin-badge{display:flex;align-items:center;gap:.4rem;padding:.2rem .6rem;border-radius:999px;
- background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-cyan-rgb),.08));
+ background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.14),rgba(var(--v5-primary-rgb),.06));
border:1px solid rgba(var(--v5-primary-rgb),.2);font-size:.58rem;font-weight:700;color:var(--v5-primary);
opacity:0;transform:scale(0) rotate(-30deg);pointer-events:none;}
-.dark .v5-admin-badge{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-cyan-rgb),.08));
+.dark .v5-admin-badge{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.14),rgba(var(--v5-primary-rgb),.06));
border-color:rgba(var(--v5-primary-rgb),.2);color:var(--v5-primary-light);}
.v5-admin-mode .v5-admin-badge{opacity:1;transform:scale(1) rotate(0);pointer-events:auto;}
/* badge zoom — 모드 진입 시 bouncy in, 이탈 시 quick out */
@@ -392,14 +391,16 @@ html:not(.dark) .v5-hdr{
@keyframes v5-badge-zoom-out{
0%{opacity:1;transform:scale(1) rotate(0)}
100%{opacity:0;transform:scale(0) rotate(30deg)}}
-.v5-admin-badge .badge-dot{width:6px;height:6px;border-radius:50%;background:var(--v5-cyan);
- box-shadow:0 0 8px var(--v5-cyan-glow);animation:v5-bdPulse 2s infinite;}
-@keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-cyan-glow)}50%{box-shadow:0 0 12px var(--v5-cyan-glow)}}
+.v5-admin-badge .badge-dot{width:6px;height:6px;border-radius:50%;background:var(--v5-primary);
+ box-shadow:0 0 8px var(--v5-primary-glow);animation:v5-bdPulse 2s infinite;}
+@keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-primary-glow)}50%{box-shadow:0 0 12px var(--v5-primary-glow)}}
/* ===== SOLID TABS ===== */
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto;
background:var(--v5-surface-solid);
- border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;}
+ border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;
+ scrollbar-width:none;-ms-overflow-style:none;}
+.v5-tabs::-webkit-scrollbar{display:none;}
.v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;}
.v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);}
@@ -514,9 +515,8 @@ html:not(.dark) .v5-side{
/* Content area stretches smoothly with sidebar */
.v5-body .v5-content{transition:all .5s cubic-bezier(.4,0,.2,1);}
-.v5-side.collapsed{width:56px;padding:.85rem .4rem;overflow:visible;z-index:30;
- border-right-color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
-.v5-side.collapsed .v5-side-toggle{box-shadow:var(--v5-glow-sm);border-color:var(--v5-primary);color:var(--v5-primary);}
+.v5-side.collapsed{width:56px;padding:.85rem .4rem;overflow:visible;z-index:30;}
+.v5-side.collapsed .v5-side-toggle{color:var(--v5-primary);}
/* Collapsed menu items — center icon */
.v5-side.collapsed .v5-si{justify-content:center;padding:.55rem;border-radius:10px;gap:0;position:relative;
@@ -529,6 +529,18 @@ html:not(.dark) .v5-side{
.v5-side.collapsed .v5-si:nth-child(6){animation-delay:.28s;}
.v5-side.collapsed .v5-si:nth-child(7){animation-delay:.32s;}
.v5-side.collapsed .v5-si:nth-child(8){animation-delay:.36s;}
+.v5-side.collapsed .v5-si:nth-child(9){animation-delay:.40s;}
+.v5-side.collapsed .v5-si:nth-child(10){animation-delay:.44s;}
+.v5-side.collapsed .v5-si:nth-child(11){animation-delay:.48s;}
+.v5-side.collapsed .v5-si:nth-child(12){animation-delay:.52s;}
+.v5-side.collapsed .v5-si:nth-child(13){animation-delay:.56s;}
+.v5-side.collapsed .v5-si:nth-child(14){animation-delay:.60s;}
+.v5-side.collapsed .v5-si:nth-child(15){animation-delay:.64s;}
+.v5-side.collapsed .v5-si:nth-child(16){animation-delay:.68s;}
+.v5-side.collapsed .v5-si:nth-child(17){animation-delay:.72s;}
+.v5-side.collapsed .v5-si:nth-child(18){animation-delay:.76s;}
+.v5-side.collapsed .v5-si:nth-child(19){animation-delay:.80s;}
+.v5-side.collapsed .v5-si:nth-child(20){animation-delay:.84s;}
@keyframes v5-iconPop{from{opacity:0;transform:scale(.5)}to{opacity:1;transform:scale(1)}}
/* Hide text when collapsed */
@@ -564,6 +576,18 @@ html:not(.dark) .v5-side{
.v5-side:not(.collapsed) .v5-si:nth-child(6){animation-delay:.2s;}
.v5-side:not(.collapsed) .v5-si:nth-child(7){animation-delay:.23s;}
.v5-side:not(.collapsed) .v5-si:nth-child(8){animation-delay:.26s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(9){animation-delay:.29s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(10){animation-delay:.32s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(11){animation-delay:.35s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(12){animation-delay:.38s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(13){animation-delay:.41s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(14){animation-delay:.44s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(15){animation-delay:.47s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(16){animation-delay:.50s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(17){animation-delay:.53s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(18){animation-delay:.56s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(19){animation-delay:.59s;}
+.v5-side:not(.collapsed) .v5-si:nth-child(20){animation-delay:.62s;}
@keyframes v5-menuSlideIn{from{opacity:0;transform:translateX(-12px)}to{opacity:1;transform:none}}
.v5-side:not(.collapsed) .v5-side-sec{opacity:1;
transition:opacity .35s .1s,height .35s .05s,padding .35s .05s;}
@@ -650,6 +674,19 @@ html:not(.dark) .v5-side{
.v5-side-flyout .fly-item:nth-child(5){animation-delay:.12s;}
.v5-side-flyout .fly-item:nth-child(6){animation-delay:.15s;}
.v5-side-flyout .fly-item:nth-child(7){animation-delay:.18s;}
+.v5-side-flyout .fly-item:nth-child(8){animation-delay:.21s;}
+.v5-side-flyout .fly-item:nth-child(9){animation-delay:.24s;}
+.v5-side-flyout .fly-item:nth-child(10){animation-delay:.27s;}
+.v5-side-flyout .fly-item:nth-child(11){animation-delay:.30s;}
+.v5-side-flyout .fly-item:nth-child(12){animation-delay:.33s;}
+.v5-side-flyout .fly-item:nth-child(13){animation-delay:.36s;}
+.v5-side-flyout .fly-item:nth-child(14){animation-delay:.39s;}
+.v5-side-flyout .fly-item:nth-child(15){animation-delay:.42s;}
+.v5-side-flyout .fly-item:nth-child(16){animation-delay:.45s;}
+.v5-side-flyout .fly-item:nth-child(17){animation-delay:.48s;}
+.v5-side-flyout .fly-item:nth-child(18){animation-delay:.51s;}
+.v5-side-flyout .fly-item:nth-child(19){animation-delay:.54s;}
+.v5-side-flyout .fly-item:nth-child(20){animation-delay:.57s;}
@keyframes v5-flyItemIn{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
.v5-side-flyout .fly-title{font-size:.58rem;font-weight:700;color:var(--v5-text-muted);
text-transform:uppercase;letter-spacing:.08em;padding:.3rem .6rem .45rem;}
@@ -663,12 +700,12 @@ html:not(.dark) .v5-side{
.v5-side-flyout .fly-item.on .ic{opacity:1;}
/* Admin sidebar accent */
-.v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-cyan-rgb),.12),rgba(var(--v5-cyan-rgb),.05));
- color:var(--v5-cyan);border-color:rgba(var(--v5-cyan-rgb),.2);}
+.v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-primary-rgb),.05));
+ color:var(--v5-primary);border-color:rgba(var(--v5-primary-rgb),.2);}
.v5-admin-side .v5-si.on .ic{opacity:1;}
-.v5-admin-side .v5-si::before{background:var(--v5-cyan);}
-.dark .v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-cyan-rgb),.12),rgba(var(--v5-cyan-rgb),.05));
- border-color:rgba(var(--v5-cyan-rgb),.15);}
+.v5-admin-side .v5-si::before{background:var(--v5-primary);}
+.dark .v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-primary-rgb),.05));
+ border-color:rgba(var(--v5-primary-rgb),.15);}
/* ===== MODE TRANSITION ===== */
.v5-mode-fade{position:fixed;inset:0;z-index:9998;pointer-events:none;opacity:0;
@@ -699,7 +736,7 @@ html:not(.dark) .v5-side{
.v5-hdr-glow{position:absolute;bottom:-1px;left:0;right:0;height:1px;
background:linear-gradient(90deg,transparent,var(--v5-primary),transparent);
opacity:0;pointer-events:none;}
-.v5-admin-mode .v5-hdr-glow{background:linear-gradient(90deg,transparent,var(--v5-cyan),transparent);}
+.v5-admin-mode .v5-hdr-glow{background:linear-gradient(90deg,transparent,var(--v5-primary),transparent);}
.v5-hdr-glow.mode-flash{animation:v5-mode-hdr-flash 1.4s cubic-bezier(.16,1,.3,1) forwards;}
@keyframes v5-mode-hdr-flash{
0%{opacity:0;height:1px;filter:blur(0)}
@@ -717,15 +754,13 @@ html:not(.dark) .v5-side{
from{opacity:0;transform:translateY(8px) scale(.9);filter:blur(4px)}
to{opacity:1;transform:translateY(0) scale(1);filter:blur(0)}}
-/* ===== MODE TRANSITION — toggle button burst (디자인시스템 mode-burst 포팅) =====
- JS 가 .v5-mode-burst 컨테이너를 fixed 위치(클릭점)에 append.
- 기본 = primary(보라, → 사용자 모드), .admin = cyan(시안, → 관리자 모드). */
+/* ===== MODE TRANSITION — toggle button burst ===== */
.v5-mode-burst{
position:fixed;width:0;height:0;
pointer-events:none;z-index:9998;
--burst-rgb:var(--v5-primary-rgb);
}
-.v5-mode-burst.admin{--burst-rgb:var(--v5-cyan-rgb);}
+.v5-mode-burst.admin{--burst-rgb:var(--v5-primary-rgb);}
/* Center expanding ring */
.v5-mode-burst .burst-ring{
@@ -768,11 +803,9 @@ html:not(.dark) .v5-side{
content:'';position:absolute;inset:0;
background:linear-gradient(90deg,
transparent 0%,
- rgba(var(--v5-cyan-rgb),0) 15%,
- rgba(var(--v5-cyan-rgb),.9) 40%,
- rgba(var(--v5-primary-rgb),1) 50%,
- rgba(var(--v5-pink-rgb),.9) 60%,
- rgba(var(--v5-cyan-rgb),0) 85%,
+ rgba(var(--v5-primary-rgb),0) 15%,
+ rgba(var(--v5-primary-rgb),.95) 50%,
+ rgba(var(--v5-primary-rgb),0) 85%,
transparent 100%);
filter:blur(.5px);
transform:translateX(-100%);
diff --git a/notes/gbpark/2026-04-24-provisioning-step2-redesign/mockup.html b/notes/gbpark/2026-04-24-provisioning-step2-redesign/mockup.html
new file mode 100644
index 00000000..2ab10fc5
--- /dev/null
+++ b/notes/gbpark/2026-04-24-provisioning-step2-redesign/mockup.html
@@ -0,0 +1,1011 @@
+
+
+
+
+
+Step2 — INVYONE 기본 제공 템플릿 (mockup v2)
+
+
+
+
+
+
+
+
+
A · 기본 · 템플릿 확인만 하고 “다음”
+
+
+ 01 · 회사 정보
+ ›
+ 02 · 기본 제공 템플릿
+ ›
+ 03 · 관리자 계정
+ ›
+ 04 · 생성
+
+
+
+
+
02 · 기본 제공 템플릿
+
INVYONE 기본 제공 템플릿
+
+ 새 회사에 제공될 기본 구성을 확인합니다. 모든 테이블(스키마) 은 예외 없이 복제 되므로 나중에 어떤 기능도 켤 수 있습니다.
+ 아래 카테고리에서 체크는 기준 데이터까지 함께 복제할지 를 의미합니다.
+
+
+
+
+
+
+
+
테이블 스키마 전부 복제
+
124개 전부 . 비어있더라도 나중에 기능 켜면 바로 사용 가능. 해제할 수 없습니다.
+
+
+
+
+
+
기준 데이터 선택적 복제
+
메타 DB invyone 에 company_code='*' 로 등록된 seed 행. 아래 체크로 포함 여부 결정.
+
+
+
+
+
+
+
+
+
+
INVYONE 기본 제공 템플릿 v1.3
+
마지막 업데이트 2026-04-20 · 승격한 사람 gbpark · 소스 메타 DB invyone
+
+
+
+ 재스캔
+
+
+
+
+
+
스키마 복제
+
124개
+
전체 테이블 — 항상
+
+
+
기준 데이터 포함
+
47개
+
아래 체크된 항목
+
+
+
복제 행 수
+
3,241
+
예상 소요 약 16초
+
+
+
+
+
+
+
필수 (해제 불가 — 빈 상태로 두면 회사 운영 자체가 불가)
+
+
+
+
+
+
+
+
+
+
+
+
+
화면 · 메뉴 · 권한
+
+
+ 필수
+
+
+
+ 데이터 포함 21 테이블
+ /
+ 1,267 행
+ /
+ 화면 74 · 메뉴 172 · 권한 8 · 관계 510
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 데이터 포함 9 테이블
+ /
+ 7,866 행
+ /
+ 공통코드 · 코드분류 · 다국어(4종) · 언어마스터
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 데이터 포함 7 테이블
+ /
+ 362 행
+ /
+ templates · 컴포넌트 표준 · 레이아웃 표준 · 테이블 라벨
+
+
+
+
+
+
+
+
+
+
추가 (해제 시 스키마만 복제. 고객이 나중에 필요하면 그때 설정)
+
+
+
+
+
+
+
+
+
+
리포트 템플릿
+
데이터 포함 5 테이블 / 48 행 / report_master · report_template · layout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
데이터 플로우
+
데이터 포함 5 테이블 / 32 행 / flow_definitions · flow_steps · connections
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
규칙 · 캐스케이딩 · 채번
+
데이터 포함 12 테이블 / 185 행 / business_rules · cascading_* · numbering_*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
대시보드 샘플
+
데이터 포함 0 테이블 / 해제됨 / dashboard_cards · dashboard_elements
+
+
+
+
+
+
+
+
+
+
고급 · 개별 테이블 단위로 조정
+
124 테이블 · 데이터 포함 47 · 해제 2
+
+
+
+
+
+ 복제 요약
+ 템플릿 기본 v1.3
+ 업데이트 2026-04-20
+
+ 스키마 (항상)
+ 전체 테이블 124 개
+
+ 기준 데이터 (선택)
+ 포함 테이블 47 개
+ 해제됨 2
+ 복제 행 수 3,241 건
+ 예상 소요 약 16초
+
+
+ 체크의 의미 — 테이블 자체는 어차피 다 복제됩니다. 체크 ON 은 메타 DB 의 company_code='*' seed 행까지 같이 복사. OFF 면 스키마만 복사되고 빈 테이블로 시작.
+
+
+
+
+
+
+
+
+
+
+
B · 고급 · 테이블 단위 확인 / 데이터 포함 토글
+
+
+ 01 · 회사 정보
+ ›
+ 02 · 기본 제공 템플릿
+ ›
+ 03 · 관리자 계정
+ ›
+ 04 · 생성
+
+
+
+
+
02 · 기본 제공 템플릿 · 고급
+
테이블 단위 조정
+
+ 기본 제공 템플릿에 포함된 모든 테이블 입니다. 스키마 열은 항상 ✓ .
+ 체크박스는 기준 데이터 포함 을 의미합니다.
+
+
+
+
+
+
+ 전체 124
+ 데이터 포함 47
+ 스키마만 77
+ ⚠ seed 0행 3
+
+
+
+
+
+
+
+ 테이블
+ 스키마
+ 전체 행
+ seed 행 ( * )
+ 데이터 복제
+ 카테고리
+
+
+
+
+
+ templates
+ ✓
+ 214
+ 182
+ 182
+ 핵심 로우코드
+
+
+
+ screen_definitions
+ ✓
+ 86
+ 74
+ 74
+ 핵심 화면
+
+
+
+ menu_info
+ ✓
+ 187
+ 172
+ 172
+ 핵심 메뉴
+
+
+
+ rel_menu_auth
+ ✓
+ 512
+ 510
+ 510
+ 핵심 메뉴
+
+
+
+ common_code
+ ✓
+ 1,428
+ 1,402
+ 1,402
+ 코드
+
+
+
+ code_info
+ ✓
+ 1,392
+ 1,366
+ 1,366
+ 코드
+
+
+
+ multi_lang_text
+ ✓
+ 4,210
+ 4,210
+ 4,210
+ 다국어
+
+
+
+ report_template
+ ✓
+ 48
+ 48
+ 48
+ 리포트
+
+
+
+
+
+ dashboard_cards
+ ✓
+ 18
+ 12
+ — (스키마만)
+ 수동 해제 대시보드
+
+
+
+ dashboard_elements
+ ✓
+ 42
+ 38
+ — (스키마만)
+ 수동 해제 대시보드
+
+
+
+
+
+ sales_order_mng
+ ✓
+ 12,847
+ 0
+ —
+ 런타임 전용
+
+
+
+ inventory_stock
+ ✓
+ 4,102
+ 0
+ —
+ 런타임 전용
+
+
+
+ work_instruction
+ ✓
+ 2,341
+ 0
+ —
+ 런타임 전용
+
+
+
+
+
+ dtg_management
+ ✓
+ 0
+ 0
+ —
+ seed 0행 mapper 참조 없음
+
+
+
+ batch_mappings
+ ✓
+ 0
+ 0
+ —
+ seed 0행
+
+
+
+ screen_templates
+ ✓
+ 0
+ 0
+ —
+ seed 0행 mapper 참조 없음
+
+
+
+
+ … 나머지 107개 테이블 (스크롤) …
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/start/README.md b/scripts/start/README.md
new file mode 100644
index 00000000..fe2e437b
--- /dev/null
+++ b/scripts/start/README.md
@@ -0,0 +1,70 @@
+# scripts/start — invyone 도커 기동 스크립트
+
+플랫폼별 1-클릭 실행 스크립트. `docker/dev/docker-compose.invyone.yml` 을 기준으로 프론트엔드 + 백엔드-스프링 컨테이너를 자동 기동합니다.
+
+## 파일 구성
+
+| 파일 | 플랫폼 | 실행 방법 |
+|---|---|---|
+| `invyone-start-docker-all.bat` | Windows | 더블클릭 또는 cmd 에서 실행 |
+| `invyone-start-docker-all.sh` | Linux | `./invyone-start-docker-all.sh` |
+| `invyone-start-docker-all.command` | macOS | Finder 더블클릭 (Terminal 자동 오픈) |
+
+## 하는 일
+
+1. `docker` CLI 설치 여부 + daemon 실행 여부 체크
+2. `docker compose -f docker/dev/docker-compose.invyone.yml up -d` 실행
+3. 컨테이너 상태 + 접속 URL 안내 출력
+
+## 접속 URL (기동 후)
+
+| 대상 | URL |
+|---|---|
+| Frontend | `http://localhost:9772` |
+| Backend API | `http://localhost:8083/api` |
+| 테넌트 (서브도메인) | `http://.localhost:9772` — 예: `http://test01.localhost:9772` |
+
+서브도메인 멀티테넌시는 `*.localhost` RFC 6761 자동 매핑을 이용합니다 (Chrome / Firefox / Edge 기본 지원, hosts 편집 · DNS 설정 불필요).
+
+## 리눅스/맥 첫 실행 전 — 실행 권한 1회
+
+```bash
+chmod +x scripts/start/invyone-start-docker-all.sh
+chmod +x scripts/start/invyone-start-docker-all.command
+```
+
+Windows 에서 작성된 파일을 git 으로 받은 경우 executable bit 가 빠질 수 있습니다. 리포에 박아두려면:
+
+```bash
+git update-index --chmod=+x scripts/start/invyone-start-docker-all.sh
+git update-index --chmod=+x scripts/start/invyone-start-docker-all.command
+```
+
+## 관련 명령 (참고용)
+
+| 용도 | 명령 |
+|---|---|
+| 로그 실시간 | `docker compose -f docker/dev/docker-compose.invyone.yml logs -f` |
+| 재시작 (볼륨 유지) | `docker compose -f docker/dev/docker-compose.invyone.yml restart` |
+| 컨테이너 내리기 | `docker compose -f docker/dev/docker-compose.invyone.yml down` |
+| 상태 확인 | `docker ps` |
+
+## 트러블슈팅
+
+### `docker daemon 이 실행 중이지 않습니다`
+Docker Desktop 실행 후 재시도.
+
+### 포트 충돌 (9772 / 8083)
+같은 머신에서 다른 프로젝트가 해당 포트를 점유했는지 확인 (`docker ps` · 태스크 매니저 · `lsof -i :9772`). 필요하면 `docker-compose.invyone.yml` 의 호스트 포트 매핑을 바꿔서 회피.
+
+### 테넌트 서브도메인 접속 시 META DB 로 떨어지는 경우
+현재 백엔드 `SubdomainResolverFilter` 와 프론트 `subdomain.ts` / `client.ts` 는 **`*.invyone.com` 만** 서브도메인으로 인식합니다. dev 환경에서 `*.localhost` 를 허용하는 패치는 별도 작업으로 예정 (`TENANT_ALLOWED_SUFFIXES` / `NEXT_PUBLIC_TENANT_HOST_SUFFIXES` 환경변수 기반).
+
+### 프론트 `routes-manifest.json` ENOENT
+`frontend/next.config.mjs` 의 `output: "standalone"` + `experimental.webpackMemoryOptimizations: true` 가 dev 모드에서 청크/매니페스트를 깨는 이슈. 이미 `isDev` 분기로 수정됨 (2026-04-07). 재발 시 해당 설정이 prod build 전용인지 확인.
+
+## 전제 조건
+
+- Docker Desktop (Windows/Mac) 또는 docker-ce + docker-compose-plugin (Linux)
+- 프로젝트 루트에 `docker/dev/docker-compose.invyone.yml` 존재
+- 본인 로컬 머신에 `.env` 등 필요 설정이 이미 놓여있어야 함 (syncthing 환경이면 자동 동기화)
diff --git a/scripts/start/invyone-start-docker-all.bat b/scripts/start/invyone-start-docker-all.bat
new file mode 100644
index 00000000..6ae4eb85
--- /dev/null
+++ b/scripts/start/invyone-start-docker-all.bat
@@ -0,0 +1,55 @@
+@echo off
+REM invyone 개발용 도커 컨테이너 기동 (Windows)
+REM 사용법: 더블클릭 또는 cmd 에서 실행
+chcp 65001 >nul
+
+pushd "%~dp0..\.."
+set COMPOSE_FILE=docker\dev\docker-compose.invyone.yml
+
+where docker >nul 2>&1
+if errorlevel 1 (
+ echo [invyone] docker 가 설치되어있지 않습니다. Docker Desktop 설치 후 다시 실행해주세요.
+ popd
+ pause
+ exit /b 1
+)
+
+docker info >nul 2>&1
+if errorlevel 1 (
+ echo [invyone] docker daemon 이 실행 중이지 않습니다. Docker Desktop 을 실행해주세요.
+ popd
+ pause
+ exit /b 1
+)
+
+if not exist "%COMPOSE_FILE%" (
+ echo [invyone] compose 파일을 찾을 수 없음: %COMPOSE_FILE%
+ popd
+ pause
+ exit /b 1
+)
+
+echo [invyone] 도커 컨테이너 기동 중...
+docker compose -f %COMPOSE_FILE% up -d
+if errorlevel 1 (
+ echo [invyone] 기동 실패. 로그를 확인해주세요.
+ popd
+ pause
+ exit /b 1
+)
+
+echo.
+echo [invyone] 컨테이너 상태:
+docker compose -f %COMPOSE_FILE% ps
+
+echo.
+echo [invyone] 접속 URL:
+echo Frontend: http://localhost:9772
+echo Backend: http://localhost:8083/api
+echo 테넌트: http://^.localhost:9772 (예: http://test01.localhost:9772)
+echo.
+echo [invyone] 로그 보기: docker compose -f docker/dev/docker-compose.invyone.yml logs -f
+echo [invyone] 컨테이너 내리기: docker compose -f docker/dev/docker-compose.invyone.yml down
+
+popd
+pause
diff --git a/scripts/start/invyone-start-docker-all.command b/scripts/start/invyone-start-docker-all.command
new file mode 100644
index 00000000..6860f0f0
--- /dev/null
+++ b/scripts/start/invyone-start-docker-all.command
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# invyone 개발용 도커 컨테이너 기동 (macOS)
+# 사용법: Finder 에서 더블클릭 또는 터미널에서 ./invyone-start-docker-all.command
+# 실행권한 필요 시: chmod +x invyone-start-docker-all.command
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+COMPOSE_FILE="$PROJECT_ROOT/docker/dev/docker-compose.invyone.yml"
+
+cd "$PROJECT_ROOT"
+
+if ! command -v docker >/dev/null 2>&1; then
+ echo "[invyone] docker 가 설치되어있지 않습니다. Docker Desktop 설치 후 다시 실행해주세요."
+ read -n 1 -s -r -p "아무 키나 눌러 종료"
+ exit 1
+fi
+
+if ! docker info >/dev/null 2>&1; then
+ echo "[invyone] docker daemon 이 실행 중이지 않습니다. Docker Desktop 을 실행해주세요."
+ read -n 1 -s -r -p "아무 키나 눌러 종료"
+ exit 1
+fi
+
+if [ ! -f "$COMPOSE_FILE" ]; then
+ echo "[invyone] compose 파일을 찾을 수 없음: $COMPOSE_FILE"
+ read -n 1 -s -r -p "아무 키나 눌러 종료"
+ exit 1
+fi
+
+echo "[invyone] 도커 컨테이너 기동 중..."
+docker compose -f "$COMPOSE_FILE" up -d
+
+echo ""
+echo "[invyone] 컨테이너 상태:"
+docker compose -f "$COMPOSE_FILE" ps
+
+echo ""
+echo "[invyone] 접속 URL:"
+echo " Frontend: http://localhost:9772"
+echo " Backend: http://localhost:8083/api"
+echo " 테넌트: http://.localhost:9772 (예: http://test01.localhost:9772)"
+echo ""
+echo "[invyone] 로그 보기: docker compose -f docker/dev/docker-compose.invyone.yml logs -f"
+echo "[invyone] 컨테이너 내리기: docker compose -f docker/dev/docker-compose.invyone.yml down"
+echo ""
+read -n 1 -s -r -p "아무 키나 눌러 종료"
diff --git a/scripts/start/invyone-start-docker-all.sh b/scripts/start/invyone-start-docker-all.sh
new file mode 100644
index 00000000..c9319115
--- /dev/null
+++ b/scripts/start/invyone-start-docker-all.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+# invyone 개발용 도커 컨테이너 기동 (리눅스)
+# 사용법: ./invyone-start-docker-all.sh
+# 실행권한 필요 시: chmod +x invyone-start-docker-all.sh
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+COMPOSE_FILE="$PROJECT_ROOT/docker/dev/docker-compose.invyone.yml"
+
+if ! command -v docker >/dev/null 2>&1; then
+ echo "[invyone] docker 가 설치되어있지 않습니다. Docker 설치 후 다시 실행해주세요."
+ exit 1
+fi
+
+if ! docker info >/dev/null 2>&1; then
+ echo "[invyone] docker daemon 이 실행 중이지 않습니다. Docker Desktop 또는 dockerd 를 실행해주세요."
+ exit 1
+fi
+
+if [ ! -f "$COMPOSE_FILE" ]; then
+ echo "[invyone] compose 파일을 찾을 수 없음: $COMPOSE_FILE"
+ exit 1
+fi
+
+echo "[invyone] 도커 컨테이너 기동 중..."
+docker compose -f "$COMPOSE_FILE" up -d
+
+echo ""
+echo "[invyone] 컨테이너 상태:"
+docker compose -f "$COMPOSE_FILE" ps
+
+echo ""
+echo "[invyone] 접속 URL:"
+echo " Frontend: http://localhost:9772"
+echo " Backend: http://localhost:8083/api"
+echo " 테넌트: http://.localhost:9772 (예: http://test01.localhost:9772)"
+echo ""
+echo "[invyone] 로그 보기: docker compose -f docker/dev/docker-compose.invyone.yml logs -f"
+echo "[invyone] 컨테이너 내리기: docker compose -f docker/dev/docker-compose.invyone.yml down"