748 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|