Files
invyone/frontend/components/admin/provisioning/CompanyAccordionRow.tsx
T
gbpark 8c861144dc
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
서브도메인 배포 작업
2026-04-24 17:49:16 +09:00

748 lines
24 KiB
TypeScript

"use client";
import { useLayoutEffect, useRef, useState } from "react";
import {
ChevronRight,
ChevronDown,
MoreHorizontal,
Info,
Users,
Layers,
AlertTriangle,
ArrowUpRight,
KeyRound,
Copy,
PauseCircle,
Trash2,
UserPlus,
RefreshCw,
} from "lucide-react";
import StatusDot from "./StatusDot";
const TABS = [
{ key: "overview", label: "개요", Icon: Info, soon: false },
{ key: "members", label: "구성원", Icon: Users, soon: true },
{ key: "templates", label: "템플릿", Icon: Layers, soon: true },
{ key: "danger", label: "위험 영역", Icon: AlertTriangle, soon: false },
] as const;
/**
* 단일 회사 row. 클릭 시 accordion 펼쳐지며 탭 4개 (개요/구성원/템플릿/위험영역) 표시.
* 목업 v9 AccRow 포팅. API 데이터 스키마는 /companies-stats 응답.
*/
export default function CompanyAccordionRow({
r,
open,
onToggle,
}: {
r: Record<string, any>;
open: boolean;
onToggle: () => void;
}) {
const [tab, setTab] = useState<"overview" | "members" | "templates" | "danger">("overview");
const tabsWrapRef = useRef<HTMLDivElement>(null);
const tabBtnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 });
useLayoutEffect(() => {
if (!open) return;
const el = tabBtnRefs.current[tab];
const wrap = tabsWrapRef.current;
if (el && wrap) {
const r1 = el.getBoundingClientRect();
const r2 = wrap.getBoundingClientRect();
setIndicator({ left: r1.left - r2.left, width: r1.width });
}
}, [tab, open]);
const sub = r.subdomain || "";
const name = r.company_name || r.name || r.company_code;
const plan = (r.plan || "Starter").toString();
const dbName = r.db_name || `${sub}_vexplor`;
const dbPct = Number(r.db_pct) || 0;
const users = Number(r.users) || 0;
const active30 = Number(r.active30) || 0;
return (
<div
data-accrow
data-prov-row
style={{
background: open ? "var(--v5-surface-hover)" : "var(--v5-surface-solid)",
borderBottom: "1px solid var(--v5-border)",
position: "relative",
}}
>
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 2,
background: "var(--v5-primary)",
transform: open ? "scaleY(1)" : "scaleY(0)",
transformOrigin: "center",
transition: "transform 0.25s cubic-bezier(0.4,0,0.2,1)",
}}
/>
<button
onClick={onToggle}
style={{
width: "100%",
textAlign: "left",
background: "transparent",
border: 0,
cursor: "pointer",
padding: "0.6rem 2rem 0.6rem 2rem",
display: "grid",
gridTemplateColumns: "14px minmax(280px, 420px) minmax(160px, 1fr) 90px 110px 110px 100px 80px 18px",
gap: "0.85rem",
alignItems: "center",
}}
>
<ChevronRight
size={12}
color={open ? "var(--v5-primary)" : "var(--v5-text-muted)"}
strokeWidth={1.75}
style={{
transform: open ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 0.25s cubic-bezier(0.4,0,0.2,1), color 0.2s ease",
}}
/>
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontSize: "0.82rem", fontWeight: 700, letterSpacing: "-0.01em", color: "var(--v5-text)" }}>
{name}
</span>
</div>
<div
style={{
fontSize: "0.64rem",
color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)",
marginTop: 2,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span style={{ color: "var(--v5-primary)" }}>{sub ? `${sub}.invyone.com` : "—"}</span>
<span style={{ opacity: 0.4 }}>·</span>
<span>{r.company_code}</span>
{r.industry && (
<>
<span style={{ opacity: 0.4 }}>·</span>
<span>{r.industry}</span>
</>
)}
</div>
</div>
{/* 담당자 */}
<div style={{ minWidth: 0 }}>
{r.owner || r.representative_name ? (
<>
<div
style={{
fontSize: "0.76rem",
fontWeight: 600,
color: "var(--v5-text)",
lineHeight: 1.15,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{r.owner || r.representative_name}
</div>
{r.email && (
<div
style={{
fontSize: "0.6rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
marginTop: 2,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{r.email}
</div>
)}
{!r.email && r.representative_phone && (
<div
style={{
fontSize: "0.6rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
marginTop: 2,
}}
>
{r.representative_phone}
</div>
)}
</>
) : (
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-muted)" }}></div>
)}
</div>
{/* 플랜 */}
<div>
<PlanBadge plan={plan} />
</div>
{/* 생성일 */}
<div>
<div style={labelSm}></div>
<div
style={{
fontSize: "0.72rem",
fontWeight: 600,
fontFamily: "var(--v5-font-mono)",
color: "var(--v5-text)",
lineHeight: 1.1,
}}
>
{formatRelative(r.created) || "—"}
</div>
<div
style={{
fontSize: "0.58rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
marginTop: 2,
}}
>
{formatDate(r.created)}
</div>
</div>
<div>
<div style={labelSm}></div>
<div
style={{
fontSize: "0.85rem",
fontWeight: 700,
fontFamily: "var(--v5-font-mono)",
fontVariantNumeric: "tabular-nums",
color: "var(--v5-text)",
lineHeight: 1.1,
}}
>
{users}
<span style={{ fontSize: "0.62rem", color: "var(--v5-text-sec)", fontWeight: 500, marginLeft: 2 }}></span>
</div>
<div style={{ fontSize: "0.6rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)", marginTop: 1 }}>
30 {active30}
</div>
</div>
<div>
<div style={labelSm}>DB</div>
<div style={{ fontSize: "0.66rem", fontFamily: "var(--v5-font-mono)", color: "var(--v5-text)", fontWeight: 600, marginBottom: 3 }}>
{r.db_size || "—"}
</div>
<div style={{ height: 2, background: "var(--v5-border)", borderRadius: 1, overflow: "hidden" }}>
<div
style={{
width: `${Math.min(dbPct, 100)}%`,
height: "100%",
background: dbPct > 70 ? "var(--v5-amber)" : "var(--v5-primary)",
}}
/>
</div>
</div>
<StatusDot status={r.db_status || r.status} />
<MoreHorizontal size={12} color="var(--v5-text-muted)" strokeWidth={1.75} />
</button>
<div
style={{
display: "grid",
gridTemplateRows: open ? "1fr" : "0fr",
transition: "grid-template-rows 0.3s cubic-bezier(0.4,0,0.2,1)",
}}
>
<div style={{ overflow: "hidden" }}>
<div
style={{
padding: "0 2rem 0.9rem 3rem",
opacity: open ? 1 : 0,
transform: open ? "translateY(0)" : "translateY(-4px)",
transition: "opacity 0.25s ease 0.05s, transform 0.25s ease 0.05s",
}}
>
{/* tabs */}
<div
ref={tabsWrapRef}
style={{
display: "flex",
gap: 0,
borderBottom: "1px solid var(--v5-border)",
marginBottom: "0.7rem",
position: "relative",
}}
>
{TABS.map(({ key: k, label: l, Icon: IconC, soon }) => (
<button
key={k}
ref={(el) => { tabBtnRefs.current[k] = el; }}
onClick={(e) => {
e.stopPropagation();
setTab(k);
}}
style={{
padding: "0.45rem 0.7rem 0.5rem",
background: "transparent",
border: 0,
cursor: "pointer",
fontSize: "0.72rem",
fontWeight: 600,
color: tab === k ? "var(--v5-primary)" : "var(--v5-text-sec)",
display: "inline-flex",
alignItems: "center",
gap: 5,
fontFamily: "inherit",
transition: "color 0.2s ease",
}}
>
<IconC size={11} strokeWidth={1.75} /> {l}
{soon && (
<span
style={{
fontSize: "0.52rem",
fontWeight: 700,
padding: "1px 5px",
borderRadius: 3,
background: "rgba(var(--v5-amber-rgb), 0.18)",
color: "var(--v5-amber)",
letterSpacing: "0.03em",
marginLeft: 2,
}}
>
</span>
)}
</button>
))}
<div style={{ flex: 1 }} />
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: "0.6rem",
color: "var(--v5-text-muted)",
padding: "0.4rem 0",
fontFamily: "var(--v5-font-mono)",
}}
>
{r.created && <> {formatDate(r.created)}</>}
{r.writer && <> · writer {r.writer}</>}
</div>
{/* sliding indicator */}
<div
style={{
position: "absolute",
bottom: -1,
left: indicator.left,
width: indicator.width,
height: 2,
background: "var(--v5-primary)",
boxShadow: "0 0 5px rgba(var(--v5-primary-rgb), 0.25)",
transition: "left 0.28s cubic-bezier(0.4,0,0.2,1), width 0.28s cubic-bezier(0.4,0,0.2,1)",
pointerEvents: "none",
}}
/>
</div>
{tab === "overview" && (
<div style={{ display: "grid", gridTemplateColumns: "1.3fr 1fr", gap: "1.2rem" }}>
{/* 기본정보 */}
<div>
<div style={sectionTitle}> </div>
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", rowGap: "0.5rem", fontSize: "0.72rem" }}>
{(
[
["회사 코드", r.company_code, true],
["회사명", name, false],
["서브도메인", sub ? <SubdomainLine sub={sub} /> : "—", true],
["DB명", dbName, true],
["사업자번호", r.brn || "—", true],
["플랜", plan, false],
["업종", r.industry || "—", false],
["대표자", r.owner || "—", false],
] as const
).map(([l, v, mono], i) => (
<div key={i} style={{ display: "contents" }}>
<span style={{ color: "var(--v5-text-sec)", fontSize: "0.68rem", fontWeight: 500 }}>{l}</span>
<span
style={{
color: "var(--v5-text)",
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
fontSize: mono ? "0.7rem" : "0.72rem",
fontWeight: 500,
}}
>
{v as any}
</span>
</div>
))}
</div>
</div>
{/* 운영지표 + 액션 */}
<div>
<div style={sectionTitle}> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.4rem" }}>
{(
[
["총 사용자", users],
["30일 활성", active30],
["DB", r.db_size || "—"],
["설치 템플릿", r.templates || 0],
] as const
).map(([l, v], i) => (
<div
key={i}
style={{
padding: "0.45rem 0.55rem",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 6,
}}
>
<div
style={{
fontSize: "0.6rem",
color: "var(--v5-text-sec)",
marginBottom: 3,
fontFamily: "var(--v5-font-mono)",
letterSpacing: "0.04em",
fontWeight: 600,
}}
>
{l}
</div>
<div
style={{
fontSize: "0.92rem",
fontWeight: 800,
color: "var(--v5-text)",
fontFamily: "var(--v5-font-mono)",
fontVariantNumeric: "tabular-nums",
letterSpacing: "-0.01em",
}}
>
{v}
</div>
</div>
))}
</div>
<div style={{ display: "flex", gap: 5, marginTop: "0.55rem", flexWrap: "wrap" }}>
<ABtn
icon={<ArrowUpRight size={11} strokeWidth={1.75} />}
onClick={(e) => {
e.stopPropagation();
if (sub) window.open(`http://${sub}.invyone.com`, "_blank");
}}
>
</ABtn>
<ABtn icon={<KeyRound size={11} strokeWidth={1.75} />} soon> </ABtn>
<ABtn icon={<Copy size={11} strokeWidth={1.75} />} soon>릿 </ABtn>
</div>
</div>
</div>
)}
{tab === "members" && (
<EmptyNote>
<UserPlus size={14} strokeWidth={1.75} /> tenant DB (Phase 4).
{users}.
</EmptyNote>
)}
{tab === "templates" && (
<EmptyNote>
<Layers size={14} strokeWidth={1.75} /> 릿 {r.templates || 0}. Phase 4 .
</EmptyNote>
)}
{tab === "danger" && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
{[
{
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 (
<div
key={i}
style={{
display: "grid",
gridTemplateColumns: "20px 1fr 100px",
gap: 12,
alignItems: "center",
padding: "0.55rem 0.7rem",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 6,
}}
>
<div style={{ color: row.c }}>
<IconC size={14} strokeWidth={1.75} />
</div>
<div>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "var(--v5-text)" }}>{row.t}</div>
<div style={{ fontSize: "0.6rem", color: "var(--v5-text-muted)", marginTop: 2 }}>{row.d}</div>
</div>
<button
disabled
title="Phase 4 에서 구현"
style={{
height: 26,
padding: "0 0.6rem",
borderRadius: 5,
border: `1px solid ${row.c}`,
background: "transparent",
color: row.c,
fontSize: "0.62rem",
fontWeight: 700,
cursor: "not-allowed",
opacity: 0.5,
fontFamily: "inherit",
}}
>
{row.b}
</button>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
}
const labelSm: React.CSSProperties = {
fontSize: "0.58rem",
color: "var(--v5-text-sec)",
letterSpacing: "0.06em",
textTransform: "uppercase",
fontWeight: 700,
marginBottom: 2,
fontFamily: "var(--v5-font-mono)",
};
const sectionTitle: React.CSSProperties = {
fontSize: "0.62rem",
color: "var(--v5-text-sec)",
letterSpacing: "0.06em",
textTransform: "uppercase",
fontWeight: 700,
marginBottom: "0.55rem",
fontFamily: "var(--v5-font-mono)",
};
function SubdomainLine({ sub }: { sub: string }) {
return (
<span>
<span style={{ color: "var(--v5-primary)" }}>{sub}</span>
<span style={{ color: "var(--v5-text-muted)" }}>.invyone.com</span>
</span>
);
}
function ABtn({
children,
icon,
onClick,
soon,
}: {
children: React.ReactNode;
icon?: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
soon?: boolean;
}) {
return (
<button
onClick={soon ? (e) => e.stopPropagation() : (e) => {
e.stopPropagation();
onClick?.(e);
}}
disabled={soon}
title={soon ? "준비 중인 기능입니다" : undefined}
className={soon ? undefined : "prov-hbtn prov-hbtn-secondary"}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
height: 26,
padding: "0 0.6rem",
borderRadius: 6,
fontSize: "0.64rem",
fontWeight: 600,
border: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
color: "var(--v5-text)",
cursor: soon ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
fontFamily: "inherit",
opacity: soon ? 0.55 : 1,
}}
>
{icon}
{children}
{soon && (
<span
style={{
fontSize: "0.54rem",
fontWeight: 700,
padding: "1px 5px",
borderRadius: 3,
background: "rgba(var(--v5-amber-rgb), 0.18)",
color: "var(--v5-amber)",
letterSpacing: "0.03em",
marginLeft: 2,
}}
>
</span>
)}
</button>
);
}
function EmptyNote({ children }: { children: React.ReactNode }) {
return (
<div
style={{
padding: "1rem",
textAlign: "center",
border: "1px dashed var(--v5-border)",
borderRadius: 6,
color: "var(--v5-text-muted)",
fontSize: "0.66rem",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
width: "100%",
}}
>
{children}
</div>
);
}
function formatDate(v: any): string {
if (!v) return "—";
try {
const d = typeof v === "number" ? new Date(v) : new Date(String(v));
if (isNaN(d.getTime())) return String(v);
return d.toISOString().slice(0, 10);
} catch {
return String(v);
}
}
function formatRelative(v: any): string {
if (!v) return "";
try {
const d = typeof v === "number" ? new Date(v) : new Date(String(v));
if (isNaN(d.getTime())) return "";
const diffMs = Date.now() - d.getTime();
const sec = Math.round(diffMs / 1000);
if (sec < 60) return "방금";
const min = Math.round(sec / 60);
if (min < 60) return `${min}분 전`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}시간 전`;
const day = Math.round(hr / 24);
if (day < 7) return `${day}일 전`;
const wk = Math.round(day / 7);
if (wk < 5) return `${wk}주 전`;
const mo = Math.round(day / 30);
if (mo < 12) return `${mo}개월 전`;
const yr = Math.round(day / 365);
return `${yr}년 전`;
} catch {
return "";
}
}
const PLAN_STYLE: Record<string, { bg: string; color: string; border: string }> = {
ENTERPRISE: {
bg: "rgba(var(--v5-primary-rgb), 0.12)",
color: "var(--v5-primary)",
border: "rgba(var(--v5-primary-rgb), 0.3)",
},
STANDARD: {
bg: "rgba(var(--v5-cyan-rgb), 0.1)",
color: "var(--v5-cyan)",
border: "rgba(var(--v5-cyan-rgb), 0.3)",
},
STARTER: {
bg: "var(--v5-bg-subtle)",
color: "var(--v5-text-sec)",
border: "var(--v5-border)",
},
};
function PlanBadge({ plan }: { plan: string }) {
const key = plan.toUpperCase();
const s = PLAN_STYLE[key] || PLAN_STYLE.STARTER;
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
fontSize: "0.6rem",
fontWeight: 700,
padding: "3px 8px",
borderRadius: 4,
background: s.bg,
color: s.color,
border: `1px solid ${s.border}`,
letterSpacing: "0.06em",
fontFamily: "var(--v5-font-mono)",
whiteSpace: "nowrap",
}}
>
{key}
</span>
);
}