# 네이버 클라우드 콘솔 UI 패턴 참조

> GUARDiA Manager UI는 네이버 클라우드 콘솔(console.ncloud.com)의 디자인 패턴을 참조한다.
> 아래 코드 스니펫은 해당 패턴을 GUARDiA Manager에 맞게 재구현한 것이다.

---

## 1. DataTable 컴포넌트 (NCloud 리소스 목록)

```tsx
// components/common/DataTable.tsx
import { useState } from 'react';
import styles from './DataTable.module.css';

interface Column<T> {
  key: keyof T | string;
  header: string;
  width?: string;
  render?: (row: T) => React.ReactNode;
  sortable?: boolean;
}

interface DataTableProps<T extends { id: string | number }> {
  columns: Column<T>[];
  data: T[];
  onRowClick?: (row: T) => void;
  actions?: React.ReactNode;          // 상단 액션 버튼
  loading?: boolean;
  emptyMessage?: string;
  selectable?: boolean;
  onSelectionChange?: (selected: T[]) => void;
}

export function DataTable<T extends { id: string | number }>({
  columns, data, onRowClick, actions, loading, emptyMessage,
  selectable = false, onSelectionChange,
}: DataTableProps<T>) {
  const [selected, setSelected] = useState<Set<string | number>>(new Set());
  const [sortKey, setSortKey] = useState<string | null>(null);
  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');

  const toggleAll = () => {
    const next = selected.size === data.length
      ? new Set<string | number>()
      : new Set(data.map(r => r.id));
    setSelected(next);
    onSelectionChange?.(data.filter(r => next.has(r.id)));
  };

  const toggleRow = (id: string | number) => {
    const next = new Set(selected);
    next.has(id) ? next.delete(id) : next.add(id);
    setSelected(next);
    onSelectionChange?.(data.filter(r => next.has(r.id)));
  };

  return (
    <div className={styles.wrapper}>
      {/* 상단 액션 영역 */}
      {actions && (
        <div className={styles.toolbar}>
          {selected.size > 0 && (
            <span className={styles.selectionCount}>{selected.size}개 선택됨</span>
          )}
          <div className={styles.actions}>{actions}</div>
        </div>
      )}
      <table className={styles.table}>
        <thead>
          <tr>
            {selectable && (
              <th className={styles.checkboxCol}>
                <input type="checkbox"
                  checked={selected.size === data.length && data.length > 0}
                  onChange={toggleAll} />
              </th>
            )}
            {columns.map(col => (
              <th key={String(col.key)}
                style={{ width: col.width }}
                className={col.sortable ? styles.sortable : ''}
                onClick={() => col.sortable && (setSortKey(String(col.key)),
                  setSortDir(d => d === 'asc' ? 'desc' : 'asc'))}>
                {col.header}
                {sortKey === String(col.key) && (
                  <span>{sortDir === 'asc' ? ' ↑' : ' ↓'}</span>
                )}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {loading ? (
            <tr><td colSpan={columns.length + 1}
              className={styles.loading}>로딩 중...</td></tr>
          ) : data.length === 0 ? (
            <tr><td colSpan={columns.length + 1}
              className={styles.empty}>{emptyMessage ?? '데이터가 없습니다.'}</td></tr>
          ) : data.map(row => (
            <tr key={row.id}
              className={`${styles.row} ${onRowClick ? styles.clickable : ''}`}
              onClick={() => onRowClick?.(row)}>
              {selectable && (
                <td className={styles.checkboxCol} onClick={e => e.stopPropagation()}>
                  <input type="checkbox"
                    checked={selected.has(row.id)}
                    onChange={() => toggleRow(row.id)} />
                </td>
              )}
              {columns.map(col => (
                <td key={String(col.key)}>
                  {col.render ? col.render(row) : String((row as any)[col.key] ?? '')}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
```

---

## 2. ResourceCard 컴포넌트 (서버/서비스 카드)

```tsx
// components/common/ResourceCard.tsx
import { StatusBadge } from './StatusBadge';

interface ResourceCardProps {
  name: string;
  type: string;          // '서버' | 'DB' | 'WAS' | 'API'
  status: 'running' | 'stopped' | 'error' | 'pending';
  spec?: string;         // '2vCPU / 4GB' 등
  ip?: string;
  onClick?: () => void;
}

export function ResourceCard({ name, type, status, spec, ip, onClick }: ResourceCardProps) {
  return (
    <div className="resource-card" onClick={onClick} style={{
      background: '#fff',
      border: '1px solid var(--border)',
      borderRadius: 8,
      padding: '16px',
      cursor: onClick ? 'pointer' : 'default',
      transition: 'box-shadow 0.15s',
    }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
        <span style={{ fontSize: 12, color: 'var(--text-muted)',
          background: 'var(--brand-light)', padding: '2px 8px', borderRadius: 4 }}>
          {type}
        </span>
        <StatusBadge status={status} />
      </div>
      <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>{name}</div>
      {spec && <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{spec}</div>}
      {ip && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{ip}</div>}
    </div>
  );
}
```

---

## 3. SlidePanel 컴포넌트 (NCloud 상세 정보 패널)

NCloud 콘솔에서 리소스 클릭 시 우측에서 슬라이드하는 상세 패널.

```tsx
// components/common/SlidePanel.tsx
import { useEffect } from 'react';

interface SlidePanelProps {
  open: boolean;
  onClose: () => void;
  title: string;
  width?: number;       // 기본 480px
  children: React.ReactNode;
  actions?: React.ReactNode;
}

export function SlidePanel({ open, onClose, title, width = 480, children, actions }: SlidePanelProps) {
  useEffect(() => {
    const handler = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, [onClose]);

  return (
    <>
      {/* 오버레이 */}
      {open && (
        <div onClick={onClose} style={{
          position: 'fixed', inset: 0,
          background: 'rgba(0,0,0,0.3)', zIndex: 200
        }} />
      )}
      {/* 패널 */}
      <div style={{
        position: 'fixed', top: 0, right: 0, bottom: 0,
        width, background: '#fff', zIndex: 201,
        transform: open ? 'translateX(0)' : `translateX(${width}px)`,
        transition: 'transform 0.25s ease',
        display: 'flex', flexDirection: 'column',
        boxShadow: '-4px 0 24px rgba(0,0,0,0.15)',
      }}>
        {/* 헤더 */}
        <div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border)',
          display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
          <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{title}</h3>
          <button onClick={onClose} style={{ background: 'none', border: 'none',
            cursor: 'pointer', fontSize: 18, color: 'var(--text-muted)' }}>✕</button>
        </div>
        {/* 콘텐츠 */}
        <div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>{children}</div>
        {/* 푸터 액션 */}
        {actions && (
          <div style={{ padding: '16px 24px', borderTop: '1px solid var(--border)',
            display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            {actions}
          </div>
        )}
      </div>
    </>
  );
}
```

---

## 4. StatCard 컴포넌트 (대시보드 통계 카드)

```tsx
// components/common/StatCard.tsx
interface StatCardProps {
  title: string;
  value: string | number;
  sub?: string;
  trend?: { value: number; positive: boolean };
  icon?: string;
  color?: string;        // 아이콘 배경색
  onClick?: () => void;
}

export function StatCard({ title, value, sub, trend, icon, color = 'var(--brand-light)', onClick }: StatCardProps) {
  return (
    <div onClick={onClick} style={{
      background: '#fff', border: '1px solid var(--border)',
      borderRadius: 10, padding: '20px',
      cursor: onClick ? 'pointer' : 'default',
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
        {icon && (
          <div style={{ width: 44, height: 44, borderRadius: 10,
            background: color, display: 'flex', alignItems: 'center',
            justifyContent: 'center', fontSize: 20, flexShrink: 0 }}>
            {icon}
          </div>
        )}
        <div>
          <div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary)' }}>
            {value}
          </div>
          <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{title}</div>
          {sub && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{sub}</div>}
        </div>
      </div>
      {trend && (
        <div style={{ marginTop: 10, fontSize: 11,
          color: trend.positive ? 'var(--status-running)' : 'var(--status-error)' }}>
          {trend.positive ? '▲' : '▼'} {Math.abs(trend.value)}% 전주 대비
        </div>
      )}
    </div>
  );
}
```

---

## 5. 사이드바 네비게이션 (NCloud 서비스 트리 스타일)

```tsx
const MENU = [
  { label: '대시보드', icon: '📊', path: '/' },
  {
    label: '인프라 관리', icon: '🖥️',
    children: [
      { label: '서버 목록', path: '/servers' },
      { label: 'CMDB 현황', path: '/cmdb' },
      { label: 'SSH 자격증명', path: '/credentials' },
    ]
  },
  {
    label: '배포/CI-CD', icon: '🚀',
    children: [
      { label: '배포 이력', path: '/deployments' },
      { label: '저장소 목록', path: '/repos' },
      { label: '서비스 상태', path: '/services' },
    ]
  },
  {
    label: '사용자/테넌트', icon: '👥',
    children: [
      { label: '사용자 관리', path: '/users' },
      { label: '기관 관리', path: '/institutions' },
      { label: '역할 설정', path: '/roles' },
    ]
  },
  {
    label: '보안', icon: '🔒',
    children: [
      { label: 'API Key 관리', path: '/api-keys' },
      { label: '감사 로그', path: '/audit' },
      { label: '취약점 현황', path: '/vulns' },
    ]
  },
  {
    label: 'AI/LLM', icon: '🤖',
    children: [
      { label: 'Ollama 모델', path: '/llm' },
      { label: 'AI 에이전트', path: '/agents' },
    ]
  },
  {
    label: '시스템 설정', icon: '⚙️',
    children: [
      { label: '환경변수', path: '/config/env' },
      { label: 'Nginx 설정', path: '/config/nginx' },
      { label: '알림 설정', path: '/config/notify' },
    ]
  },
];
```
