Files
invyone/frontend/components/admin/provisioning/CompanyAccordionRow.tsx
T
gbpark 8be7e16e56
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m28s
서브도메인설정
2026-04-24 04:56:40 +09:00

515 lines
17 KiB
TypeScript

"use client";
import { 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";
/**
* 단일 회사 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 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
style={{
background: open ? "var(--v5-surface-hover)" : "var(--v5-surface-solid)",
borderBottom: "1px solid var(--v5-border)",
position: "relative",
transition: "background 0.12s",
}}
>
{open && (
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 2,
background: "var(--v5-primary)",
}}
/>
)}
<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 1fr 110px 100px 80px 18px",
gap: "0.85rem",
alignItems: "center",
}}
>
{open ? (
<ChevronDown size={12} color="var(--v5-text-muted)" strokeWidth={1.75} />
) : (
<ChevronRight size={12} color="var(--v5-text-muted)" strokeWidth={1.75} />
)}
<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>
<span
style={{
fontSize: "0.58rem",
padding: "1px 6px",
borderRadius: 3,
background: "var(--v5-border)",
color: "var(--v5-text)",
fontWeight: 700,
letterSpacing: "0.04em",
}}
>
{plan.toUpperCase()}
</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>
<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>
{open && (
<div style={{ padding: "0 2rem 0.9rem 3rem" }}>
{/* tabs */}
<div style={{ display: "flex", gap: 0, borderBottom: "1px solid var(--v5-border)", marginBottom: "0.7rem" }}>
{([
["overview", "개요", Info],
["members", "구성원", Users],
["templates", "템플릿", Layers],
["danger", "위험 영역", AlertTriangle],
] as const).map(([k, l, IconC]) => (
<button
key={k}
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)",
borderBottom: `2px solid ${tab === k ? "var(--v5-primary)" : "transparent"}`,
marginBottom: -1,
display: "inline-flex",
alignItems: "center",
gap: 5,
fontFamily: "inherit",
}}
>
<IconC size={11} strokeWidth={1.75} /> {l}
</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>
</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} />}> </ABtn>
<ABtn icon={<Copy size={11} strokeWidth={1.75} />}>릿 </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>
);
}
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,
}: {
children: React.ReactNode;
icon?: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
}) {
return (
<button
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
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: "pointer",
whiteSpace: "nowrap",
fontFamily: "inherit",
transition: "all 0.15s",
}}
>
{icon}
{children}
</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);
}
}