515 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|