68f85f3736
- 첫 로그인 비번 강제 변경 (RUN_082): FORCE_PASSWORD_CHANGE 컬럼, ForcePasswordChangeGuardFilter, /auth/change-password API + 페이지 - 테넌트 일관성 가드: TenantConsistencyGuardFilter 로 JWT.company_code ↔ 서브도메인 company_code 대조, CompanyResolver 가 (db_name, company_code) 동시 반환 - 회사 관리 확장 (RUN_083 audit log, RUN_084 lifecycle 컬럼): CompanyAdmin/Members/Templates/Lifecycle/AuditLog 서비스 + CompanyMgmtController + SuperAdminGuard - 회사 관리 UI: CompanyAccordionRow 탭화 + 모달 4종 (AdminInfo/Deactivate/Delete/RecopyTemplates) + AuditLogDrawer + csvExport - 프로비저닝 마법사: force_password_change 토글 반영 - 프론트 인증: storage 이벤트 멀티탭 동기화, 403 errorCode (PASSWORD_CHANGE_REQUIRED / CROSS_TENANT_REJECTED / TENANT_NOT_RESOLVED) 전역 리다이렉트 - 기타: StartupSchemaMigrator, OS별 도커 기동 스크립트, CLAUDE.md 트래킹 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
6.6 KiB
TypeScript
189 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { TrendingUp } from "lucide-react";
|
|
|
|
/**
|
|
* 상단 KPI strip — 4 카드 (전체 회사 / 활성률 / 총 사용자 / DB 사용량).
|
|
* 모든 카드가 같은 grid row 구조 공유해서 시각적 일관성 유지.
|
|
*/
|
|
export default function CompanyStatsStrip({ rows }: { rows: Record<string, any>[] }) {
|
|
const total = rows.length;
|
|
const active = rows.filter((r) => r.db_status === "active").length;
|
|
const inact = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
|
|
const provis = rows.filter((r) => r.db_status === "provisioning").length;
|
|
|
|
const users = rows.reduce((s, r) => s + (Number(r.users) || 0), 0);
|
|
const active30 = rows.reduce((s, r) => s + (Number(r.active30) || 0), 0);
|
|
const pctActive = total > 0 ? Math.round((active / total) * 100) : 0;
|
|
const pctEngaged = users > 0 ? Math.round((active30 / users) * 100) : 0;
|
|
|
|
const dbBytes = rows.reduce((s, r) => s + (Number(r.db_size_bytes) || 0), 0);
|
|
const dbGB = dbBytes / (1024 * 1024 * 1024);
|
|
const dbQuotaGB = 20; // 기본 quota (추후 합계로 변경 가능)
|
|
const pctDB = Math.round((dbGB / dbQuotaGB) * 100);
|
|
|
|
const cardStyle: React.CSSProperties = {
|
|
padding: "0.85rem 0.95rem",
|
|
display: "grid",
|
|
gridTemplateRows: "18px 34px 6px 18px",
|
|
rowGap: 9,
|
|
background: "var(--v5-surface-solid)",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 10,
|
|
};
|
|
const label: React.CSSProperties = {
|
|
fontSize: "0.75rem",
|
|
color: "var(--v5-text-sec)",
|
|
fontWeight: 700,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
letterSpacing: "-0.005em",
|
|
};
|
|
const bigRow: React.CSSProperties = { display: "flex", alignItems: "baseline", gap: 6 };
|
|
const bigNum: React.CSSProperties = {
|
|
fontSize: "1.85rem",
|
|
fontWeight: 800,
|
|
color: "var(--v5-text)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontVariantNumeric: "tabular-nums",
|
|
letterSpacing: "-0.03em",
|
|
lineHeight: 1,
|
|
};
|
|
const unit: React.CSSProperties = { fontSize: "0.75rem", color: "var(--v5-text-sec)", fontWeight: 500 };
|
|
const bar: React.CSSProperties = {
|
|
height: 4,
|
|
borderRadius: 2,
|
|
overflow: "hidden",
|
|
background: "var(--v5-border)",
|
|
alignSelf: "center",
|
|
};
|
|
const sub: React.CSSProperties = {
|
|
fontSize: "0.72rem",
|
|
color: "var(--v5-text-sec)",
|
|
fontWeight: 500,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(4, 1fr)",
|
|
gap: "0.55rem",
|
|
marginBottom: "0.8rem",
|
|
}}
|
|
>
|
|
{/* 1 · 전체 회사 */}
|
|
<div style={cardStyle}>
|
|
<div style={label}>전체 테넌트 회사</div>
|
|
<div style={bigRow}>
|
|
<span style={bigNum}>{total}</span>
|
|
<span style={unit}>개 회사</span>
|
|
</div>
|
|
<div style={{ ...bar, display: "flex" }}>
|
|
<div style={{ flex: active || 0.0001, background: "rgb(var(--v5-green-rgb))" }} />
|
|
<div style={{ flex: provis || 0.0001, background: "var(--v5-primary)" }} />
|
|
<div style={{ flex: inact || 0.0001, background: "var(--v5-text-muted)", opacity: 0.35 }} />
|
|
</div>
|
|
<div style={sub}>
|
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
|
<span style={{ width: 5, height: 5, borderRadius: "50%", background: "rgb(var(--v5-green-rgb))" }} />
|
|
활성 <b style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)" }}>{active}</b>
|
|
</span>
|
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
|
<span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--v5-text-muted)", opacity: 0.5 }} />
|
|
비활성 <b style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)" }}>{inact}</b>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2 · 활성률 */}
|
|
<div style={cardStyle}>
|
|
<div style={label}>활성률</div>
|
|
<div style={bigRow}>
|
|
<span style={bigNum}>{pctActive}</span>
|
|
<span style={{ ...unit, fontSize: "0.9rem", fontWeight: 600 }}>%</span>
|
|
<span
|
|
style={{
|
|
fontSize: "0.68rem",
|
|
color: "rgb(var(--v5-green-rgb))",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
marginLeft: "auto",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
}}
|
|
>
|
|
<TrendingUp size={11} strokeWidth={1.75} />
|
|
기준 30일
|
|
</span>
|
|
</div>
|
|
<div style={bar}>
|
|
<div style={{ width: `${pctActive}%`, height: "100%", background: "rgb(var(--v5-green-rgb))" }} />
|
|
</div>
|
|
<div style={sub}>
|
|
활성 <b style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)" }}>{active}</b> / 전체 {total}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 3 · 총 사용자 */}
|
|
<div style={cardStyle}>
|
|
<div style={label}>총 사용자</div>
|
|
<div style={bigRow}>
|
|
<span style={bigNum}>{users}</span>
|
|
<span style={unit}>명</span>
|
|
<span
|
|
style={{
|
|
fontSize: "0.68rem",
|
|
color: "var(--v5-text-muted)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
marginLeft: "auto",
|
|
}}
|
|
>
|
|
30일 {active30}
|
|
</span>
|
|
</div>
|
|
<div style={bar}>
|
|
<div style={{ width: `${pctEngaged}%`, height: "100%", background: "var(--v5-primary)" }} />
|
|
</div>
|
|
<div style={sub}>
|
|
참여율 <b style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)" }}>{pctEngaged}%</b>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 4 · DB 사용량 */}
|
|
<div style={cardStyle}>
|
|
<div style={label}>총 DB 사용량</div>
|
|
<div style={bigRow}>
|
|
<span style={bigNum}>{dbGB.toFixed(1)}</span>
|
|
<span style={unit}>GB</span>
|
|
<span
|
|
style={{
|
|
fontSize: "0.68rem",
|
|
color: "var(--v5-text-muted)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
marginLeft: "auto",
|
|
}}
|
|
>
|
|
/ {dbQuotaGB} GB
|
|
</span>
|
|
</div>
|
|
<div style={bar}>
|
|
<div
|
|
style={{
|
|
width: `${Math.min(pctDB, 100)}%`,
|
|
height: "100%",
|
|
background: pctDB > 70 ? "var(--v5-amber)" : "var(--v5-primary)",
|
|
}}
|
|
/>
|
|
</div>
|
|
<div style={sub}>
|
|
사용률 <b style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)" }}>{pctDB}%</b> · {total}개 회사 합계
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|