Files
invyone/frontend/components/admin/provisioning/modals/RecopyTemplatesModal.tsx
T
gbpark 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>
2026-04-25 00:36:05 +09:00

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>
);
}