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>
198 lines
8.0 KiB
TypeScript
198 lines
8.0 KiB
TypeScript
"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<string, any>[];
|
|
onClose: () => void;
|
|
onSuccess?: () => void;
|
|
}) {
|
|
const [selected, setSelected] = useState<Set<string>>(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 (
|
|
<ModalShell
|
|
title={<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><Copy size={18} strokeWidth={1.75} /> 템플릿 재복제</span>}
|
|
subtitle={(companyName || companyCode) + " · " + dbName}
|
|
onClose={mutation.isPending ? () => {} : onClose}
|
|
width={620}
|
|
footer={
|
|
result ? (
|
|
<ModalBtn variant="primary" onClick={() => { onSuccess?.(); onClose(); }}>완료</ModalBtn>
|
|
) : (
|
|
<>
|
|
<ModalBtn variant="ghost" onClick={onClose} disabled={mutation.isPending}>취소</ModalBtn>
|
|
<ModalBtn
|
|
variant="primary"
|
|
onClick={() => mutation.mutate()}
|
|
disabled={selected.size === 0 || mutation.isPending}
|
|
icon={<Copy size={13} strokeWidth={1.75} />}
|
|
>
|
|
{mutation.isPending ? "복사 중..." : `재복제 (${selected.size}개 그룹)`}
|
|
</ModalBtn>
|
|
</>
|
|
)
|
|
}
|
|
>
|
|
{!result && (
|
|
<>
|
|
<div style={{
|
|
display: "flex",
|
|
gap: 10,
|
|
padding: "0.75rem 0.85rem",
|
|
background: "var(--v5-bg-subtle)",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 7,
|
|
marginBottom: "1rem",
|
|
alignItems: "flex-start",
|
|
}}>
|
|
<AlertTriangle size={16} color="var(--v5-text-sec)" strokeWidth={1.75} style={{ flexShrink: 0, marginTop: 1 }} />
|
|
<div style={{ fontSize: "0.78rem", color: "var(--v5-text-sec)", lineHeight: 1.5 }}>
|
|
메타 DB 의 공통 템플릿 row (<code>company_code IN ('*','TEMPLATE')</code>) 를 이 회사 DB 에
|
|
추가합니다. <b>기존 데이터는 변경되지 않습니다</b> — INSERT ON CONFLICT DO NOTHING 정책.
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", letterSpacing: "0.05em", textTransform: "uppercase", fontWeight: 700, fontFamily: "var(--v5-font-mono)", marginBottom: 7 }}>
|
|
복사할 그룹
|
|
</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginBottom: "0.7rem" }}>
|
|
{installedGroups.map((g) => {
|
|
const checked = selected.has(g.id);
|
|
return (
|
|
<label
|
|
key={g.id}
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "18px 1fr 90px",
|
|
gap: 10,
|
|
alignItems: "center",
|
|
padding: "0.55rem 0.7rem",
|
|
background: checked ? "rgba(var(--v5-primary-rgb), 0.06)" : "var(--v5-surface-solid)",
|
|
border: `1px solid ${checked ? "var(--v5-primary)" : "var(--v5-border)"}`,
|
|
borderRadius: 7,
|
|
cursor: "pointer",
|
|
transition: "all 0.15s ease",
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={() => toggle(g.id)}
|
|
style={{ accentColor: "var(--v5-primary)" }}
|
|
/>
|
|
<div>
|
|
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--v5-text)" }}>{g.label}</div>
|
|
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 1, fontFamily: "var(--v5-font-mono)" }}>
|
|
{g.id} · {(g.tables || []).length} 테이블
|
|
</div>
|
|
</div>
|
|
<span style={{ textAlign: "right", fontSize: "0.7rem", color: g.installed ? "rgb(var(--v5-green-rgb))" : "var(--v5-text-muted)", fontWeight: 600 }}>
|
|
{g.installed ? "설치됨" : "미설치"}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{mutation.isError && (
|
|
<div style={{ fontSize: "0.8rem", color: "var(--v5-red)" }}>
|
|
오류: {(mutation.error as Error)?.message}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{result && (
|
|
<>
|
|
<div style={{
|
|
padding: "0.85rem 0.95rem",
|
|
background: result.errors?.length
|
|
? "rgba(var(--v5-amber-rgb), 0.1)"
|
|
: "rgba(var(--v5-green-rgb), 0.08)",
|
|
border: `1px solid ${result.errors?.length ? "rgba(var(--v5-amber-rgb), 0.35)" : "rgba(var(--v5-green-rgb), 0.35)"}`,
|
|
borderRadius: 8,
|
|
marginBottom: "1rem",
|
|
fontSize: "0.85rem",
|
|
color: "var(--v5-text)",
|
|
}}>
|
|
{result.errors?.length ? (
|
|
<><b style={{ color: "var(--v5-amber)" }}>⚠ 부분 성공</b> — 총 {result.total_inserted} row 추가됨, {result.errors.length} 테이블 실패</>
|
|
) : (
|
|
<><b style={{ color: "rgb(var(--v5-green-rgb))" }}>✅ 완료</b> — 총 {result.total_inserted} row 추가됨 (중복은 건너뜀)</>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", letterSpacing: "0.05em", textTransform: "uppercase", fontWeight: 700, fontFamily: "var(--v5-font-mono)", marginBottom: 7 }}>
|
|
테이블별 결과
|
|
</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 260, overflowY: "auto" }}>
|
|
{(result.tables || []).map((t: any) => (
|
|
<div
|
|
key={t.table}
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "16px 1fr 80px",
|
|
gap: 10,
|
|
alignItems: "center",
|
|
padding: "0.4rem 0.6rem",
|
|
background: "var(--v5-surface-solid)",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 5,
|
|
fontSize: "0.78rem",
|
|
}}
|
|
>
|
|
{t.status === "ok" ? (
|
|
<Check size={11} color="rgb(var(--v5-green-rgb))" strokeWidth={2} />
|
|
) : (
|
|
<AlertTriangle size={11} color="var(--v5-red)" strokeWidth={1.75} />
|
|
)}
|
|
<span style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
{t.table}
|
|
</span>
|
|
<span style={{ textAlign: "right", fontFamily: "var(--v5-font-mono)", color: t.status === "ok" ? "var(--v5-primary)" : "var(--v5-red)", fontWeight: 600 }}>
|
|
+{t.inserted}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</ModalShell>
|
|
);
|
|
}
|