Files
invyone/frontend/components/admin/provisioning/CompanyStatsStrip.tsx
T
gbpark 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>
2026-04-25 00:36:05 +09:00

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>
);
}