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

526 lines
17 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { getCompaniesStats } from "@/lib/api/provisioning";
import CompanyStatsStrip from "@/components/admin/provisioning/CompanyStatsStrip";
import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordionRow";
import Wizard from "@/components/admin/provisioning/wizard/Wizard";
const PAGE_SIZE = 10;
/**
* SUPER_ADMIN — 회사 프로비저닝 / 서브도메인 관리.
* 경로: /admin/sysMng/subdomainList
*
* 기존 회사 관리(/admin/userMng/companyList) 는 일반 CRUD 그대로 유지.
* 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅" 전용.
*/
export default function SubdomainListPage() {
const [openKey, setOpenKey] = useState<string | null>(null);
const [q, setQ] = useState("");
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
const [planFilter, setPlanFilter] = useState("all");
const [wizardOpen, setWizardOpen] = useState(false);
const [page, setPage] = useState(1);
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
queryKey: ["companies-stats"],
queryFn: getCompaniesStats,
refetchInterval: (query) => {
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
const hasProvisioning = Array.isArray(query.state.data)
? query.state.data.some((r: any) => r.db_status === "provisioning")
: false;
return hasProvisioning ? 3_000 : 30_000;
},
staleTime: 2_000,
});
const filtered = useMemo(
() =>
rows
.filter((r) => filter === "all" || (r.db_status || r.status) === filter)
.filter((r) => planFilter === "all" || (r.plan || "Starter") === planFilter)
.filter((r) => {
if (!q) return true;
const needle = q.toLowerCase();
return (
(r.company_name || "").toLowerCase().includes(needle) ||
(r.subdomain || "").toLowerCase().includes(needle) ||
(r.company_code || "").toLowerCase().includes(needle)
);
}),
[rows, filter, planFilter, q],
);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const pageStart = (safePage - 1) * PAGE_SIZE;
const paged = filtered.slice(pageStart, pageStart + PAGE_SIZE);
useEffect(() => {
setPage(1);
}, [q, filter, planFilter]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const activeCount = rows.filter((r) => r.db_status === "active").length;
const provisCount = rows.filter((r) => r.db_status === "provisioning").length;
const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
return (
<div
style={{
padding: "1.1rem",
height: "100%",
display: "flex",
flexDirection: "column",
background: "var(--v5-bg)",
color: "var(--v5-text)",
overflow: "hidden",
}}
>
<style>{`
@keyframes pulsedot { 0%,100% { opacity: 1; } 50% { opacity: 0.35; transform: scale(0.88); } }
@keyframes provPageFadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes provPageFade {
from { opacity: 0; }
to { opacity: 1; }
}
[data-accrow]:last-child { border-bottom: 0 !important; }
[data-prov-enter] {
animation: provPageFadeUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
}
[data-prov-enter="1"] { animation-delay: 0ms; }
[data-prov-enter="2"] { animation-delay: 70ms; }
[data-prov-enter="3"] { animation-delay: 140ms; }
[data-prov-enter="4"] { animation-delay: 210ms; }
[data-prov-enter="5"] { animation-delay: 280ms; }
[data-prov-row] {
transition: background 0.18s ease, box-shadow 0.2s ease, transform 0.2s ease;
animation: provPageFade 0.4s ease-out both;
}
[data-prov-row]:hover {
background: var(--v5-surface-hover) !important;
}
.prov-hbtn {
transition: transform 0.18s cubic-bezier(0.4,0,0.2,1),
box-shadow 0.18s ease,
background 0.18s ease,
border-color 0.18s ease;
}
.prov-hbtn:not(:disabled):hover { transform: translateY(-1px); }
.prov-hbtn:not(:disabled):active { transform: translateY(0) scale(0.97); }
.prov-hbtn-primary:not(:disabled):hover {
box-shadow: 0 0 14px rgba(var(--v5-primary-rgb), 0.28) !important;
}
.prov-hbtn-secondary:not(:disabled):hover {
background: var(--v5-surface-hover) !important;
border-color: var(--v5-primary) !important;
color: var(--v5-primary) !important;
}
.prov-pagebtn {
transition: all 0.15s ease;
}
.prov-pagebtn:not(:disabled):hover {
background: var(--v5-surface-hover) !important;
border-color: var(--v5-primary) !important;
color: var(--v5-primary) !important;
}
.prov-pagebtn-active {
box-shadow: 0 0 5px rgba(var(--v5-primary-rgb), 0.2);
}
.prov-input:focus {
border-color: var(--v5-primary) !important;
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), 0.15) !important;
}
`}</style>
{/* 페이지 헤더 */}
<div
data-prov-enter="1"
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
marginBottom: "0.85rem",
gap: "1rem",
}}
>
<div>
<div style={{ fontSize: "0.68rem", color: "var(--v5-text-sec)", fontWeight: 500, marginBottom: 3 }}>
<b style={{ color: "var(--v5-text)" }}></b> · · ·
</div>
<div style={{ fontSize: "1.15rem", fontWeight: 800, letterSpacing: "-0.02em", color: "var(--v5-text)" }}>
</div>
<div
style={{
fontSize: "0.72rem",
color: "var(--v5-text-sec)",
marginTop: "0.25rem",
fontWeight: 500,
}}
>
Invy.one ·
</div>
</div>
<div style={{ display: "flex", gap: "0.4rem" }}>
<HeaderBtn onClick={() => refetch()} icon={<RefreshCw size={11} strokeWidth={1.75} />}>
</HeaderBtn>
<HeaderBtn icon={<FileText size={11} strokeWidth={1.75} />} soon> </HeaderBtn>
<HeaderBtn icon={<Download size={11} strokeWidth={1.75} />} soon>CSV </HeaderBtn>
<HeaderBtn
variant="primary"
icon={<Plus size={11} strokeWidth={1.75} />}
onClick={() => setWizardOpen(true)}
>
</HeaderBtn>
</div>
</div>
{/* stats strip */}
<div data-prov-enter="2">
<CompanyStatsStrip rows={rows} />
</div>
{/* toolbar */}
<div
data-prov-enter="3"
style={{
marginLeft: "-1.1rem",
marginRight: "-1.1rem",
padding: "0.55rem 2rem",
borderTop: "1px solid var(--v5-border)",
borderBottom: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
display: "flex",
gap: "0.45rem",
alignItems: "center",
}}
>
<div style={{ position: "relative", flex: "0 0 260px" }}>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="회사명 · subdomain · company_code 검색"
className="prov-input"
style={{
width: "100%",
padding: "0.4rem 0.45rem 0.4rem 1.7rem",
background: "var(--v5-surface-hover)",
border: "1px solid var(--v5-border)",
borderRadius: 0,
color: "var(--v5-text)",
fontSize: "0.72rem",
fontFamily: "var(--v5-font-mono)",
outline: "none",
transition: "all 0.18s ease",
}}
/>
<span
style={{
position: "absolute",
left: 8,
top: "50%",
transform: "translateY(-50%)",
color: "var(--v5-text-muted)",
}}
>
<Search size={11} strokeWidth={1.75} />
</span>
</div>
<select
value={filter}
onChange={(e) => setFilter(e.target.value as any)}
style={selectStyle}
>
<option value="all"> </option>
<option value="active"></option>
<option value="provisioning"> </option>
<option value="failed"></option>
<option value="inactive"></option>
</select>
<select value={planFilter} onChange={(e) => setPlanFilter(e.target.value)} style={selectStyle}>
<option value="all"> </option>
<option value="Enterprise">Enterprise</option>
<option value="Standard">Standard</option>
<option value="Starter">Starter</option>
</select>
<div style={{ flex: 1 }} />
<div style={{ fontSize: "0.66rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)", fontWeight: 500 }}>
<b style={{ color: "rgb(var(--v5-green-rgb))" }}>{activeCount}</b>
<span style={{ margin: "0 6px", opacity: 0.4 }}>·</span>
<b style={{ color: "var(--v5-primary)" }}>{provisCount}</b>
<span style={{ margin: "0 6px", opacity: 0.4 }}>·</span>
<b style={{ color: "var(--v5-text)" }}>{inactCount}</b>
</div>
</div>
{/* list header */}
<div
data-prov-enter="4"
style={{
marginLeft: "-1.1rem",
marginRight: "-1.1rem",
display: "grid",
gridTemplateColumns: "14px minmax(280px, 420px) minmax(160px, 1fr) 90px 110px 110px 100px 80px 18px",
gap: "0.85rem",
padding: "0.65rem 2rem",
fontSize: "0.7rem",
color: "var(--v5-text)",
letterSpacing: "0.02em",
fontWeight: 700,
background: "var(--v5-surface-hover)",
borderBottom: "1px solid var(--v5-border)",
}}
>
<span />
<span> / </span>
<span></span>
<span></span>
<span></span>
<span></span>
<span>DB </span>
<span></span>
<span />
</div>
{/* rows (scrollable when long) */}
<div
data-prov-enter="5"
style={{
marginLeft: "-1.1rem",
marginRight: "-1.1rem",
flex: 1,
minHeight: 0,
overflowY: "auto",
borderBottom: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
}}
>
{isLoading && (
<div
style={{
padding: "2.2rem",
textAlign: "center",
color: "var(--v5-text-sec)",
fontSize: "0.76rem",
fontWeight: 500,
}}
>
...
</div>
)}
{!isLoading &&
paged.map((r) => (
<CompanyAccordionRow
key={r.company_code}
r={r}
open={r.company_code === openKey}
onToggle={() => setOpenKey(r.company_code === openKey ? null : r.company_code)}
/>
))}
{!isLoading && filtered.length === 0 && (
<div
style={{
padding: "2.2rem",
textAlign: "center",
color: "var(--v5-text-sec)",
fontSize: "0.76rem",
fontWeight: 500,
}}
>
.
</div>
)}
</div>
{/* footer + pagination */}
<div
style={{
marginTop: "0.65rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.66rem",
color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)",
flexShrink: 0,
fontWeight: 500,
}}
>
<span>
{filtered.length === 0 ? 0 : pageStart + 1}-{pageStart + paged.length} / {filtered.length} rows
{filtered.length !== rows.length && (
<span style={{ opacity: 0.6 }}> · {rows.length}</span>
)}
</span>
<Pagination page={safePage} totalPages={totalPages} onChange={setPage} />
<span>
last sync ·{" "}
{new Date(dataUpdatedAt).toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
{wizardOpen && <Wizard onClose={() => setWizardOpen(false)} />}
</div>
);
}
function HeaderBtn({
children,
icon,
variant = "secondary",
onClick,
soon,
}: {
children: React.ReactNode;
icon?: React.ReactNode;
variant?: "primary" | "secondary";
onClick?: () => void;
soon?: boolean;
}) {
const isPrimary = variant === "primary";
return (
<button
onClick={soon ? undefined : onClick}
disabled={soon}
title={soon ? "준비 중인 기능입니다" : undefined}
className={soon ? undefined : `prov-hbtn prov-hbtn-${isPrimary ? "primary" : "secondary"}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
height: 30,
padding: "0 0.75rem",
borderRadius: 6,
fontSize: "0.72rem",
fontWeight: 600,
border: `1px solid ${isPrimary ? "var(--v5-primary)" : "var(--v5-border)"}`,
background: isPrimary ? "var(--v5-primary)" : "var(--v5-surface-solid)",
color: isPrimary ? "#fff" : "var(--v5-text)",
boxShadow: isPrimary && !soon ? "0 0 10px rgba(var(--v5-primary-rgb), 0.14)" : "none",
cursor: soon ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
fontFamily: "inherit",
opacity: soon ? 0.55 : 1,
}}
>
{icon}
{children}
{soon && (
<span
style={{
fontSize: "0.58rem",
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 Pagination({
page,
totalPages,
onChange,
}: {
page: number;
totalPages: number;
onChange: (p: number) => void;
}) {
if (totalPages <= 1) return <span />;
const windowSize = 5;
let start = Math.max(1, page - Math.floor(windowSize / 2));
let end = Math.min(totalPages, start + windowSize - 1);
start = Math.max(1, end - windowSize + 1);
const pages: number[] = [];
for (let i = start; i <= end; i++) pages.push(i);
const btn = (key: string, label: React.ReactNode, p: number, disabled: boolean, active = false): React.ReactNode => (
<button
key={key}
onClick={() => !disabled && onChange(p)}
disabled={disabled}
className={`prov-pagebtn ${active ? "prov-pagebtn-active" : ""}`}
style={{
minWidth: 24,
height: 24,
padding: "0 7px",
border: `1px solid ${active ? "var(--v5-primary)" : "var(--v5-border)"}`,
background: active ? "var(--v5-primary)" : "var(--v5-surface-solid)",
color: active ? "#fff" : disabled ? "var(--v5-text-muted)" : "var(--v5-text)",
fontSize: "0.68rem",
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 4,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.45 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
{label}
</button>
);
return (
<div style={{ display: "inline-flex", alignItems: "center", gap: 3 }}>
{btn("prev", <ChevronLeft size={11} strokeWidth={1.75} />, page - 1, page <= 1)}
{start > 1 && btn("p-1", 1, 1, false, page === 1)}
{start > 2 && <span key="lead-dots" style={{ padding: "0 2px", color: "var(--v5-text-muted)" }}></span>}
{pages.map((p) => btn(`p-${p}`, p, p, false, p === page))}
{end < totalPages - 1 && <span key="tail-dots" style={{ padding: "0 2px", color: "var(--v5-text-muted)" }}></span>}
{end < totalPages && btn(`p-${totalPages}`, totalPages, totalPages, false, page === totalPages)}
{btn("next", <ChevronRight size={11} strokeWidth={1.75} />, page + 1, page >= totalPages)}
</div>
);
}
const selectStyle: React.CSSProperties = {
padding: "0.4rem 0.5rem",
background: "var(--v5-surface-hover)",
border: "1px solid var(--v5-border)",
borderRadius: 0,
fontSize: "0.72rem",
color: "var(--v5-text)",
fontFamily: "inherit",
fontWeight: 500,
};