// 커머스 리뉴얼 프로젝트 - TechBridge 샘플 데이터

const PROJECT = {
  code: "",
  name: "프로젝트",
  phase: "",
  start: "",
  end: "",
};

const MEMBERS = { dev: [], aa: [], dba: [], ca: [], pm: [] };

const allMembers = [...MEMBERS.dev, ...MEMBERS.aa, ...MEMBERS.dba, ...MEMBERS.ca];

// DB 에서 로드된 실제 사용자 매핑 — App 에서 refreshUsers 시 동기화
const __membersFromDb = new Map();
function syncMembersFromDb(users) {
  __membersFromDb.clear();
  for (const u of (users || [])) {
    __membersFromDb.set(u.id, {
      id: u.id,
      name: u.name,
      email: u.email,
      role: u.role,
      company: u.company || "-",
      avatar: u.avatar || (u.name?.[0] || "?"),
    });
  }
}
const findMember = (id) => {
  if (!id) return { name: "-", avatar: "?", role: "-", company: "-" };
  if (__membersFromDb.has(id)) return __membersFromDb.get(id);
  return allMembers.find(m => m.id === id) || { name: id, avatar: "?", role: "-", company: "-" };
};

// 요청 구분 / 세부 유형
const CATEGORIES = {
  DEV: {
    label: "DEV",
    fullLabel: "Developer",
    color: "indigo",
    subtypes: ["버그 수정", "기능 개발/지원", "코드 리뷰", "기술 문의", "라이브러리/도구"],
  },
  AA: {
    label: "AA",
    fullLabel: "Application Architect",
    color: "violet",
    subtypes: ["공통 모듈/프레임워크", "아키텍처 검토/가이드", "표준/컨벤션", "라이브러리 도입"],
  },
  DBA: {
    label: "DBA",
    fullLabel: "Database Administrator",
    color: "teal",
    subtypes: ["테이블 생성/변경", "쿼리 튜닝/인덱스", "권한/계정", "데이터 이관", "백업/복구"],
  },
  CA: {
    label: "CA",
    fullLabel: "Cloud Architect",
    color: "orange",
    subtypes: ["서버/인프라 신청", "배포/네트워크/방화벽", "모니터링/로그", "도메인/인증서"],
  },
};

const STATUSES = [
  { key: "requested", label: "요청", desc: "신규 등록", tone: "slate" },
  { key: "progress", label: "진행중", desc: "작업 수행", tone: "blue" },
  { key: "hold", label: "보류", desc: "이슈/대기", tone: "zinc" },
  { key: "done", label: "완료", desc: "처리 완료", tone: "green" },
  { key: "rejected", label: "반려", desc: "담당자 반려", tone: "rose" },
  { key: "cancelled", label: "취소", desc: "요청자 철회", tone: "zinc" },
];
// 종결(터미널) 상태 — 지연·긴급·진행중 여부 계산에서 제외하는 대상
const TERMINAL_STATUSES = new Set(["done", "rejected", "cancelled"]);
const isTerminalStatus = (s) => TERMINAL_STATUSES.has(s);

const PRIORITIES = [
  { key: "P0", label: "긴급", tone: "rose" },
  { key: "P1", label: "높음", tone: "orange" },
  { key: "P2", label: "보통", tone: "slate" },
  { key: "P3", label: "낮음", tone: "zinc" },
];

// 요청 대장 데이터
const REQUESTS = [];

// 일자 관련 유틸
const todayISO = new Date().toISOString().slice(0, 10);
const toDate = (s) => new Date(s + "T00:00:00");
const daysDiff = (a, b) => Math.round((toDate(b) - toDate(a)) / 86400000);

// 2026-04-23 (목) 10:09:13 형태로 포맷 (클라이언트 로컬 타임존)
// 비밀번호 정책 — 서버 validatePassword 와 동일 규칙
const PASSWORD_RULE_TEXT = "8자 이상, 영문 대/소문자·숫자·특수문자 중 3종류 이상, 동일 문자 3회 연속 불가";
function validatePasswordClient(password) {
  if (typeof password !== "string") return "비밀번호 형식이 올바르지 않습니다";
  if (password.length < 8) return "비밀번호는 8자 이상이어야 합니다";
  if (password.length > 128) return "비밀번호는 128자 이하여야 합니다";
  let classes = 0;
  if (/[a-z]/.test(password)) classes++;
  if (/[A-Z]/.test(password)) classes++;
  if (/\d/.test(password)) classes++;
  if (/[^A-Za-z0-9]/.test(password)) classes++;
  if (classes < 3) return "영문 대/소문자, 숫자, 특수문자 중 3종류 이상을 포함해야 합니다";
  if (/(.)\1\1/.test(password)) return "동일 문자를 3번 이상 연속으로 쓸 수 없습니다";
  return null;
}

const DOW_KR = ["일", "월", "화", "수", "목", "금", "토"];
function formatDateTimeKR(at) {
  if (!at) return "";
  const d = new Date(at);
  if (isNaN(d)) return String(at);
  const y = d.getFullYear();
  const M = String(d.getMonth() + 1).padStart(2, "0");
  const D = String(d.getDate()).padStart(2, "0");
  const hh = String(d.getHours()).padStart(2, "0");
  const mm = String(d.getMinutes()).padStart(2, "0");
  const ss = String(d.getSeconds()).padStart(2, "0");
  return `${y}-${M}-${D} (${DOW_KR[d.getDay()]}) ${hh}:${mm}:${ss}`;
}

Object.assign(window, { PROJECT, MEMBERS, CATEGORIES, STATUSES, PRIORITIES, REQUESTS, findMember, todayISO, toDate, daysDiff, allMembers });

// 공통 UI 컴포넌트 - 배지, 아바타, 칩, 아이콘

const { useState, useEffect, useRef, useMemo, createContext, useContext } = React;

function useClickOutside(ref, onOutside) {
  useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onOutside(); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, []);
}

/* ============ Icons (line, 16px) ============ */
const Icon = {
  dashboard: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><rect x="2.5" y="2.5" width="6" height="8" rx="1" /><rect x="11.5" y="2.5" width="6" height="4" rx="1" /><rect x="11.5" y="9.5" width="6" height="8" rx="1" /><rect x="2.5" y="13.5" width="6" height="4" rx="1" /></svg>,
  list: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="M3 5h14M3 10h14M3 15h14" /></svg>,
  kanban: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><rect x="2.5" y="2.5" width="4" height="15" rx="1" /><rect x="8" y="2.5" width="4" height="10" rx="1" /><rect x="13.5" y="2.5" width="4" height="7" rx="1" /></svg>,
  timeline: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="M2.5 5h8M6 10h10M4 15h8" /><circle cx="10.5" cy="5" r="1.3" fill="currentColor" stroke="none" /><circle cx="16" cy="10" r="1.3" fill="currentColor" stroke="none" /><circle cx="12" cy="15" r="1.3" fill="currentColor" stroke="none" /></svg>,
  plus: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" {...p}><path d="M10 4v12M4 10h12" /></svg>,
  search: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><circle cx="9" cy="9" r="5.5" /><path d="m13 13 4 4" /></svg>,
  filter: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="M3 5h14l-5 6v5l-4-2v-3L3 5z" /></svg>,
  sort: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="M6 4v12M3 13l3 3 3-3M14 16V4M11 7l3-3 3 3" /></svg>,
  close: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" {...p}><path d="M5 5l10 10M15 5L5 15" /></svg>,
  chevron: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="m7 5 5 5-5 5" /></svg>,
  clock: (p) => <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><circle cx="10" cy="10" r="7" /><path d="M10 6v4l3 2" /></svg>,
  comment: (p) => <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="M3 5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H9l-4 3v-3H5a2 2 0 0 1-2-2V5z" /></svg>,
  link: (p) => <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="M9 11a3 3 0 0 0 4 0l3-3a3 3 0 0 0-4-4l-1 1M11 9a3 3 0 0 0-4 0l-3 3a3 3 0 0 0 4 4l1-1" /></svg>,
  bell: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="M5 8a5 5 0 0 1 10 0v4l1.5 2h-13L5 12V8z" /><path d="M8.5 17a1.5 1.5 0 0 0 3 0" /></svg>,
  arrow: (p) => <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="M4 10h12M12 6l4 4-4 4" /></svg>,
  check: (p) => <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" {...p}><path d="m4 10 4 4 8-9" /></svg>,
  paperclip: (p) => <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="m15 8-6 6a3 3 0 0 1-4-4l7-7a2 2 0 0 1 3 3l-7 7" /></svg>,
  eye: (p) => <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="M1.5 10S4.5 4 10 4s8.5 6 8.5 6-3 6-8.5 6S1.5 10 1.5 10z" /><circle cx="10" cy="10" r="2.5" /></svg>,
  warn: (p) => <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><path d="M10 3l8 14H2L10 3z" /><path d="M10 8v4M10 15v.01" /></svg>,
  menu: (p) => <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><circle cx="5" cy="10" r="1" fill="currentColor" /><circle cx="10" cy="10" r="1" fill="currentColor" /><circle cx="15" cy="10" r="1" fill="currentColor" /></svg>,
  calendar: (p) => <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><rect x="2.5" y="4" width="15" height="13" rx="1" /><path d="M2.5 8h15M7 2.5v3M13 2.5v3" /></svg>,
  users: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><circle cx="7.5" cy="7" r="2.8" /><path d="M2 16c0-2.6 2.5-4.5 5.5-4.5s5.5 1.9 5.5 4.5" /><circle cx="14.5" cy="8" r="2.2" /><path d="M13 14.5c.6-1.7 2.2-2.8 4-2.8 1.1 0 2 .4 2.5 1" /></svg>,
  building: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><rect x="4" y="3" width="12" height="14" rx="1" /><path d="M7 7h2M11 7h2M7 10h2M11 10h2M7 13h2M11 13h2" /></svg>,
  cog: (p) => <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}><circle cx="10" cy="10" r="2.6" /><path d="M10 2.5v2.3M10 15.2v2.3M17.5 10h-2.3M4.8 10H2.5M15.3 4.7l-1.6 1.6M6.3 13.7l-1.6 1.6M15.3 15.3l-1.6-1.6M6.3 6.3L4.7 4.7" /></svg>,
};

// 요청 이벤트 한 건을 사람이 읽기 쉬운 형태로 서술
function describeRequestEvent(ev) {
  const actor = ev.byName || ev.by || "사용자";
  const statusLabel = (k) => STATUSES.find(s => s.key === k)?.label || k || "(없음)";
  const prioLabel = (k) => k ? `${k} ${PRIORITIES.find(p => p.key === k)?.label || ""}`.trim() : "(없음)";
  const memberLabel = (id) => id ? (findMember(id).name || id) : "(없음)";
  switch (ev.kind) {
    case "created":
      return { icon: "🟢", text: `${actor}님이 요청을 등록했습니다` };
    case "status":
      return { icon: "🔁", text: `${actor}님이 상태를 ${statusLabel(ev.from)} → ${statusLabel(ev.to)} 로 변경` };
    case "assignee":
      return { icon: "👤", text: `${actor}님이 담당자를 ${memberLabel(ev.from)} → ${memberLabel(ev.to)} 로 변경` };
    case "priority":
      return { icon: "⚡", text: `${actor}님이 우선순위를 ${prioLabel(ev.from)} → ${prioLabel(ev.to)} 로 변경` };
    default:
      return { icon: "·", text: `${actor}님이 '${ev.kind}' 이벤트` };
  }
}

// 브랜드 마크 — 로그인/TopBar/파비콘 공통
// 상태 전환 시 사유 입력 모달 (보류/반려/취소 공통)
function ReasonPromptModal({ statusKey, onConfirm, onCancel }) {
  const [text, setText] = useState("");
  const STATUS_LABEL = { hold: "보류", rejected: "반려", cancelled: "취소" };
  const label = STATUS_LABEL[statusKey] || "";
  const MAX = 500;
  const submit = () => {
    const t = text.trim();
    if (!t) return;
    onConfirm(t);
  };
  return (
    <div style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.4)", display: "grid", placeItems: "center", zIndex: 1000 }} onClick={onCancel}>
      <div onClick={e => e.stopPropagation()} style={{ background: "#fff", borderRadius: 8, padding: "22px 24px", width: 460, boxShadow: "var(--shadow-lg)" }}>
        <div style={{ fontWeight: 600, fontSize: 15, marginBottom: 6 }}>{label} 사유 입력</div>
        <div style={{ color: "var(--ink-3)", fontSize: 12.5, lineHeight: 1.6, marginBottom: 12 }}>
          상태를 <b style={{ color: "var(--ink-1)" }}>{label}</b>(으)로 변경하려면 사유가 필요합니다. 입력한 사유는 코멘트로 기록되어 팀원이 볼 수 있습니다.
        </div>
        <textarea
          className="textarea"
          autoFocus
          value={text}
          onChange={e => setText(e.target.value.slice(0, MAX))}
          onKeyDown={e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) submit(); }}
          placeholder={`${label} 사유를 구체적으로 작성해 주세요 (Cmd/Ctrl + Enter 로 저장)`}
          style={{ minHeight: 110, width: "100%" }}
        />
        <div style={{ display: "flex", alignItems: "center", marginTop: 12, gap: 8 }}>
          <span style={{ color: "var(--ink-4)", fontSize: 11, fontFamily: "var(--mono)" }}>{text.length}/{MAX}</span>
          <div style={{ marginLeft: "auto", display: "flex", gap: 8 }}>
            <button className="btn btn-ghost" onClick={onCancel}>취소</button>
            <button className="btn btn-primary" onClick={submit} disabled={!text.trim()}>확인</button>
          </div>
        </div>
      </div>
    </div>
  );
}

function BrandMark() {
  return (
    <svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <defs>
        <linearGradient id="brand-mark-g" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#38bdf8" />
          <stop offset="1" stopColor="#0284c7" />
        </linearGradient>
      </defs>
      <rect width="32" height="32" rx="7" fill="url(#brand-mark-g)" />
      <g fill="none" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
        {/* 왼쪽 셰브런 */}
        <path d="M9.5 11.5 L15 16 L9.5 20.5" />
        {/* 가운데 다리 */}
        <line x1="15" y1="16" x2="17" y2="16" strokeWidth="2.6" />
        {/* 오른쪽 셰브런 (보조) */}
        <path d="M22.5 11.5 L17 16 L22.5 20.5" opacity="0.55" />
      </g>
    </svg>
  );
}

/* ============ Status/Priority/Category Badges ============ */
const toneMap = {
  slate: { bg: "#e4e9f0", fg: "#475569", dot: "#64748b" },
  amber: { bg: "#fef3c7", fg: "#92400e", dot: "#d97706" },
  blue: { bg: "#dbeafe", fg: "#1d4ed8", dot: "#3b82f6" },
  zinc: { bg: "#e4e4e7", fg: "#52525b", dot: "#71717a" },
  green: { bg: "#d1fae5", fg: "#047857", dot: "#10b981" },
  rose: { bg: "#ffe4e6", fg: "#be123c", dot: "#f43f5e" },
  orange: { bg: "#ffedd5", fg: "#c2410c", dot: "#f97316" },
  violet: { bg: "#ede9fe", fg: "#6d28d9", dot: "#8b5cf6" },
  teal: { bg: "#ccfbf1", fg: "#0f766e", dot: "#14b8a6" },
  indigo: { bg: "#e0e7ff", fg: "#3730a3", dot: "#6366f1" },
};

function StatusBadge({ statusKey, dense }) {
  const s = STATUSES.find(x => x.key === statusKey);
  if (!s) return null;
  const c = toneMap[s.tone];
  return (
    <span className="badge" style={{ background: c.bg, color: c.fg, fontSize: dense ? 10.5 : 11 }}>
      <span className="dot" style={{ background: c.dot }} />
      {s.label}
    </span>
  );
}

function PriorityChip({ pKey }) {
  const p = PRIORITIES.find(x => x.key === pKey);
  if (!p) return null;
  const c = toneMap[p.tone];
  return (
    <span className="chip chip-priority" style={{ borderColor: c.dot, color: c.fg }}>
      <b style={{ fontFamily: "var(--mono)", color: c.dot }}>{p.key}</b>
      <span style={{ opacity: 0.8 }}>{p.label}</span>
    </span>
  );
}

function CategoryTag({ catKey, dense }) {
  const cat = CATEGORIES[catKey];
  if (!cat) return null;
  const c = toneMap[cat.color];
  return (
    <span className="cat-tag" style={{ background: c.bg, color: c.fg, fontSize: dense ? 10.5 : 11 }}>
      <span style={{ fontFamily: "var(--mono)", fontWeight: 700 }}>{cat.label}</span>
    </span>
  );
}

function Avatar({ id, size = 22, showTip }) {
  const m = findMember(id);
  const tone = toneMap.slate;
  const iconSize = Math.round(size * 0.6);
  return (
    <span
      className="avatar"
      title={showTip ? `${m.name}${m.role ? ` · ${m.role}` : ""}` : undefined}
      style={{ width: size, height: size, background: tone.bg, color: tone.fg, display: "inline-grid", placeItems: "center" }}
    >
      <svg viewBox="0 0 20 20" width={iconSize} height={iconSize} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        <circle cx="10" cy="7.2" r="3" />
        <path d="M3.8 16.8c0-3.2 2.8-5.2 6.2-5.2s6.2 2 6.2 5.2" />
      </svg>
    </span>
  );
}

function AvatarStack({ ids = [], size = 20, max = 3 }) {
  const shown = ids.slice(0, max);
  const rest = ids.length - shown.length;
  return (
    <span className="av-stack">
      {shown.map(id => <Avatar key={id} id={id} size={size} showTip />)}
      {rest > 0 && <span className="av-more" style={{ width: size, height: size, fontSize: size * 0.42 }}>+{rest}</span>}
    </span>
  );
}

/* ============ Due / Date helpers ============ */
function DueBadge({ due, status }) {
  if (!due) return null;
  const diff = daysDiff(todayISO, due);
  const closed = isTerminalStatus(status);
  let tone = "slate", label = "";
  if (closed) {
    const closedLabel = status === "done" ? "완료" : status === "rejected" ? "반려" : status === "cancelled" ? "취소" : "종료";
    tone = "zinc"; label = closedLabel;
  } else if (diff < 0) {
    tone = "rose"; label = `D+${-diff} 지연`;
  } else if (diff === 0) {
    tone = "orange"; label = "D-Day";
  } else if (diff <= 3) {
    tone = "amber"; label = `D-${diff}`;
  } else {
    tone = "slate"; label = `D-${diff}`;
  }
  const c = toneMap[tone];
  return (
    <span className="due" style={{ color: c.fg, background: c.bg }}>
      <Icon.clock /> {label}
    </span>
  );
}

/* ============ Buttons / Inputs ============ */
function Btn({ kind = "ghost", children, onClick, icon, style, ...rest }) {
  const classes = {
    primary: "btn btn-primary",
    ghost: "btn btn-ghost",
    outline: "btn btn-outline",
    danger: "btn btn-danger",
  };
  return (
    <button className={classes[kind]} onClick={onClick} style={style} {...rest}>
      {icon && <span className="btn-ico">{icon}</span>}
      {children}
    </button>
  );
}

function TextField({ label, hint, error, children }) {
  return (
    <label className="field">
      {label && <span className="field-label">{label}{hint && <em>{hint}</em>}</span>}
      {children}
      {error && <span className="field-err">{error}</span>}
    </label>
  );
}

function Select({ value, onChange, options, style }) {
  return (
    <select className="select" value={value} onChange={e => onChange(e.target.value)} style={style}>
      {options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
    </select>
  );
}

/* ============ Toast ============ */
const ToastCtx = createContext(null);
function ToastProvider({ children }) {
  const [list, setList] = useState([]);
  const push = (msg, tone = "slate") => {
    const id = Math.random().toString(36).slice(2);
    setList(l => [...l, { id, msg, tone }]);
    setTimeout(() => setList(l => l.filter(x => x.id !== id)), 2600);
  };
  return (
    <ToastCtx.Provider value={push}>
      {children}
      <div className="toast-wrap">
        {list.map(t => {
          const c = toneMap[t.tone] || toneMap.slate;
          return <div key={t.id} className="toast" style={{ background: c.bg, color: c.fg, borderColor: c.dot }}>{t.msg}</div>;
        })}
      </div>
    </ToastCtx.Provider>
  );
}
const useToast = () => useContext(ToastCtx);

Object.assign(window, {
  Icon, StatusBadge, PriorityChip, CategoryTag, Avatar, AvatarStack, DueBadge,
  Btn, TextField, Select, toneMap,
  ToastProvider, useToast,
});

// 레이아웃 - TopBar, Sidebar, SubBar, FilterBar

const LayoutHelpers = (() => {
  const VIEWS = [
    { key: "dashboard", label: "대시보드", icon: <Icon.dashboard /> },
    { key: "list", label: "전체 리스트", icon: <Icon.list /> },
    { key: "kanban", label: "칸반 보드", icon: <Icon.kanban /> },
    { key: "timeline", label: "타임라인", icon: <Icon.timeline /> },
  ];
  return { VIEWS };
})();

function NotificationsBell({ requests, pendingUsers, currentUser, onNavigate, onOpenRequest }) {
  const [open, setOpen] = useState(false);
  const wrapRef = useRef(null);
  useClickOutside(wrapRef, () => setOpen(false));
  const userId = currentUser?.id;
  const isAdmin = currentUser?.role === "admin";

  // 읽음 상태는 서버(DB)에 저장 — 기기/브라우저 바뀌어도 유지
  const [readSet, setReadSet] = useState(new Set());
  useEffect(() => {
    if (!userId) return;
    let cancelled = false;
    API.getNotiReads()
      .then(({ keys }) => { if (!cancelled) setReadSet(new Set(keys || [])); })
      .catch(() => { });
    return () => { cancelled = true; };
  }, [userId]);

  // 서버 비동기 저장 실패는 무시 (로컬 state는 이미 낙관적 업데이트됨)
  const persistKeys = (keys) => {
    if (!keys.length) return;
    API.addNotiReads(keys).catch(() => { });
  };
  const markRead = (key) => {
    setReadSet(prev => {
      if (prev.has(key)) return prev;
      const next = new Set(prev); next.add(key);
      persistKeys([key]);
      return next;
    });
  };
  const markAllRead = (keys) => {
    setReadSet(prev => {
      const next = new Set(prev);
      const added = [];
      for (const k of keys) if (!next.has(k)) { next.add(k); added.push(k); }
      if (added.length) persistKeys(added);
      return next;
    });
  };

  const items = useMemo(() => {
    const res = [];
    if (isAdmin && pendingUsers?.length) {
      for (const u of pendingUsers) {
        res.push({
          key: "pending:" + u.id,
          kind: "pending",
          text: `${u.name} 가입 승인 대기`,
          sub: u.email,
          onClick: () => onNavigate("admin"),
        });
      }
    }
    // 최근 30일 이내 이벤트 중 현재 사용자와 관련된 것
    const cutoff = new Date(); cutoff.setUTCDate(cutoff.getUTCDate() - 30);
    const cutoffKey = cutoff.toISOString();
    for (const r of (requests || [])) {
      for (const ev of (r.events || [])) {
        if (!ev.at || ev.at < cutoffKey) continue;
        if (ev.by === userId) continue; // 본인이 만든 이벤트는 알릴 필요 없음
        const text = `${r.id} ${r.title}`;
        if (ev.kind === "assignee" && ev.to === userId) {
          res.push({
            key: `event:${ev.id}`, kind: "assigned",
            text,
            sub: `${ev.byName || ev.by}님이 담당자로 지정 · ${formatDateTimeKR(ev.at)}`,
            onClick: () => onOpenRequest(r.id),
          });
        } else if (ev.kind === "status" && r.requester === userId) {
          const label = (k) => STATUSES.find(s => s.key === k)?.label || k;
          res.push({
            key: `event:${ev.id}`, kind: "statusChanged",
            text,
            sub: `상태: ${label(ev.from)} → ${label(ev.to)} · ${formatDateTimeKR(ev.at)}`,
            onClick: () => onOpenRequest(r.id),
          });
        }
      }
    }

    // 본인에게 할당된 미완료 요청만 — 지연 > 긴급(P0/P1) > 일반 순으로 정렬
    const incoming = (requests || []).filter(r =>
      r.assignee === userId && !isTerminalStatus(r.status)
    );
    const rank = (r) => {
      if (r.dueAt && daysDiff(todayISO, r.dueAt) < 0) return 0;
      if (r.priority === "P0") return 1;
      if (r.priority === "P1") return 2;
      return 3;
    };
    const sorted = [...incoming].sort((a, b) => {
      const ra = rank(a), rb = rank(b);
      if (ra !== rb) return ra - rb;
      return (a.dueAt || "").localeCompare(b.dueAt || "");
    });
    for (const r of sorted) {
      const isOverdue = r.dueAt && daysDiff(todayISO, r.dueAt) < 0;
      const isUrgent = r.priority === "P0" || r.priority === "P1";
      const kind = isOverdue ? "overdue" : isUrgent ? "urgent" : "incoming";
      const sub = isOverdue
        ? `희망일 ${-daysDiff(todayISO, r.dueAt)}일 초과`
        : isUrgent
          ? `${r.priority} · 담당: 나`
          : `${r.priority || ""}${r.dueAt ? ` · ~${r.dueAt}` : ""}`;
      res.push({
        key: `${kind}:${r.id}`, kind,
        text: `${r.id} ${r.title}`, sub,
        onClick: () => onOpenRequest(r.id),
      });
    }
    return res;
  }, [requests, pendingUsers, userId, isAdmin]);

  const totalCount = items.length;
  const unreadItems = items.filter(it => !readSet.has(it.key));
  const unreadCount = unreadItems.length;

  return (
    <div className="dropdown-wrap" ref={wrapRef} style={{ position: "relative" }}>
      <button
        className="icon-btn"
        title={unreadCount ? `읽지 않은 알림 ${unreadCount}건` : totalCount ? `알림 ${totalCount}건 (모두 읽음)` : "알림 없음"}
        onClick={() => setOpen(v => !v)}
      >
        <Icon.bell />
        {unreadCount > 0 && <span className="count">{unreadCount > 99 ? "99+" : unreadCount}</span>}
      </button>
      {open && (
        <div className="popover" style={{ right: 0, left: "auto", top: "calc(100% + 6px)", width: 340, maxHeight: 420, overflow: "auto" }} onClick={e => e.stopPropagation()}>
          <div style={{ padding: "10px 12px", borderBottom: "1px solid var(--line)", fontWeight: 600, fontSize: 13, color: "var(--ink-1)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
            <span>알림{totalCount > 0 && <span style={{ marginLeft: 6, color: "var(--ink-4)", fontSize: 11, fontFamily: "var(--mono)", fontWeight: 500 }}>{unreadCount}/{totalCount}</span>}</span>
            {unreadCount > 0 && (
              <button
                onClick={(e) => { e.stopPropagation(); markAllRead(items.map(it => it.key)); }}
                style={{ background: "none", border: 0, color: "var(--accent)", fontSize: 11.5, cursor: "pointer", padding: 2 }}
              >
                모두 읽음
              </button>
            )}
          </div>
          {totalCount === 0 ? (
            <div style={{ padding: "20px 12px", color: "var(--ink-3)", fontSize: 12.5, textAlign: "center" }}>확인할 알림이 없습니다.</div>
          ) : items.map(it => {
            const read = readSet.has(it.key);
            return (
              <div
                key={it.key}
                className="item"
                onClick={() => { setOpen(false); markRead(it.key); it.onClick(); }}
                style={{ padding: "10px 12px", borderBottom: "1px solid var(--line-2)", cursor: "pointer", display: "flex", alignItems: "flex-start", gap: 8, background: read ? "transparent" : "rgba(79, 70, 229, 0.04)" }}
              >
                <span style={{ fontSize: 14, lineHeight: 1, marginTop: 1, position: "relative" }}>
                  {it.kind === "pending" ? "👤"
                    : it.kind === "overdue" ? "⚠"
                      : it.kind === "urgent" ? "🔥"
                        : it.kind === "assigned" ? "🙋"
                          : it.kind === "statusChanged" ? "🔁"
                            : "📥"}
                  {!read && <span style={{ position: "absolute", top: -1, right: -4, width: 6, height: 6, borderRadius: "50%", background: "var(--accent)" }} />}
                </span>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontSize: 12.5, color: read ? "var(--ink-3)" : "var(--ink-1)", fontWeight: read ? 400 : 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.text}</div>
                  <div style={{ fontSize: 11, color: "var(--ink-4)", marginTop: 2 }}>{it.sub}</div>
                </div>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

function TopBar({ onNew, currentUserId, setCurrentUserId, currentUser, onLogout, project, onChangePassword, requests, pendingUsers, onNavigate, onOpenRequest }) {
  const me = currentUser || findMember(currentUserId);
  const isAdmin = currentUser?.role === "admin";
  const [meOpen, setMeOpen] = useState(false);
  const meRef = useRef(null);
  useClickOutside(meRef, () => setMeOpen(false));
  const roleName = ROLE_LABEL[me.role] || me.role?.toUpperCase() || "";
  const proj = project || PROJECT;
  return (
    <div className="topbar">
      <div style={{ display: "flex", alignItems: "center" }}>
        <div className="brand">
          <div className="logo"><BrandMark /></div>
          <span>TechBridge</span>
        </div>
        <div className="proj">
          <span>{proj.name}</span>
        </div>
      </div>
      <div className="search">
        <Icon.search />
        <input placeholder="요청번호, 제목, 담당자 검색..." />
        <kbd>⌘K</kbd>
      </div>
      <div className="right">
        {!isAdmin && <Btn kind="primary" icon={<Icon.plus />} onClick={onNew}>신규 요청</Btn>}
        <NotificationsBell
          requests={requests}
          pendingUsers={pendingUsers}
          currentUser={currentUser}
          onNavigate={onNavigate}
          onOpenRequest={onOpenRequest}
        />
        <div className="dropdown-wrap" ref={meRef}>
          <div className="me" onClick={() => setMeOpen(v => !v)} style={{ cursor: "pointer" }}>
            <Avatar id={currentUser?.id || currentUserId} size={22} />
            <div>
              <div className="name">{me.name}</div>
              <div className="role">{roleName}</div>
            </div>
            <Icon.chevron style={{ transform: "rotate(90deg)", color: "#8791aa" }} />
          </div>
          {meOpen && (
            <div className="popover" style={{ right: 0, left: "auto", top: "calc(100% + 6px)", minWidth: 220 }}>
              <div style={{ padding: "10px 12px", borderBottom: "1px solid var(--line)" }}>
                <div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink-1)" }}>{me.name}</div>
                <div style={{ fontSize: 11, color: "var(--ink-3)", marginTop: 2, fontFamily: "var(--mono)" }}>{me.email || (me.id + "@company.com")}</div>
                <div style={{ fontSize: 11, color: "var(--ink-3)", marginTop: 2 }}>{roleName}{me.company && " · " + me.company}</div>
              </div>
              <div className="item" onClick={() => { setMeOpen(false); onChangePassword && onChangePassword(); }}>
                <span style={{ width: 20, display: "inline-flex", justifyContent: "center" }}>🔑</span>
                <span>비밀번호 변경</span>
              </div>
              <div className="item" onClick={() => { setMeOpen(false); onLogout(); }} style={{ color: "#dc2626" }}>
                <span style={{ width: 20, display: "inline-flex", justifyContent: "center" }}>⎋</span>
                <span>로그아웃</span>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function Sidebar({ view, setView, category, setCategory, counts, isAdmin, pendingCount }) {
  return (
    <div className="side">
      <div className="side-title">뷰</div>
      {LayoutHelpers.VIEWS.map(v => (
        <div key={v.key} className={"side-item " + (view === v.key ? "active" : "")} onClick={() => setView(v.key)}>
          <span className="ico">{v.icon}</span>
          <span>{v.label}</span>
        </div>
      ))}
      {isAdmin && (
        <>
          <div className="side-divider" />
          <div className="side-title">관리자</div>
          <div className={"side-item " + (view === "admin" ? "active" : "")} onClick={() => setView("admin")}>
            <span className="ico"><Icon.users /></span>
            <span>계정 관리</span>
            {pendingCount > 0 && <span className="count" style={{ background: "#f97316", color: "#fff" }}>{pendingCount}</span>}
          </div>
          <div className={"side-item " + (view === "project" ? "active" : "")} onClick={() => setView("project")}>
            <span className="ico"><Icon.cog /></span>
            <span>프로젝트 설정</span>
          </div>
          <div className={"side-item " + (view === "companies" ? "active" : "")} onClick={() => setView("companies")}>
            <span className="ico"><Icon.building /></span>
            <span>회사 관리</span>
          </div>
        </>
      )}

      <div className="side-divider" />

      <div className="side-title">요청 구분</div>
      <div className={"side-item " + (category === "ALL" ? "active" : "")} onClick={() => setCategory("ALL")}>
        <span className="ico">∑</span>
        <span>전체</span>
        <span className="count">{counts.ALL}</span>
      </div>
      {Object.entries(CATEGORIES).map(([k, cat]) => {
        const c = toneMap[cat.color];
        return (
          <div key={k} className="cat-item" onClick={() => setCategory(k)} style={{
            paddingLeft: 20,
            background: category === k ? "var(--hover)" : "",
            fontWeight: category === k ? 600 : 400,
            color: category === k ? "var(--ink-1)" : "var(--ink-2)",
          }}>
            <span className="sw" style={{ background: c.dot }} />
            <span><b style={{ fontFamily: "var(--mono)" }}>{cat.label}</b> · {cat.fullLabel.split(" ")[0]}</span>
            <span className="count">{counts[k] || 0}</span>
          </div>
        );
      })}

    </div>
  );
}

function SubBar({ view, setView, title, right }) {
  const curView = LayoutHelpers.VIEWS.find(v => v.key === view);
  const label = curView?.label || (view === "admin" ? "계정 관리" : view === "project" ? "프로젝트 설정" : view === "companies" ? "회사 관리" : "");
  return (
    <div className="subbar">
      <div className="crumb">
        <b>{label}</b>
        {title && <>
          <span className="sep">/</span>
          <span>{title}</span>
        </>}
      </div>
      <div className="grow" />
      {right}
    </div>
  );
}

function FilterBar({ filters, setFilters, currentUserId, resultCount, users }) {
  const update = (k, v) => setFilters({ ...filters, [k]: v });
  const clear = (k) => {
    const n = { ...filters }; delete n[k]; setFilters(n);
  };
  const assigneeOptions = useMemo(() => {
    const roleOrder = { dev: 0, aa: 1, dba: 2, ca: 3 };
    return (users || [])
      .filter(u => u.status === "active" && roleOrder[u.role] !== undefined)
      .sort((a, b) => (roleOrder[a.role] - roleOrder[b.role]) || (a.name || "").localeCompare(b.name || "", "ko"))
      .map(u => ({ value: u.id, label: `${u.name} (${u.role.toUpperCase()})${u.company ? ` · ${u.company}` : ""}` }));
  }, [users]);
  return (
    <div className="filterbar">
      <div className="input-s" style={{ minWidth: 240 }}>
        <Icon.search />
        <input
          placeholder="제목, 요청번호, 담당자..."
          value={filters.q || ""}
          onChange={e => update("q", e.target.value)}
        />
      </div>

      <Dropdown
        label="담당자"
        value={filters.assignee}
        onClear={() => clear("assignee")}
        options={assigneeOptions}
        onSelect={v => update("assignee", v)}
      />

      <Dropdown
        label="우선순위"
        value={filters.priority}
        onClear={() => clear("priority")}
        options={PRIORITIES.map(p => ({ value: p.key, label: `${p.key} · ${p.label}` }))}
        onSelect={v => update("priority", v)}
      />

      <Dropdown
        label="상태"
        value={filters.status}
        onClear={() => clear("status")}
        options={STATUSES.map(s => ({ value: s.key, label: s.label }))}
        onSelect={v => update("status", v)}
      />

      <Dropdown
        label="정렬"
        value={filters.sort || "updated"}
        onClear={null}
        labelAlways
        options={[
          { value: "updated", label: "최근 업데이트순" },
          { value: "due", label: "희망 완료일순" },
          { value: "priority", label: "우선순위순" },
          { value: "created", label: "요청일순" },
        ]}
        onSelect={v => update("sort", v)}
      />

      <div className="divider" />

      <div className={"toggle " + (filters.mine ? "on" : "")} onClick={() => update("mine", !filters.mine)}>
        <div className="sw" />
        <span>내 요청만</span>
      </div>
      <div className={"toggle " + (filters.openOnly ? "on" : "")} onClick={() => update("openOnly", !filters.openOnly)}>
        <div className="sw" />
        <span>진행 중만</span>
      </div>

      <div className="grow" style={{ flex: 1 }} />
      <div style={{ fontSize: 11.5, color: "var(--ink-3)", fontFamily: "var(--mono)" }}>
        {resultCount}건
      </div>
    </div>
  );
}

function Dropdown({ label, value, options, onSelect, onClear, labelAlways }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("click", h);
    return () => document.removeEventListener("click", h);
  }, []);
  const selected = options.find(o => o.value === value);
  return (
    <div className="dropdown-wrap" ref={ref}>
      <div className={"fl " + (value ? "active" : "")} onClick={() => setOpen(v => !v)}>
        <span className="lbl">{label}:</span>
        <span className="val">{selected ? selected.label : (labelAlways ? options[0]?.label : "전체")}</span>
        {value && onClear ? (
          <span className="x" onClick={e => { e.stopPropagation(); onClear(); }}><Icon.close /></span>
        ) : (
          <Icon.chevron style={{ transform: "rotate(90deg)", color: "#94a3b8" }} />
        )}
      </div>
      {open && (
        <div className="popover" style={{ maxHeight: 280, overflow: "auto" }}>
          {options.map(o => (
            <div key={o.value} className={"item " + (o.value === value ? "selected" : "")}
              onClick={() => { onSelect(o.value); setOpen(false); }}>
              <span>{o.label}</span>
              {o.value === value && <span className="check"><Icon.check /></span>}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Object.assign(window, { TopBar, Sidebar, SubBar, FilterBar, LayoutHelpers, Dropdown });

// 뷰: 대시보드, 리스트, 칸반, 타임라인

/* ============ Dashboard ============ */
function DashboardView({ requests, onOpen }) {
  const total = requests.length;
  const byStatus = STATUSES.map(s => ({ ...s, n: requests.filter(r => r.status === s.key).length }));
  const byCat = Object.entries(CATEGORIES).map(([k, c]) => ({
    key: k, cat: c,
    total: requests.filter(r => r.category === k).length,
    done: requests.filter(r => r.category === k && r.status === "done").length,
    progress: requests.filter(r => r.category === k && r.status === "progress").length,
    requested: requests.filter(r => r.category === k && (r.status === "requested" || r.status === "review")).length,
  }));
  const overdue = requests.filter(r => daysDiff(todayISO, r.dueAt) < 0 && !isTerminalStatus(r.status));
  const urgent = requests.filter(r => (r.priority === "P0" || r.priority === "P1") && !isTerminalStatus(r.status))
    .sort((a, b) => a.dueAt.localeCompare(b.dueAt));
  const recent = [...requests].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 6);

  // 최근 14일 requestedAt 기준 일별 등록 건수 집계
  const DAYS = 14;
  const dayKeys = [];
  const countByDay = {};
  for (let i = DAYS - 1; i >= 0; i--) {
    const d = new Date();
    d.setUTCDate(d.getUTCDate() - i);
    const key = d.toISOString().slice(0, 10);
    dayKeys.push(key);
    countByDay[key] = 0;
  }
  for (const r of requests) {
    const key = (r.requestedAt || "").slice(0, 10);
    if (key in countByDay) countByDay[key]++;
  }
  const weekBars = dayKeys.map(k => countByDay[k]);
  const weekMax = Math.max(1, ...weekBars);
  const weekLabels = dayKeys.map((k, i) => {
    const [, m, d] = k.split("-");
    // 첫 번째와 월이 바뀌는 날은 "M/D", 나머지는 "D"
    if (i === 0 || (i > 0 && dayKeys[i - 1].slice(5, 7) !== m)) return `${+m}/${+d}`;
    return String(+d);
  });

  // 최근 30일 완료된 요청 평균 처리 일수 (requestedAt → updatedAt)
  const thirtyAgo = new Date(); thirtyAgo.setUTCDate(thirtyAgo.getUTCDate() - 30);
  const thirtyAgoKey = thirtyAgo.toISOString().slice(0, 10);
  const recentDone = requests.filter(r => r.status === "done" && (r.updatedAt || "").slice(0, 10) >= thirtyAgoKey);
  const avgLead = recentDone.length
    ? (recentDone.reduce((a, r) => a + Math.max(0, daysDiff(r.requestedAt, (r.updatedAt || "").slice(0, 10))), 0) / recentDone.length)
    : null;

  const statusTotal = byStatus.reduce((a, b) => a + b.n, 0);

  return (
    <div className="dash">
      {/* KPI row */}
      <div className="card kpi">
        <span className="lbl">전체 요청</span>
        <div className="n">{total}</div>
        <span className="sub">진행 {requests.filter(r => r.status === "progress").length} · 검토 {requests.filter(r => r.status === "review").length}</span>
      </div>
      <div className="card kpi">
        <span className="lbl">지연</span>
        <div className="n" style={{ color: "#dc2626" }}>{overdue.length}</div>
        <span className="sub">희망일 초과</span>
      </div>
      <div className="card kpi">
        <span className="lbl">평균 처리</span>
        <div className="n">
          {avgLead == null ? "—" : avgLead.toFixed(1)}
          <span style={{ fontSize: 14, color: "var(--ink-3)", fontWeight: 500 }}>{avgLead == null ? "" : "일"}</span>
        </div>
        <span className="sub">최근 30일{avgLead == null ? " · 데이터 없음" : ` · 완료 ${recentDone.length}건`}</span>
      </div>
      <div className="card kpi">
        <span className="lbl">긴급 P0·P1</span>
        <div className="n" style={{ color: "#ea580c" }}>{urgent.length}</div>
        <span className="sub">미완료</span>
      </div>

      {/* Role distribution */}
      <div className="card role-grid">
        <h4>역할별 요청 현황 <em>총 {total}건</em></h4>
        {byCat.map(c => {
          const total = c.total || 1;
          const seg1 = (c.done / total) * 100;
          const seg2 = (c.progress / total) * 100;
          const seg3 = (c.requested / total) * 100;
          return (
            <div key={c.key} className="bar-row">
              <div className="lbl">
                <CategoryTag catKey={c.key} />
              </div>
              <div className="track">
                {seg1 > 0 && <div className="seg" style={{ width: seg1 + "%", background: toneMap.green.dot }}>{c.done}</div>}
                {seg2 > 0 && <div className="seg" style={{ width: seg2 + "%", background: toneMap.blue.dot }}>{c.progress}</div>}
                {seg3 > 0 && <div className="seg" style={{ width: seg3 + "%", background: toneMap.amber.dot }}>{c.requested}</div>}
              </div>
              <div className="n">{c.total}건</div>
            </div>
          );
        })}
        <div style={{ display: "flex", gap: 14, fontSize: 11, color: "var(--ink-3)", marginTop: 10, paddingTop: 10, borderTop: "1px solid var(--line-2)" }}>
          <span><span style={{ display: "inline-block", width: 8, height: 8, background: toneMap.green.dot, borderRadius: 2, marginRight: 5 }} />완료</span>
          <span><span style={{ display: "inline-block", width: 8, height: 8, background: toneMap.blue.dot, borderRadius: 2, marginRight: 5 }} />진행중</span>
          <span><span style={{ display: "inline-block", width: 8, height: 8, background: toneMap.amber.dot, borderRadius: 2, marginRight: 5 }} />요청/검토</span>
        </div>
      </div>

      {/* Status distribution */}
      <div className="card status-grid">
        <h4>상태별 분포 <em>실시간</em></h4>
        {byStatus.filter(s => s.n > 0).map(s => {
          const pct = statusTotal ? (s.n / statusTotal) * 100 : 0;
          return (
            <div key={s.key} className="bar-row">
              <div className="lbl"><StatusBadge statusKey={s.key} /></div>
              <div className="track">
                {pct > 0 && <div className="seg" style={{ width: pct + "%", background: toneMap[s.tone].dot }} />}
              </div>
              <div className="n">{s.n}건</div>
            </div>
          );
        })}
      </div>

      {/* Weekly chart */}
      <div className="card burn">
        <h4>일별 요청 등록 추이 <em>최근 2주</em></h4>
        <div className="mini-bar" style={{ marginTop: 20 }}>
          {weekBars.map((n, i) => (
            <div key={i} className="b" data-n={n} style={{ height: (n / weekMax) * 100 + "%", minHeight: n > 0 ? 4 : 1, opacity: n === 0 ? 0.25 : 1 }} title={`${dayKeys[i]} · ${n}건`} />
          ))}
        </div>
        <div className="x-axis">
          {weekLabels.map((l, i) => <span key={i}>{l}</span>)}
        </div>
      </div>

      {/* Recent */}
      <div className="card recent">
        <h4>최근 업데이트 <em>Top 6</em></h4>
        <div className="recent-list">
          {recent.map(r => (
            <div key={r.id} className="recent-item" onClick={() => onOpen(r.id)}>
              <CategoryTag catKey={r.category} dense />
              <div className="body">
                <div className="id">{r.id}</div>
                <div className="t">{r.title}</div>
                <div className="m">
                  <StatusBadge statusKey={r.status} dense />
                  <span>·</span>
                  <Avatar id={r.assignee} size={16} showTip />
                  <span style={{ color: "var(--ink-4)", fontFamily: "var(--mono)", fontSize: 10.5 }}>{r.updatedAt}</span>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>

      {/* Urgent */}
      <div className="card urgent">
        <h4>긴급 / 지연 요청 <em>P0-P1 · 미완료</em></h4>
        <div className="table-wrap" style={{ border: "none", marginTop: -4 }}>
          <table className="req-table">
            <thead>
              <tr>
                <th style={{ width: 70 }}>우선순위</th>
                <th style={{ width: 100 }}>요청번호</th>
                <th style={{ width: 60 }}>구분</th>
                <th>제목</th>
                <th style={{ width: 120 }}>담당자</th>
                <th style={{ width: 90 }}>상태</th>
                <th style={{ width: 90 }}>완료 희망</th>
              </tr>
            </thead>
            <tbody>
              {urgent.slice(0, 6).map(r => {
                const asn = findMember(r.assignee);
                return (
                  <tr key={r.id} onClick={() => onOpen(r.id)}>
                    <td><PriorityChip pKey={r.priority} /></td>
                    <td className="id">{r.id}</td>
                    <td><CategoryTag catKey={r.category} /></td>
                    <td className="title">{r.title}</td>
                    <td>
                      <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                        <Avatar id={r.assignee} size={18} showTip /> {asn.name}
                      </span>
                    </td>
                    <td><StatusBadge statusKey={r.status} /></td>
                    <td><DueBadge due={r.dueAt} status={r.status} /></td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

/* ============ List/Table ============ */
function ListView({ requests, onOpen, selectedId }) {
  const selectedRef = useRef(null);
  // 알림 클릭 등으로 selectedId 가 변하면 해당 행으로 자동 스크롤
  useEffect(() => {
    if (selectedRef.current) {
      selectedRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
    }
  }, [selectedId]);
  return (
    <div className="table-wrap" style={{ borderTop: 0 }}>
      <table className="req-table">
        <thead>
          <tr>
            <th style={{ width: 120 }}>요청번호</th>
            <th style={{ width: 56 }}>구분</th>
            <th>제목</th>
            <th style={{ width: 70 }}>우선</th>
            <th style={{ width: 90 }}>상태</th>
            <th style={{ width: 110 }}>담당자</th>
            <th style={{ width: 100 }}>희망일</th>
          </tr>
        </thead>
        <tbody>
          {requests.map((r, i) => {
            const asn = findMember(r.assignee);
            const isSelected = selectedId === r.id;
            return (
              <tr key={r.id} ref={isSelected ? selectedRef : null} className={isSelected ? "selected" : ""} onClick={() => onOpen(r.id)}>
                <td className="id">{r.id}</td>
                <td><CategoryTag catKey={r.category} /></td>
                <td>
                  <div className="title">{r.title}</div>
                  <div className="meta">
                    <span>{r.subtype}</span>
                    <span style={{ color: "var(--ink-4)" }}>·</span>
                    <span>{r.module}</span>
                    {r.comments.length > 0 && <>
                      <span style={{ color: "var(--ink-4)" }}>·</span>
                      <span style={{ display: "inline-flex", alignItems: "center", gap: 3 }}><Icon.comment />{r.comments.length}</span>
                    </>}
                  </div>
                </td>
                <td><PriorityChip pKey={r.priority} /></td>
                <td><StatusBadge statusKey={r.status} /></td>
                <td>
                  <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                    <Avatar id={r.assignee} size={18} showTip />
                    <span style={{ fontSize: 12 }}>{asn.name}</span>
                  </span>
                </td>
                <td><DueBadge due={r.dueAt} status={r.status} /></td>
              </tr>
            );
          })}
          {requests.length === 0 && (
            <tr><td colSpan="7" className="empty">조건에 맞는 요청이 없습니다.</td></tr>
          )}
        </tbody>
      </table>
    </div>
  );
}

/* ============ Kanban ============ */
function KanbanView({ requests, onOpen, onStatusChange, currentUser }) {
  const [dragId, setDragId] = useState(null);
  const [overKey, setOverKey] = useState(null);
  const isSupervisor = currentUser?.role === "admin" || currentUser?.role === "pm";

  // 드래그 허용 여부: admin/pm 은 전부, 그 외엔 본인이 담당자인 카드만
  const canDragCard = (r) => isSupervisor || r.assignee === currentUser?.id;

  return (
    <div className="kanban">
      {STATUSES.map(s => {
        const items = requests.filter(r => r.status === s.key);
        return (
          <div key={s.key} className="kb-col">
            <div className="kb-head">
              <StatusBadge statusKey={s.key} />
              <span className="title" style={{ fontSize: 12 }}>{s.desc}</span>
              <span className="count" style={{ marginLeft: "auto" }}>{items.length}</span>
            </div>
            <div
              className={"kb-body " + (overKey === s.key ? "drop-over" : "")}
              onDragOver={e => { e.preventDefault(); setOverKey(s.key); }}
              onDragLeave={() => setOverKey(null)}
              onDrop={e => {
                e.preventDefault();
                if (dragId) onStatusChange(dragId, s.key);
                setOverKey(null); setDragId(null);
              }}
            >
              {items.map(r => {
                const asn = findMember(r.assignee);
                const canDrag = canDragCard(r);
                return (
                  <div
                    key={r.id}
                    className={"kb-card " + (dragId === r.id ? "dragging" : "") + (canDrag ? "" : " readonly")}
                    draggable={canDrag}
                    onDragStart={canDrag ? (() => setDragId(r.id)) : undefined}
                    onDragEnd={() => setDragId(null)}
                    onClick={() => onOpen(r.id)}
                    title={canDrag ? "드래그로 상태 변경" : "상태 변경 권한이 없습니다"}
                  >
                    <div className="row1">
                      <CategoryTag catKey={r.category} />
                      <PriorityChip pKey={r.priority} />
                      <span className="id">{r.id}</span>
                    </div>
                    <div className="title">{r.title}</div>
                    <div className="foot">
                      <DueBadge due={r.dueAt} status={r.status} />
                      <span className="spacer" />
                      <span style={{ display: "inline-flex", alignItems: "center", gap: 3, color: "var(--ink-4)" }}>
                        <Icon.comment /><span style={{ fontFamily: "var(--mono)" }}>{r.comments.length}</span>
                      </span>
                      <span style={{ display: "inline-flex", alignItems: "center", gap: 4, color: "var(--ink-2)", fontSize: 11.5, maxWidth: 120 }}>
                        <Avatar id={r.assignee} size={18} showTip />
                        <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{asn.name || "—"}</span>
                      </span>
                    </div>
                  </div>
                );
              })}
              {items.length === 0 && <div className="empty" style={{ padding: "16px 0", fontSize: 11.5 }}>—</div>}
            </div>
          </div>
        );
      })}
    </div>
  );
}

/* ============ Timeline ============ */
function TimelineView({ requests, onOpen }) {
  // 2026-04-05 ~ 2026-05-16 (6주)
  const startDate = "2026-04-05";
  const totalDays = 42;
  const dates = [];
  for (let i = 0; i < totalDays; i++) {
    const d = new Date(toDate(startDate).getTime() + i * 86400000);
    dates.push(d.toISOString().slice(0, 10));
  }
  const dayWidth = 36;

  // 정렬: 카테고리 → 요청일
  const sorted = [...requests].sort((a, b) => {
    if (a.category !== b.category) return a.category.localeCompare(b.category);
    return a.requestedAt.localeCompare(b.requestedAt);
  });

  return (
    <div className="timeline">
      <div className="tl-frame">
        <div className="tl-head">
          <div className="left">요청</div>
          <div className="tl-days" style={{ gridTemplateColumns: `repeat(${totalDays}, ${dayWidth}px)` }}>
            {dates.map((d, i) => {
              const day = new Date(d).getDay();
              const isToday = d === todayISO;
              const dayNum = d.slice(8);
              const weekStart = day === 1;
              return (
                <div key={d} className={`d ${isToday ? "today" : ""} ${weekStart ? "week-start" : ""}`}>
                  {(weekStart || isToday) && <div style={{ fontSize: 9, opacity: 0.7 }}>{d.slice(5, 7)}/{dayNum}</div>}
                  {!weekStart && !isToday && dayNum}
                </div>
              );
            })}
          </div>
        </div>

        {sorted.map(r => {
          const startIdx = Math.max(0, dates.indexOf(r.requestedAt));
          const endIdx = Math.min(totalDays - 1, dates.indexOf(r.dueAt) >= 0 ? dates.indexOf(r.dueAt) : totalDays - 1);
          const width = (endIdx - startIdx + 1) * dayWidth - 2;
          const left = startIdx * dayWidth + 1;
          const c = toneMap[CATEGORIES[r.category].color];
          const done = r.status === "done";
          const overdue = daysDiff(todayISO, r.dueAt) < 0 && !isTerminalStatus(r.status);
          return (
            <div key={r.id} className="tl-row">
              <div className="left" onClick={() => onOpen(r.id)}>
                <CategoryTag catKey={r.category} />
                <span className="id">{r.id.slice(-4)}</span>
                <span className="t">{r.title}</span>
              </div>
              <div className="tl-track" style={{ gridTemplateColumns: `repeat(${totalDays}, ${dayWidth}px)` }}>
                {dates.map((d, i) => {
                  const day = new Date(d).getDay();
                  const weekStart = day === 1;
                  return (
                    <div key={d} className={`tl-grid-cell ${weekStart ? "week-start" : ""} ${d === todayISO ? "today" : ""}`} />
                  );
                })}
                {startIdx >= 0 && endIdx >= startIdx && (
                  <div
                    className="tl-bar"
                    onClick={() => onOpen(r.id)}
                    style={{
                      left: left + "px",
                      width: Math.max(width, dayWidth - 2) + "px",
                      background: done ? toneMap.green.bg : c.bg,
                      color: done ? toneMap.green.fg : c.fg,
                      border: overdue ? "1px solid #e11d48" : `1px solid ${c.dot}`,
                      opacity: done ? 0.7 : 1,
                    }}
                    title={r.title}
                  >
                    {done && <Icon.check />}
                    {overdue && <Icon.warn />}
                    <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                      {r.id.slice(-4)} · {findMember(r.assignee).name}
                    </span>
                  </div>
                )}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

Object.assign(window, { DashboardView, ListView, KanbanView, TimelineView });

// 상세 패널 + 신규 요청 모달

function DetailPanel({ request, onClose, onUpdate, onRefresh, currentUser, users }) {
  const r = request;
  if (!r) return null;
  const toast = useToast();
  const [newComment, setNewComment] = useState("");
  const [editingStatus, setEditingStatus] = useState(false);
  const [editingAssignee, setEditingAssignee] = useState(false);
  const [attachBusy, setAttachBusy] = useState(false);
  const attachInputRef = useRef(null);
  const statusRef = useRef(null);
  const assigneeRef = useRef(null);
  useClickOutside(statusRef, () => setEditingStatus(false));
  useClickOutside(assigneeRef, () => setEditingAssignee(false));

  const attachments = r.attachments || [];
  const uploadFiles = async (fileList) => {
    const files = Array.from(fileList || []);
    if (!files.length) return;
    let okCount = 0, fail = 0, firstErr = null;
    setAttachBusy(true);
    try {
      for (const f of files) {
        const existing = attachments.length + okCount;
        const vErr = validateAttachmentFile(f, existing);
        if (vErr) { if (!firstErr) firstErr = vErr; fail++; continue; }
        try { await API.uploadAttachment(r.id, f); okCount++; }
        catch (e) { if (!firstErr) firstErr = e.message || "업로드 실패"; fail++; }
      }
      if (okCount > 0) await onRefresh?.();
    } finally { setAttachBusy(false); }
    if (fail > 0) toast(firstErr || "일부 파일 업로드 실패", "rose");
    else if (okCount > 0) toast(`첨부 ${okCount}개 업로드`, "green");
  };
  const onPickDetail = (e) => { uploadFiles(e.target.files); e.target.value = ""; };

  const removeAttachment = async (att) => {
    if (!confirm(`"${att.filename}" 첨부를 삭제하시겠습니까?`)) return;
    try {
      await API.deleteAttachment(att.id);
      await onRefresh?.();
      toast("첨부가 삭제되었습니다", "blue");
    } catch (e) {
      toast(e.message || "삭제 실패", "rose");
    }
  };

  const currentUserId = currentUser?.id;
  const isAdmin = currentUser?.role === "admin";
  const isPm = currentUser?.role === "pm";
  const isSupervisor = isAdmin || isPm;
  const isAssignee = r.assignee === currentUserId;
  const isRequester = r.requester === currentUserId;

  // 상태 변경 권한 + 드롭다운에 표시할 옵션 계산
  const canEditStatus =
    isSupervisor || isAssignee || (isRequester && r.status === "requested");
  const statusOptions = (isSupervisor || isAssignee)
    ? STATUSES
    : (isRequester && r.status === "requested")
      ? STATUSES.filter(s => s.key === "requested" || s.key === "cancelled")
      : [];

  const canEditAssignee = isSupervisor || isAssignee;

  const req = findMember(r.requester);
  const asn = findMember(r.assignee);
  const cat = CATEGORIES[r.category];

  const addComment = () => {
    if (!newComment.trim()) return;
    // 서버가 at / whoName / updatedAt 모두 세팅함 (클라 값은 App.updateRequest 에서 제거됨)
    onUpdate(r.id, {
      comments: [...r.comments, { who: currentUserId, text: newComment.trim() }],
    });
    setNewComment("");
    toast("코멘트가 등록되었습니다", "green");
  };

  const changeStatus = async (s) => {
    setEditingStatus(false);
    const res = await onUpdate(r.id, { status: s });
    if (res?.cancelled || res?.error) return;
    toast(`상태가 '${STATUSES.find(x => x.key === s).label}'로 변경되었습니다`, "blue");
  };

  const changeAssignee = async (id) => {
    setEditingAssignee(false);
    const res = await onUpdate(r.id, { assignee: id });
    if (res?.error) return;
    toast("담당자가 변경되었습니다", "blue");
  };

  // 해당 카테고리 역할의 활성 사용자만 후보 — 현재 담당자는 해당 role 이 아니더라도 자기 자신은 목록에 표시
  const candidates = useMemo(() => {
    const roleKey = { DEV: "dev", AA: "aa", DBA: "dba", CA: "ca" }[cat.label];
    const list = (users || [])
      .filter(u => u.status === "active" && u.role === roleKey)
      .sort((a, b) => (a.name || "").localeCompare(b.name || "", "ko"));
    // 현재 지정된 담당자가 풀에 없으면(역할 변경/비활성화 등) 맨 위에 붙여서 여전히 표시/해제 가능하게
    if (r.assignee && !list.some(u => u.id === r.assignee)) {
      const cur = (users || []).find(u => u.id === r.assignee);
      if (cur) list.unshift(cur);
    }
    return list;
  }, [users, cat.label, r.assignee]);

  return (
    <aside className="detail">
      <div className="detail-head">
        <div className="row">
          <CategoryTag catKey={r.category} />
          <span className="id">{r.id}</span>
          <PriorityChip pKey={r.priority} />
          <span className="close" onClick={onClose}><Icon.close /></span>
        </div>
        <h2>{r.title}</h2>
      </div>

      <div className="detail-body">
        <dl className="detail-grid">
          <dt>상태</dt>
          <dd>
            {canEditStatus ? (
              <div className="dropdown-wrap" ref={statusRef}>
                <span onClick={() => setEditingStatus(v => !v)} style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 4 }}>
                  <StatusBadge statusKey={r.status} />
                  <Icon.chevron style={{ transform: "rotate(90deg)", color: "var(--ink-4)" }} />
                </span>
                {editingStatus && (
                  <div className="popover">
                    {statusOptions.map(s => (
                      <div key={s.key} className={"item " + (s.key === r.status ? "selected" : "")} onClick={() => changeStatus(s.key)}>
                        <StatusBadge statusKey={s.key} />
                        {s.key === r.status && <span className="check"><Icon.check /></span>}
                      </div>
                    ))}
                  </div>
                )}
              </div>
            ) : (
              <span title="상태 변경 권한이 없습니다"><StatusBadge statusKey={r.status} /></span>
            )}
          </dd>

          <dt>세부 유형</dt>
          <dd>{r.subtype}</dd>

          <dt>모듈</dt>
          <dd>{r.module}</dd>

          <dt>요청자</dt>
          <dd><Avatar id={r.requester} size={18} /> {req.name} <span style={{ color: "var(--ink-4)", fontSize: 11 }}>{req.company}</span></dd>

          <dt>담당자</dt>
          <dd>
            {canEditAssignee ? (
              <div className="dropdown-wrap" ref={assigneeRef}>
                <span onClick={() => setEditingAssignee(v => !v)} style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6 }}>
                  <Avatar id={r.assignee} size={18} /> {asn.name}
                  <Icon.chevron style={{ transform: "rotate(90deg)", color: "var(--ink-4)" }} />
                </span>
                {editingAssignee && (
                  <div className="popover">
                    {candidates.length === 0 ? (
                      <div style={{ padding: "10px 12px", color: "var(--ink-4)", fontSize: 12 }}>
                        {cat.label} 역할의 활성 사용자가 없습니다
                      </div>
                    ) : candidates.map(m => (
                      <div key={m.id} className={"item " + (m.id === r.assignee ? "selected" : "")} onClick={() => changeAssignee(m.id)}>
                        <Avatar id={m.id} size={18} />
                        <span>{m.name}</span>
                        <span style={{ color: "var(--ink-4)", fontSize: 11, marginLeft: "auto" }}>
                          {ROLE_LABEL[m.role] || m.role}{m.company ? ` · ${m.company}` : ""}
                        </span>
                      </div>
                    ))}
                  </div>
                )}
              </div>
            ) : (
              <span title="담당자 변경 권한이 없습니다" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                <Avatar id={r.assignee} size={18} /> {asn.name}
              </span>
            )}
          </dd>

          <dt>요청일</dt>
          <dd style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{r.requestedAt}</dd>

          <dt>완료 희망</dt>
          <dd><span style={{ fontFamily: "var(--mono)", fontSize: 12, marginRight: 6 }}>{r.dueAt}</span> <DueBadge due={r.dueAt} status={r.status} /></dd>

          <dt>예상 공수</dt>
          <dd style={{ fontFamily: "var(--mono)" }}>{r.estimate}</dd>

          {r.watchers.length > 0 && <>
            <dt>참여자</dt>
            <dd>
              <AvatarStack ids={r.watchers} size={20} max={5} />
              <span style={{ color: "var(--ink-4)", fontSize: 11 }}>{r.watchers.length}명</span>
            </dd>
          </>}
        </dl>

        <div className="detail-section">
          <h3>요청 상세</h3>
          <p>{r.summary}</p>
        </div>

        <div className="detail-section">
          <h3>변경 이력 ({(r.events || []).length})</h3>
          {(r.events || []).length === 0 ? (
            <div style={{ color: "var(--ink-4)", fontSize: 12 }}>기록된 변경이 없습니다.</div>
          ) : (
            <div className="event-list" style={{ display: "flex", flexDirection: "column", gap: 6, marginBottom: 12 }}>
              {[...(r.events || [])].sort((a, b) => (b.at || "").localeCompare(a.at || "")).map(ev => {
                const { icon, text } = describeRequestEvent(ev);
                return (
                  <div key={ev.id} style={{ display: "flex", alignItems: "baseline", gap: 8, fontSize: 12.5, color: "var(--ink-2)", padding: "4px 0" }}>
                    <span style={{ fontSize: 13, lineHeight: 1 }}>{icon}</span>
                    <span style={{ flex: 1 }}>{text}</span>
                    <span style={{ color: "var(--ink-4)", fontSize: 11, fontFamily: "var(--mono)" }} title={ev.at}>
                      {formatDateTimeKR(ev.at)}
                    </span>
                  </div>
                );
              })}
            </div>
          )}
        </div>

        <div className="detail-section">
          <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
            <h3 style={{ margin: 0 }}>첨부파일 ({attachments.length})</h3>
            <Btn
              kind="ghost"
              icon={<Icon.paperclip />}
              onClick={() => attachInputRef.current?.click()}
              disabled={attachBusy || attachments.length >= ATTACH_MAX_PER_REQUEST}
            >
              {attachBusy ? "업로드 중…" : "추가"}
            </Btn>
          </div>
          {attachments.length === 0 ? (
            <div style={{ color: "var(--ink-4)", fontSize: 12 }}>첨부된 파일이 없습니다. (파일당 {Math.round(ATTACH_MAX_FILE_SIZE/1024)}KB, 최대 {ATTACH_MAX_PER_REQUEST}개)</div>
          ) : (
            <div className="attach-list">
              {attachments.map(a => {
                const canDelete = a.uploadedBy === currentUserId || isAdmin;
                return (
                  <div key={a.id} className="attach-item">
                    <Icon.paperclip />
                    <a className="name" href={API.attachmentUrl(a.id)} target="_blank" rel="noopener" title={a.filename}>{a.filename}</a>
                    <span className="size">{formatBytes(a.size)}</span>
                    <span className="meta">{a.uploadedByName || a.uploadedBy}</span>
                    {canDelete && (
                      <button type="button" className="x" onClick={() => removeAttachment(a)} aria-label="삭제">×</button>
                    )}
                  </div>
                );
              })}
            </div>
          )}
        </div>

        <input ref={attachInputRef} type="file" accept={ATTACH_ACCEPT} multiple hidden onChange={onPickDetail} />

        <div className="detail-section">
          <h3>코멘트 ({r.comments.length})</h3>
          <div className="comments">
            {r.comments.length === 0 && <div style={{ color: "var(--ink-4)", fontSize: 12 }}>아직 코멘트가 없습니다.</div>}
            {r.comments.map((c, i) => {
              const m = findMember(c.who);
              return (
                <div key={i} className="comment">
                  <Avatar id={c.who} size={26} />
                  <div className="body">
                    <div className="who">
                      <span>{m.name}</span>
                      <span className="role">{m.role}</span>
                      <span className="when" style={{ marginLeft: "auto", fontFamily: "var(--mono)", fontSize: 11 }} title={c.at}>{formatDateTimeKR(c.at)}</span>
                    </div>
                    <p>{c.text}</p>
                  </div>
                </div>
              );
            })}
          </div>
          <div className="comment-compose">
            <textarea
              placeholder="코멘트 또는 처리 결과를 입력하세요..."
              value={newComment}
              onChange={e => setNewComment(e.target.value)}
            />
            <div className="actions">
              <Btn
                kind="ghost"
                icon={<Icon.paperclip />}
                onClick={() => attachInputRef.current?.click()}
                disabled={attachBusy || attachments.length >= ATTACH_MAX_PER_REQUEST}
              >
                {attachBusy ? "업로드 중…" : "첨부"}
              </Btn>
              <Btn kind="primary" onClick={addComment}>등록</Btn>
            </div>
          </div>
        </div>
      </div>
    </aside>
  );
}

function NewRequestModal({ onClose, onCreate, currentUser, users }) {
  const today = new Date().toISOString().slice(0, 10);
  const dueDefault = (() => {
    const d = new Date(); d.setUTCDate(d.getUTCDate() + 7);
    return d.toISOString().slice(0, 10);
  })();

  // 실제 DB 사용자 중 활성 계정만 — 역할별로 풀 구성
  const poolByRole = useMemo(() => {
    const active = (users || []).filter(u => u.status === "active");
    return {
      AA: active.filter(u => u.role === "aa"),
      DBA: active.filter(u => u.role === "dba"),
      CA: active.filter(u => u.role === "ca"),
    };
  }, [users]);

  const [form, setForm] = useState({
    category: "DBA",
    subtype: CATEGORIES.DBA.subtypes[0],
    priority: "P2",
    title: "",
    summary: "",
    module: "",
    assignee: poolByRole.DBA[0]?.id || "",
    requestedAt: today,
    dueAt: dueDefault,
    estimate: "1.0d",
  });
  const [error, setError] = useState({});
  const [pendingFiles, setPendingFiles] = useState([]);
  const [fileError, setFileError] = useState(null);
  const fileInputRef = useRef(null);
  const update = (k, v) => setForm(f => ({ ...f, [k]: v }));

  const onPickFiles = (e) => {
    const picked = Array.from(e.target.files || []);
    let next = [...pendingFiles];
    for (const f of picked) {
      const err = validateAttachmentFile(f, next.length);
      if (err) { setFileError(err); e.target.value = ""; return; }
      next.push(f);
    }
    setPendingFiles(next);
    setFileError(null);
    e.target.value = "";
  };
  const removeFile = (idx) => setPendingFiles(fs => fs.filter((_, i) => i !== idx));

  // 구분 변경 시 서브타입/담당자 재설정
  useEffect(() => {
    const pool = poolByRole[form.category] || [];
    setForm(f => ({
      ...f,
      subtype: CATEGORIES[f.category].subtypes[0],
      assignee: pool.some(m => m.id === f.assignee) ? f.assignee : (pool[0]?.id || ""),
    }));
  }, [form.category, poolByRole]);

  const assigneePool = poolByRole[form.category] || [];

  const submit = () => {
    const err = {};
    if (!form.title.trim()) err.title = "제목을 입력하세요";
    if (!form.summary.trim()) err.summary = "요청 상세를 입력하세요";
    if (!form.module.trim()) err.module = "모듈을 입력하세요";
    if (!form.assignee) err.assignee = `${form.category} 역할의 활성 사용자가 없습니다`;
    setError(err);
    if (Object.keys(err).length) return;

    onCreate({
      ...form,
      requester: currentUser.id,
      status: "requested",
      updatedAt: form.requestedAt,
      watchers: [],
      comments: [],
    }, pendingFiles);
  };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal" onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <h2>신규 요구사항 등록</h2>
          <span className="close" onClick={onClose} style={{ cursor: "pointer", color: "var(--ink-3)" }}><Icon.close /></span>
        </div>
        <div className="modal-body">
          <TextField label="요청 구분" hint="DEV / AA / DBA / CA">
            <select className="select" value={form.category} onChange={e => update("category", e.target.value)}>
              {Object.entries(CATEGORIES).map(([k, c]) => (
                <option key={k} value={k}>{c.label} · {c.fullLabel}</option>
              ))}
            </select>
          </TextField>
          <TextField label="세부 유형">
            <select className="select" value={form.subtype} onChange={e => update("subtype", e.target.value)}>
              {CATEGORIES[form.category].subtypes.map(s => <option key={s} value={s}>{s}</option>)}
            </select>
          </TextField>
          <TextField label="우선순위">
            <select className="select" value={form.priority} onChange={e => update("priority", e.target.value)}>
              {PRIORITIES.map(p => <option key={p.key} value={p.key}>{p.key} · {p.label}</option>)}
            </select>
          </TextField>
          <TextField label="모듈 / 도메인" error={error.module}>
            <input className="input" placeholder="예: 주문, 결제, 상품" value={form.module} onChange={e => update("module", e.target.value)} />
          </TextField>
          <TextField label="제목" error={error.title}>
            <input className="input" placeholder="요청 내용을 한 줄로 요약" value={form.title} onChange={e => update("title", e.target.value)} />
          </TextField>
          <div className="full">
            <TextField label="요청 상세" hint="배경, 요건, 기대 결과" error={error.summary}>
              <textarea className="textarea" value={form.summary} onChange={e => update("summary", e.target.value)} placeholder="기획 배경, 요구 사항, 참고 문서 링크 등" />
            </TextField>
          </div>
          <TextField label="요청자" hint="로그인한 본인으로 고정">
            <div className="input" style={{ display: "flex", alignItems: "center", gap: 8, background: "var(--panel-2)", color: "var(--ink-2)", cursor: "not-allowed" }}>
              <Avatar id={currentUser.id} size={20} />
              <span style={{ fontWeight: 500, color: "var(--ink-1)" }}>{currentUser.name}</span>
              {currentUser.company && <span style={{ color: "var(--ink-3)", fontSize: 12 }}>· {currentUser.company}</span>}
            </div>
          </TextField>
          <TextField label="담당자" error={error.assignee}>
            {assigneePool.length > 0 ? (
              <select className="select" value={form.assignee} onChange={e => update("assignee", e.target.value)}>
                {assigneePool.map(m => (
                  <option key={m.id} value={m.id}>
                    {m.name}{m.company ? ` · ${m.company}` : ""}
                  </option>
                ))}
              </select>
            ) : (
              <div className="input" style={{ background: "var(--panel-2)", color: "#b91c1c", fontSize: 12 }}>
                {form.category} 역할의 활성 사용자가 없습니다 — 관리자에게 문의하세요
              </div>
            )}
          </TextField>
          <TextField label="요청일">
            <input className="input" type="date" value={form.requestedAt} onChange={e => update("requestedAt", e.target.value)} />
          </TextField>
          <TextField label="완료 희망일">
            <input className="input" type="date" value={form.dueAt} onChange={e => update("dueAt", e.target.value)} />
          </TextField>
          <TextField label="예상 공수" hint="예: 0.5d, 2.0d">
            <input className="input" value={form.estimate} onChange={e => update("estimate", e.target.value)} />
          </TextField>
        </div>
        {(pendingFiles.length > 0 || fileError) && (
          <div className="attach-pending">
            {fileError && <div className="attach-err">{fileError}</div>}
            {pendingFiles.map((f, i) => (
              <div key={i} className="attach-item">
                <Icon.paperclip />
                <span className="name" title={f.name}>{f.name}</span>
                <span className="size">{formatBytes(f.size)}</span>
                <button type="button" className="x" onClick={() => removeFile(i)} aria-label="제거">×</button>
              </div>
            ))}
          </div>
        )}
        <input ref={fileInputRef} type="file" accept={ATTACH_ACCEPT} multiple hidden onChange={onPickFiles} />
        <div className="modal-foot">
          <Btn kind="ghost" onClick={onClose}>취소</Btn>
          <Btn kind="outline" icon={<Icon.paperclip />} onClick={() => fileInputRef.current?.click()}>
            파일 첨부{pendingFiles.length > 0 && ` (${pendingFiles.length})`}
          </Btn>
          <Btn kind="primary" onClick={submit}>요청 등록</Btn>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { DetailPanel, NewRequestModal });

// ===== Auth & Admin =====
const { useState: _useS, useEffect: _useE } = React;

const AUTH_STORAGE_KEY = null; // (제거됨: API 기반 세션 사용)

// ============ API Client ============
async function apiJSON(method, url, body) {
  const opts = { method, headers: {}, credentials: "same-origin" };
  if (body !== undefined) {
    opts.headers["Content-Type"] = "application/json";
    opts.body = JSON.stringify(body);
  }
  const res = await fetch(url, opts);
  let data = null;
  try { data = await res.json(); } catch { }
  if (!res.ok) {
    const msg = data?.error || `요청 실패 (${res.status})`;
    throw new Error(msg);
  }
  return data;
}

// ============ Attachment constants (서버와 동일하게 유지) ============
const ATTACH_MAX_FILE_SIZE = 700 * 1024;
const ATTACH_MAX_PER_REQUEST = 10;
const ATTACH_ALLOWED_MIME = new Set([
  "image/jpeg", "image/png", "image/gif", "image/webp",
  "application/pdf",
  "application/msword",
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  "application/vnd.ms-excel",
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  "application/vnd.ms-powerpoint",
  "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  "application/zip",
  "text/plain", "text/csv", "text/markdown",
]);
const ATTACH_ACCEPT = "image/jpeg,image/png,image/gif,image/webp,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,application/zip,text/plain,.csv,.md";

function formatBytes(n) {
  if (n < 1024) return n + " B";
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
  return (n / 1024 / 1024).toFixed(2) + " MB";
}
function validateAttachmentFile(file, existingCount) {
  if (existingCount >= ATTACH_MAX_PER_REQUEST) return `첨부는 최대 ${ATTACH_MAX_PER_REQUEST}개까지 가능합니다`;
  if (file.size > ATTACH_MAX_FILE_SIZE) return `"${file.name}"이(가) ${Math.round(ATTACH_MAX_FILE_SIZE/1024)}KB를 초과합니다 (${formatBytes(file.size)})`;
  if (file.size === 0) return `"${file.name}"이(가) 비어있습니다`;
  const mime = (file.type || "").toLowerCase();
  if (!ATTACH_ALLOWED_MIME.has(mime)) return `"${file.name}" 형식은 허용되지 않습니다`;
  return null;
}

const API = {
  me: () => apiJSON("GET", "/api/auth/me"),
  login: (email, password) => apiJSON("POST", "/api/auth/login", { email, password }),
  logout: () => apiJSON("POST", "/api/auth/logout"),
  signup: (payload) => apiJSON("POST", "/api/auth/signup", payload),
  changePw: (cur, next) => apiJSON("POST", "/api/auth/change-password", { currentPassword: cur, newPassword: next }),
  listUsers: () => apiJSON("GET", "/api/users"),
  patchUser: (id, p) => apiJSON("PATCH", `/api/users/${id}`, p),
  deleteUser: (id) => apiJSON("DELETE", `/api/users/${id}`),
  resetPassword: (id, p = {}) => apiJSON("POST", `/api/users/${id}/reset-password`, p),
  listRequests: () => apiJSON("GET", "/api/requests"),
  createRequest: (p) => apiJSON("POST", "/api/requests", p),
  patchRequest: (id, p) => apiJSON("PATCH", `/api/requests/${id}`, p),
  deleteRequest: (id) => apiJSON("DELETE", `/api/requests/${id}`),
  addComment: (id, text) => apiJSON("POST", `/api/requests/${id}/comments`, { text }),
  uploadAttachment: async (requestId, file) => {
    const res = await fetch(`/api/requests/${requestId}/attachments`, {
      method: "POST",
      credentials: "same-origin",
      headers: {
        "Content-Type": file.type || "application/octet-stream",
        "X-Filename": encodeURIComponent(file.name),
      },
      body: file,
    });
    let data = null;
    try { data = await res.json(); } catch { }
    if (!res.ok) throw new Error(data?.error || `업로드 실패 (${res.status})`);
    return data;
  },
  deleteAttachment: (id) => apiJSON("DELETE", `/api/attachments/${id}`),
  attachmentUrl: (id) => `/api/attachments/${id}`,
  getProject: () => apiJSON("GET", "/api/project"),
  patchProject: (p) => apiJSON("PATCH", "/api/project", p),
  getNotiReads: () => apiJSON("GET", "/api/notifications/reads"),
  addNotiReads: (keys) => apiJSON("POST", "/api/notifications/reads", { keys }),
  listCompanies: () => apiJSON("GET", "/api/companies"),
  createCompany: (name) => apiJSON("POST", "/api/companies", { name }),
  deleteCompany: (name) => apiJSON("DELETE", `/api/companies/${encodeURIComponent(name)}`),
};

const ROLE_LABEL = {
  dev: "DEV", aa: "AA", dba: "DBA", ca: "CA", pm: "PM", admin: "Admin",
};
const ROLE_DESC = {
  dev: "Developer", aa: "Application", dba: "Database", ca: "Cloud", pm: "프로젝트 감독자",
};

function AuthScreen({ onLogin, project, companies }) {
  const [tab, setTab] = _useS("login");
  const [loginForm, setLoginForm] = _useS({ email: "", password: "" });
  const [signupForm, setSignupForm] = _useS({ email: "", password: "", name: "", company: "", role: "dev" });
  const [msg, setMsg] = _useS(null);
  const [busy, setBusy] = _useS(false);

  const updateLogin = (k, v) => setLoginForm(f => ({ ...f, [k]: v }));
  const updateSignup = (k, v) => setSignupForm(f => ({ ...f, [k]: v }));

  const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
  const LOGIN_ID_RE = /^[A-Za-z0-9._%+-]+(?:@[A-Za-z0-9.-]+\.[A-Za-z]{2,})?$/;

  const doLogin = async () => {
    const id = loginForm.email.trim().toLowerCase();
    if (!id || !loginForm.password) return setMsg({ kind: "err", text: "아이디(이메일 앞부분) 또는 이메일과 비밀번호를 입력해주세요." });
    if (!LOGIN_ID_RE.test(id)) return setMsg({ kind: "err", text: "아이디 또는 이메일 형식이 올바르지 않습니다." });
    setBusy(true);
    try {
      const { user } = await API.login(id, loginForm.password);
      onLogin(user);
    } catch (e) {
      setMsg({ kind: "err", text: e.message });
    } finally { setBusy(false); }
  };

  const doSignup = async () => {
    const email = signupForm.email.trim();
    const name = signupForm.name.trim();
    if (!email || !signupForm.password || !name) return setMsg({ kind: "err", text: "이메일, 비밀번호, 이름을 입력해주세요." });
    if (!EMAIL_RE.test(email)) return setMsg({ kind: "err", text: "이메일 형식이 올바르지 않습니다." });
    const pwErr = validatePasswordClient(signupForm.password);
    if (pwErr) return setMsg({ kind: "err", text: pwErr });
    setBusy(true);
    try {
      await API.signup({ email, password: signupForm.password, name, role: signupForm.role, company: signupForm.company?.trim() || null });
      setMsg({ kind: "ok", text: "가입 신청이 접수되었습니다. 관리자 승인 후 로그인하실 수 있습니다." });
      setLoginForm({ email, password: "" });
      setSignupForm({ email: "", password: "", name: "", company: "", role: "dev" });
      setTimeout(() => setTab("login"), 1500);
    } catch (e) {
      setMsg({ kind: "err", text: e.message });
    } finally { setBusy(false); }
  };

  return (
    <div className="auth-wrap">
      <div className="auth-card">
        <div className="auth-brand">
          <div className="logo"><BrandMark /></div>
          <div>
            <div className="title">TechBridge</div>
            <div className="sub">{[project?.code, project?.name].filter(Boolean).join(" · ")}</div>
          </div>
        </div>
        <div className="auth-tabs">
          <button className={tab === "login" ? "active" : ""} onClick={() => {
            setTab("login"); setMsg(null);
            setLoginForm({ email: "", password: "" });
          }}>로그인</button>
          <button className={tab === "signup" ? "active" : ""} onClick={() => {
            setTab("signup"); setMsg(null);
            setSignupForm({ email: "", password: "", name: "", company: "", role: "dev" });
          }}>회원가입</button>
        </div>

        {msg && <div className={"auth-msg " + (msg.kind === "ok" ? "ok" : msg.kind === "info" ? "info" : "")}>{msg.text}</div>}

        {tab === "login" ? (
          <>
            <div className="auth-field">
              <label>아이디 또는 이메일</label>
              <input value={loginForm.email} onChange={e => updateLogin("email", e.target.value)} placeholder="예: hdo 또는 hdo@company.com" autoComplete="username" />
            </div>
            <div className="auth-field">
              <label>비밀번호</label>
              <input type="password" value={loginForm.password} onChange={e => updateLogin("password", e.target.value)} onKeyDown={e => e.key === "Enter" && doLogin()} autoComplete="current-password" />
            </div>
            <button className="auth-submit" onClick={doLogin} disabled={busy}>로그인</button>
          </>
        ) : (
          <>
            <div className="auth-field">
              <label>이름</label>
              <input value={signupForm.name} onChange={e => updateSignup("name", e.target.value)} placeholder="홍길동" autoComplete="name" />
            </div>
            <div className="auth-field">
              <label>이메일</label>
              <input type="email" value={signupForm.email} onChange={e => updateSignup("email", e.target.value)} placeholder="user@company.com" autoComplete="email" />
            </div>
            <div className="auth-field">
              <label>비밀번호</label>
              <input type="password" value={signupForm.password} onChange={e => updateSignup("password", e.target.value)} autoComplete="new-password" />
              <div style={{ fontSize: 11, color: "var(--ink-4)", marginTop: 4 }}>{PASSWORD_RULE_TEXT}</div>
            </div>
            <div className="auth-field">
              <label>소속 회사 *</label>
              {companies && companies.length > 0 ? (
                <select className="input" value={signupForm.company} onChange={e => updateSignup("company", e.target.value)}>
                  <option value="">— 선택 —</option>
                  {companies.map(c => <option key={c.name} value={c.name}>{c.name}</option>)}
                </select>
              ) : (
                <div className="input" style={{ color: "#b91c1c", background: "var(--panel-2)", fontSize: 12 }}>
                  등록된 회사가 없습니다. 관리자에게 회사 등록을 요청해 주세요.
                </div>
              )}
            </div>
            <div className="auth-field">
              <label>역할</label>
              <div className="auth-roles">
                {["dev", "aa", "dba", "ca", "pm"].map(r => (
                  <div key={r} className={"r " + (signupForm.role === r ? "sel" : "")} onClick={() => updateSignup("role", r)}>
                    <b>{ROLE_LABEL[r]}</b>
                    <span>{ROLE_DESC[r]}</span>
                  </div>
                ))}
              </div>
            </div>
            <button className="auth-submit" onClick={doSignup} disabled={busy}>가입 신청</button>
            <div className="auth-hint">
              가입 신청 후 관리자 승인이 필요합니다
            </div>
          </>
        )}
      </div>
    </div>
  );
}

function PasswordResetResultModal({ user, password, onClose }) {
  const [copied, setCopied] = _useS(false);
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(password);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch {
      // clipboard API 사용 불가 환경 fallback
      const ta = document.createElement("textarea");
      ta.value = password; document.body.appendChild(ta); ta.select();
      try { document.execCommand("copy"); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { }
      document.body.removeChild(ta);
    }
  };
  return (
    <div style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.4)", display: "grid", placeItems: "center", zIndex: 1000 }} onClick={onClose}>
      <div onClick={e => e.stopPropagation()} style={{ background: "#fff", borderRadius: 8, padding: "22px 24px", width: 440, boxShadow: "var(--shadow-lg)" }}>
        <div style={{ fontWeight: 600, fontSize: 15, marginBottom: 4 }}>비밀번호 초기화 완료</div>
        <div style={{ color: "var(--ink-3)", fontSize: 12.5, lineHeight: 1.6, marginBottom: 14 }}>
          <b style={{ color: "var(--ink-1)" }}>{user.name}</b>({user.email})의 임시 비밀번호입니다. 이 화면을 닫으면 다시 볼 수 없으니 지금 복사해서 사용자에게 전달하세요. 사용자는 로그인 후 [비밀번호 변경]에서 바꿔야 합니다. 기존 세션은 모두 로그아웃되었습니다.
        </div>
        <div style={{ display: "flex", gap: 8, alignItems: "stretch" }}>
          <code style={{ flex: 1, fontFamily: "var(--mono)", fontSize: 15, padding: "10px 12px", background: "#f8f9fc", border: "1px solid var(--line)", borderRadius: 4, letterSpacing: "0.03em", userSelect: "all" }}>{password}</code>
          <button className="btn btn-outline" onClick={copy} style={{ minWidth: 64 }}>{copied ? "복사됨" : "복사"}</button>
        </div>
        <div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
          <button className="btn btn-primary" onClick={onClose}>닫기</button>
        </div>
      </div>
    </div>
  );
}

function AdminUsersView({ users, refresh, toast }) {
  const pending = users.filter(u => u.status === "pending");
  const active = users.filter(u => u.status === "active");
  const disabled = users.filter(u => u.status === "disabled");
  const [resetResult, setResetResult] = _useS(null); // { user, password }

  const run = async (fn, okMsg, okTone) => {
    try {
      await fn();
      await refresh();
      if (okMsg) toast(okMsg, okTone || "green");
    } catch (e) {
      toast(e.message || "요청 실패", "rose");
    }
  };
  const approve = (u) => run(() => API.patchUser(u.id, { status: "active" }), `${u.name} 계정이 승인되었습니다`, "green");
  const reject = (u) => run(() => API.deleteUser(u.id), `${u.name} 가입 신청을 반려했습니다`, "slate");
  const disable = (u) => run(() => API.patchUser(u.id, { status: "disabled" }), `${u.name} 계정을 비활성화했습니다`, "slate");
  const enable = (u) => run(() => API.patchUser(u.id, { status: "active" }), `${u.name} 계정을 활성화했습니다`, "green");
  const changeRole = (u, r) => run(() => API.patchUser(u.id, { role: r }), `${u.name} 역할을 ${ROLE_LABEL[r]}로 변경했습니다`, "blue");
  const resetPassword = async (u) => {
    if (!window.confirm(`${u.name}(${u.email})의 비밀번호를 초기화하시겠습니까?\n\n임시 비밀번호가 생성되며 기존 세션이 모두 로그아웃됩니다.`)) return;
    try {
      const { password } = await API.resetPassword(u.id);
      setResetResult({ user: u, password });
      await refresh();
    } catch (e) {
      toast(e.message || "초기화 실패", "rose");
    }
  };

  return (
    <div className="admin-wrap">
      <div className="admin-stats">
        <div className="s"><div className="lbl">전체 사용자</div><div className="n">{users.length}</div></div>
        <div className="s"><div className="lbl">승인 대기</div><div className="n" style={{ color: "#c2410c" }}>{pending.length}</div></div>
        <div className="s"><div className="lbl">활성</div><div className="n" style={{ color: "#047857" }}>{active.length}</div></div>
        <div className="s"><div className="lbl">비활성</div><div className="n" style={{ color: "#64748b" }}>{disabled.length}</div></div>
      </div>

      <div className="admin-section">
        <h3>승인 대기 <span className={"n " + (pending.length === 0 ? "gray" : "")}>{pending.length}</span></h3>
        {pending.length === 0 ? (
          <div className="admin-empty">대기 중인 가입 신청이 없습니다.</div>
        ) : (
          <table className="user-table">
            <thead><tr>
              <th style={{ width: 180 }}>이름</th><th style={{ width: 220 }}>이메일</th>
              <th style={{ width: 90 }}>신청 역할</th><th style={{ width: 140 }}>소속</th>
              <th style={{ width: 110 }}>신청일</th><th>승인/반려</th>
            </tr></thead>
            <tbody>
              {pending.map(u => (
                <tr key={u.id}>
                  <td><b>{u.name}</b></td>
                  <td style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{u.email}</td>
                  <td><span style={{ fontFamily: "var(--mono)", fontWeight: 700 }}>{ROLE_LABEL[u.role]}</span></td>
                  <td>{u.company || <span style={{ color: "var(--ink-4)" }}>—</span>}</td>
                  <td style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--ink-3)" }}>{u.createdAt}</td>
                  <td><div className="user-actions">
                    <button className="primary" onClick={() => approve(u)}>승인</button>
                    <button className="danger" onClick={() => reject(u)}>반려</button>
                  </div></td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </div>

      <div className="admin-section">
        <h3>사용자 목록 <span className="n gray">{active.length + disabled.length}</span></h3>
        <table className="user-table">
          <thead><tr>
            <th style={{ width: 180 }}>이름</th><th style={{ width: 220 }}>이메일</th>
            <th style={{ width: 120 }}>역할</th><th style={{ width: 140 }}>소속</th>
            <th style={{ width: 100 }}>상태</th><th>관리</th>
          </tr></thead>
          <tbody>
            {[...active, ...disabled].map(u => (
              <tr key={u.id}>
                <td>
                  <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                    <Avatar id={u.id} size={24} />
                    <b>{u.name}</b>
                    {u.role === "admin" && <span style={{ fontSize: 10, fontFamily: "var(--mono)", background: "#1e2538", color: "#fff", padding: "1px 6px", borderRadius: 3 }}>ADMIN</span>}
                  </div>
                </td>
                <td style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{u.email}</td>
                <td>
                  {u.role === "admin" ? (
                    <span style={{ fontFamily: "var(--mono)", fontWeight: 700 }}>Admin</span>
                  ) : (
                    <select value={u.role} onChange={e => changeRole(u, e.target.value)}
                      style={{ height: 26, fontSize: 12, border: "1px solid var(--line)", borderRadius: 4, padding: "0 6px", background: "#fff" }}>
                      <option value="dev">개발자</option>
                      <option value="aa">AA</option>
                      <option value="dba">DBA</option>
                      <option value="ca">CA</option>
                      <option value="pm">PM</option>
                    </select>
                  )}
                </td>
                <td>{u.company || <span style={{ color: "var(--ink-4)" }}>—</span>}</td>
                <td>
                  <span className={"user-status " + u.status}>
                    <span className="dot" />
                    {u.status === "active" ? "활성" : "비활성"}
                  </span>
                </td>
                <td>
                  {u.role !== "admin" && <div className="user-actions">
                    {u.status === "active"
                      ? <button className="danger" onClick={() => disable(u)}>비활성화</button>
                      : <button className="primary" onClick={() => enable(u)}>활성화</button>}
                    <button onClick={() => resetPassword(u)} title="임시 비밀번호로 초기화">비번 초기화</button>
                  </div>}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {resetResult && (
        <PasswordResetResultModal
          user={resetResult.user}
          password={resetResult.password}
          onClose={() => setResetResult(null)}
        />
      )}
    </div>
  );
}

function SettingsRow({ label, hint, children }) {
  return (
    <div className="auth-field" style={{ marginBottom: 14 }}>
      <label style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
        <span>{label}</span>
        {hint && <span style={{ color: "var(--ink-4)", fontSize: 11, fontFamily: "var(--mono)" }}>{hint}</span>}
      </label>
      {children}
    </div>
  );
}

// ============================================
function SettingsField({ label, hint, required, children }) {
  return (
    <div className="settings-field">
      <div className="label">
        {label}{required && <span className="req">*</span>}
        {hint && <span className="hint">{hint}</span>}
      </div>
      <div className="control">{children}</div>
    </div>
  );
}

function ProjectSettingsView({ project, onSaved, toast }) {
  const [form, setForm] = _useS({
    code: project?.code || "", name: project?.name || "",
    phase: project?.phase || "",
    start: project?.start || "", end: project?.end || "",
  });
  const [busy, setBusy] = _useS(false);

  _useE(() => {
    setForm({
      code: project?.code || "", name: project?.name || "",
      phase: project?.phase || "",
      start: project?.start || "", end: project?.end || "",
    });
  }, [project?.updatedAt]);

  const dirty = ["code", "name", "phase", "start", "end"]
    .some(k => (form[k] || "") !== (project?.[k] || ""));
  const update = (k, v) => setForm(f => ({ ...f, [k]: v }));

  const save = async () => {
    if (!form.name.trim()) return toast("프로젝트 이름은 필수입니다", "rose");
    setBusy(true);
    try {
      const { project: next } = await API.patchProject(form);
      onSaved(next);
      toast("프로젝트 정보가 저장되었습니다", "green");
    } catch (e) {
      toast(e.message || "저장 실패", "rose");
    } finally { setBusy(false); }
  };

  return (
    <div className="settings-page">
      <div className="settings-head">
        <div>
          <h2>프로젝트 설정</h2>
          <p>상단 헤더에 표시되는 프로젝트 정보를 관리합니다. 변경 사항은 모든 사용자에게 즉시 반영됩니다.</p>
        </div>
        <Btn kind="primary" onClick={save} disabled={busy || !dirty}>
          {busy ? "저장 중…" : "저장"}
        </Btn>
      </div>

      <div className="admin-section">
        <h3>프로젝트 정보</h3>

        <div className="settings-group">
          <div className="settings-group-title">기본 정보</div>
          <SettingsField label="프로젝트 코드" hint="예: CMRN-2026 · 자동 생성된 값은 그대로 두셔도 됩니다">
            <input className="input" value={form.code}
              onChange={e => update("code", e.target.value)} placeholder="CMRN-2026" />
          </SettingsField>
          <SettingsField label="프로젝트 이름" required>
            <input className="input" value={form.name}
              onChange={e => update("name", e.target.value)} placeholder="커머스 플랫폼 리뉴얼" />
          </SettingsField>
          <SettingsField label="단계" hint="phase — 개발 1차 / 안정화 등">
            <input className="input" value={form.phase}
              onChange={e => update("phase", e.target.value)} placeholder="개발 2단계" />
          </SettingsField>
        </div>

        <div className="settings-group">
          <div className="settings-group-title">일정</div>
          <SettingsField label="프로젝트 기간">
            <div className="pair">
              <input className="input" type="date" value={form.start}
                onChange={e => update("start", e.target.value)} />
              <input className="input" type="date" value={form.end}
                onChange={e => update("end", e.target.value)} />
            </div>
          </SettingsField>
        </div>

      </div>
    </div>
  );
}

function ChangePasswordView({ forced, onDone, onCancel, onLogout }) {
  const [cur, setCur] = _useS("");
  const [next, setNext] = _useS("");
  const [confirm, setConfirm] = _useS("");
  const [busy, setBusy] = _useS(false);
  const [msg, setMsg] = _useS(null);

  const submit = async () => {
    if (!cur) return setMsg({ kind: "err", text: forced ? "임시 비밀번호를 입력해주세요." : "현재 비밀번호를 입력해주세요." });
    const pwErr = validatePasswordClient(next);
    if (pwErr) return setMsg({ kind: "err", text: pwErr });
    if (next !== confirm) return setMsg({ kind: "err", text: "새 비밀번호 확인이 일치하지 않습니다." });
    if (next === cur) return setMsg({ kind: "err", text: "새 비밀번호는 현재 비밀번호와 달라야 합니다." });
    setBusy(true);
    try {
      await API.changePw(cur, next);
      setMsg({ kind: "ok", text: "비밀번호가 변경되었습니다." });
      setTimeout(() => onDone && onDone(), 600);
    } catch (e) {
      setMsg({ kind: "err", text: e.message || "변경 실패" });
    } finally { setBusy(false); }
  };

  const Card = (
    <div className="auth-card" style={{ width: 400 }}>
      <div className="auth-brand">
        <div className="logo"><BrandMark /></div>
        <div>
          <div className="title">{forced ? "임시 비밀번호 변경이 필요합니다" : "비밀번호 변경"}</div>
          <div className="sub">{forced ? "계속하려면 새 비밀번호를 설정하세요" : "안전한 비밀번호로 교체하세요"}</div>
        </div>
      </div>

      {msg && <div className={"auth-msg " + (msg.kind === "ok" ? "ok" : msg.kind === "info" ? "info" : "")}>{msg.text}</div>}

      <div className="auth-field">
        <label>{forced ? "임시 비밀번호" : "현재 비밀번호"}</label>
        <input type="password" value={cur} onChange={e => setCur(e.target.value)} autoComplete="current-password" />
      </div>
      <div className="auth-field">
        <label>새 비밀번호</label>
        <input type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
        <div style={{ fontSize: 11, color: "var(--ink-4)", marginTop: 4 }}>{PASSWORD_RULE_TEXT}</div>
      </div>
      <div className="auth-field">
        <label>새 비밀번호 확인</label>
        <input
          type="password"
          value={confirm}
          onChange={e => setConfirm(e.target.value)}
          onKeyDown={e => e.key === "Enter" && submit()}
          autoComplete="new-password"
        />
      </div>
      <button className="auth-submit" onClick={submit} disabled={busy}>{busy ? "변경 중…" : "변경"}</button>
      <div className="auth-hint" style={{ display: "flex", justifyContent: "space-between" }}>
        <span>현재 비밀번호와 달라야 함</span>
        {forced
          ? <a onClick={onLogout} style={{ cursor: "pointer", color: "var(--ink-3)" }}>로그아웃</a>
          : <a onClick={onCancel} style={{ cursor: "pointer", color: "var(--ink-3)" }}>취소</a>}
      </div>
    </div>
  );

  if (forced) {
    // 앱 진입 전 전용 화면 — AuthScreen 과 동일 레이아웃
    return <div className="auth-wrap">{Card}</div>;
  }
  // 자발적 변경 — 반투명 오버레이 모달
  return (
    <div style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,0.4)", display: "grid", placeItems: "center", zIndex: 1000 }} onClick={onCancel}>
      <div onClick={e => e.stopPropagation()}>{Card}</div>
    </div>
  );
}

// ============================================
// CompaniesView (개선판)
// 기존 구조 유지, 디자인만 정리:
//  - 페이지 헤더 + 설명
//  - 상단 요약 칩 (회사 수 / 소속 사용자 합)
//  - 등록 인풋을 카드 상단 영역으로 분리 (company-add)
//  - 기존 user-table 스타일 재사용, 행 hover
//  - 빈 상태 보강
// 기존 app.jsx 의 CompaniesView 블록 통째로 교체
// ============================================

function CompaniesView({ companies, refresh, toast }) {
  const [name, setName] = _useS("");
  const [busy, setBusy] = _useS(false);

  const totalUsers = companies.reduce((s, c) => s + (c.user_count || 0), 0);

  const add = async () => {
    const n = name.trim();
    if (!n) return toast("회사명을 입력해 주세요", "rose");
    setBusy(true);
    try {
      await API.createCompany(n);
      setName("");
      await refresh();
      toast(`'${n}' 회사가 등록되었습니다`, "green");
    } catch (e) {
      toast(e.message || "등록 실패", "rose");
    } finally { setBusy(false); }
  };

  const remove = async (c) => {
    if (!window.confirm(`'${c.name}' 회사를 삭제하시겠습니까?`)) return;
    try {
      await API.deleteCompany(c.name);
      await refresh();
      toast(`'${c.name}' 회사가 삭제되었습니다`, "slate");
    } catch (e) {
      toast(e.message || "삭제 실패", "rose");
    }
  };

  return (
    <div className="settings-page">
      <div className="settings-head">
        <h2>회사 관리</h2>
        <p>프로젝트에 참여하는 수행사/협력사를 등록합니다. 여기에 등록된 회사만 회원가입 시 선택할 수 있습니다.</p>
      </div>

      <div className="admin-section">
        <h3>회사 목록 <span className="n gray">{companies.length}</span></h3>

        <div className="settings-summary">
          <div className="item"><span className="lbl">회사</span><span className="val">{companies.length}</span></div>
          <div className="item"><span className="lbl">소속 사용자</span><span className="val">{totalUsers}</span></div>
        </div>

        <div className="company-add">
          <input
            className="input"
            placeholder="회사명 입력 후 Enter 또는 등록"
            value={name}
            onChange={e => setName(e.target.value)}
            onKeyDown={e => e.key === "Enter" && !busy && add()}
          />
          <Btn kind="primary" onClick={add} disabled={busy || !name.trim()}>
            {busy ? "등록 중…" : "회사 등록"}
          </Btn>
          <span className="hint">소속 사용자가 있는 회사는 삭제할 수 없습니다</span>
        </div>

        {companies.length === 0 ? (
          <div className="admin-empty pad-lg">
            아직 등록된 회사가 없습니다.<br />
            위 입력창에 회사명을 적고 Enter 를 눌러 첫 회사를 등록해보세요.
          </div>
        ) : (
          <table className="user-table">
            <thead>
              <tr>
                <th style={{ width: "40%" }}>회사명</th>
                <th style={{ width: 130 }}>소속 사용자</th>
                <th style={{ width: 160 }}>등록일</th>
                <th>관리</th>
              </tr>
            </thead>
            <tbody>
              {companies.map(c => {
                const locked = c.user_count > 0;
                return (
                  <tr key={c.name}>
                    <td><b>{c.name}</b></td>
                    <td>
                      <span style={{
                        fontFamily: "var(--mono)",
                        color: locked ? "var(--ink-1)" : "var(--ink-4)",
                        fontWeight: locked ? 600 : 400,
                      }}>{c.user_count}명</span>
                    </td>
                    <td style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--ink-3)" }}>
                      {(c.created_at || "").slice(0, 10)}
                    </td>
                    <td>
                      <div className="user-actions">
                        <button className="danger"
                          onClick={() => remove(c)}
                          disabled={locked}
                          title={locked ? "소속 사용자가 있어 삭제할 수 없습니다" : "삭제"}>
                          삭제
                        </button>
                      </div>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        )}
      </div>
    </div>
  );
}

Object.assign(window, { AuthScreen, AdminUsersView, ProjectSettingsView, CompaniesView, ChangePasswordView, API, ROLE_LABEL });
// 메인 App

function App() {
  const [currentUser, setCurrentUser] = useState(null);
  const [authLoaded, setAuthLoaded] = useState(false);
  const [users, setUsers] = useState([]);
  const [requests, setRequests] = useState([]);
  const [project, setProject] = useState(PROJECT); // 초기 fallback, 마운트 후 서버값으로 대체
  const [companies, setCompanies] = useState([]);
  const isAdmin = currentUser?.role === "admin";

  const refreshCompanies = React.useCallback(async () => {
    try { const { companies } = await API.listCompanies(); setCompanies(companies || []); } catch { }
  }, []);

  useEffect(() => {
    API.getProject().then(({ project }) => { if (project) setProject(project); }).catch(() => { });
    refreshCompanies();
  }, [refreshCompanies]);

  const [view, setView] = useState(() => localStorage.getItem("req-view") || "dashboard");
  const [category, setCategory] = useState("ALL");
  const [filters, setFilters] = useState({ sort: "updated" });
  const [selectedId, setSelectedId] = useState(null);
  const [modalOpen, setModalOpen] = useState(false);
  const [currentUserId, setCurrentUserId] = useState(null);

  useEffect(() => { setCurrentUserId(currentUser?.id || null); }, [currentUser?.id]);

  // 최초 로드: 세션 복구
  useEffect(() => {
    API.me().then(({ user }) => { setCurrentUser(user); })
      .catch(() => { })
      .finally(() => setAuthLoaded(true));
  }, []);

  // 로그인 상태에서 사용자·요청 목록 동기화
  const refreshUsers = React.useCallback(async () => {
    if (!currentUser) return;
    try {
      const { users } = await API.listUsers();
      // findMember 가 즉시 이름을 찾을 수 있도록 setUsers 전에 글로벌 맵 동기화
      syncMembersFromDb(users);
      setUsers(users);
    } catch { }
  }, [currentUser]);
  const refreshRequests = React.useCallback(async () => {
    if (!currentUser) return;
    try { const { requests } = await API.listRequests(); setRequests(requests); } catch { }
  }, [currentUser]);
  useEffect(() => {
    if (!currentUser) { setUsers([]); setRequests([]); return; }
    refreshUsers();
    refreshRequests();
  }, [currentUser, refreshUsers, refreshRequests]);

  // 30초 주기 폴링 (탭이 보이는 상태에서만) — 새 요청/상태변경을 알림 뱃지에 반영
  useEffect(() => {
    if (!currentUser) return;
    const POLL_MS = 30_000;
    const tick = () => {
      if (document.visibilityState !== "visible") return;
      refreshRequests();
      refreshUsers();
    };
    const id = setInterval(tick, POLL_MS);
    // 탭 포커스 복귀 시 즉시 1회 재조회 (오래 방치된 상태 동기화)
    const onVis = () => { if (document.visibilityState === "visible") tick(); };
    document.addEventListener("visibilitychange", onVis);
    return () => {
      clearInterval(id);
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [currentUser, refreshRequests, refreshUsers]);

  const handleLogout = async () => {
    try { await API.logout(); } catch { }
    // 다른 사용자로 로그인해도 이전 UI 상태가 남지 않도록 전부 초기화
    setCurrentUser(null);
    setView("dashboard");
    setSelectedId(null);
    setCategory("ALL");
    setFilters(f => ({ sort: f?.sort || "updated" }));
    setModalOpen(false);
    setPwOpen(false);
    setTweakOpen(false);
  };
  const [tweaks, setTweaks] = useState({ density: "comfortable", accent: "neutral" });
  const [tweakOpen, setTweakOpen] = useState(false);
  const [pwOpen, setPwOpen] = useState(false);
  // 상태 전환 시 사유 입력 프롬프트 (Promise 기반으로 updateRequest 에서 await)
  const [reasonPromptState, setReasonPromptState] = useState(null); // { statusKey, resolve }
  const promptReason = (statusKey) => new Promise((resolve) => {
    setReasonPromptState({ statusKey, resolve });
  });
  const toast = useToast();

  useEffect(() => { localStorage.setItem("req-view", view); }, [view]);

  // Tweaks (edit-mode) host integration
  useEffect(() => {
    const h = (e) => {
      if (e.data?.type === "__activate_edit_mode") setTweakOpen(true);
      if (e.data?.type === "__deactivate_edit_mode") setTweakOpen(false);
    };
    window.addEventListener("message", h);
    window.parent.postMessage({ type: "__edit_mode_available" }, "*");
    return () => window.removeEventListener("message", h);
  }, []);

  // Filter logic
  const filtered = useMemo(() => {
    let list = requests;
    if (category !== "ALL") list = list.filter(r => r.category === category);
    if (filters.assignee) list = list.filter(r => r.assignee === filters.assignee);
    if (filters.priority) list = list.filter(r => r.priority === filters.priority);
    if (filters.status) list = list.filter(r => r.status === filters.status);
    if (filters.mine) list = list.filter(r => r.requester === currentUserId);
    if (filters.openOnly) list = list.filter(r => r.status === "progress");
    if (filters.q) {
      const q = filters.q.toLowerCase();
      list = list.filter(r =>
        r.id.toLowerCase().includes(q) ||
        r.title.toLowerCase().includes(q) ||
        r.summary.toLowerCase().includes(q) ||
        findMember(r.assignee).name.includes(filters.q) ||
        findMember(r.requester).name.includes(filters.q)
      );
    }
    const sort = filters.sort || "updated";
    const pOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
    list = [...list].sort((a, b) => {
      if (sort === "updated") return b.updatedAt.localeCompare(a.updatedAt);
      if (sort === "due") return a.dueAt.localeCompare(b.dueAt);
      if (sort === "priority") return pOrder[a.priority] - pOrder[b.priority];
      if (sort === "created") return b.requestedAt.localeCompare(a.requestedAt);
      return 0;
    });
    return list;
  }, [requests, category, filters, currentUserId]);

  // Sidebar counts
  const counts = useMemo(() => {
    const c = { ALL: requests.length };
    Object.keys(CATEGORIES).forEach(k => c[k] = requests.filter(r => r.category === k).length);
    STATUSES.forEach(s => c["st:" + s.key] = requests.filter(r => r.status === s.key).length);
    return c;
  }, [requests]);

  const updateRequest = async (id, patch) => {
    // 보류/반려/취소 전환은 사유가 필수 — 미입력 시 모달 프롬프트
    const REASON_STATUSES = new Set(["hold", "rejected", "cancelled"]);
    if (patch.status && REASON_STATUSES.has(patch.status) && !("reason" in patch)) {
      const reason = await promptReason(patch.status);
      if (!reason) return { cancelled: true };
      patch = { ...patch, reason };
    }
    // 로컬 optimistic update + 서버 반영 (comments 는 별도 처리)
    const { comments: newComments, ...rest } = patch;
    // 코멘트 배열이 통째로 온 경우: 마지막 항목만 서버에 추가
    if (newComments) {
      const prev = requests.find(r => r.id === id);
      const added = newComments.slice((prev?.comments?.length) || 0);
      for (const c of added) {
        try { await API.addComment(id, c.text); } catch (e) { toast(e.message, "rose"); }
      }
    }
    if (Object.keys(rest).length) {
      // updatedAt 은 서버에서 세팅하므로 제거
      delete rest.updatedAt;
      if (Object.keys(rest).length) {
        try { await API.patchRequest(id, rest); } catch (e) { toast(e.message, "rose"); return { error: e.message }; }
      }
    }
    await refreshRequests();
    return { ok: true };
  };

  const exportCsv = () => {
    if (!filtered.length) { toast("내보낼 데이터가 없습니다", "slate"); return; }
    const header = ["요청번호", "구분", "세부유형", "모듈", "제목", "요약", "우선순위", "상태", "요청자", "담당자", "요청일", "희망일", "최근수정"];
    const esc = (v) => {
      const s = v == null ? "" : String(v);
      return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
    };
    const statusLabel = (k) => STATUSES.find(s => s.key === k)?.label || k || "";
    const prioLabel = (k) => PRIORITIES.find(p => p.key === k)?.label || k || "";
    const catLabel = (k) => CATEGORIES[k]?.label || k || "";
    const lines = filtered.map(r => {
      const reqM = findMember(r.requester);
      const asnM = findMember(r.assignee);
      return [
        r.id, catLabel(r.category), r.subtype, r.module,
        r.title, r.summary,
        prioLabel(r.priority), statusLabel(r.status),
        reqM.name, asnM.name,
        r.requestedAt, r.dueAt,
        (r.updatedAt || "").slice(0, 19).replace("T", " "),
      ].map(esc).join(",");
    });
    // Excel 에서 한글 깨지지 않도록 UTF-8 BOM 앞에 붙임
    const csv = "﻿" + [header.map(esc).join(","), ...lines].join("\r\n");
    const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    const today = new Date().toISOString().slice(0, 10);
    a.href = url;
    a.download = `req-registry-${today}.csv`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
    toast(`${filtered.length}건을 CSV로 내보냈습니다`, "green");
  };

  const createRequest = async (payload, files = []) => {
    try {
      const { request } = await API.createRequest({
        category: payload.category,
        subtype: payload.subtype,
        module: payload.module,
        title: payload.title,
        summary: payload.summary,
        detail: payload.detail,
        priority: payload.priority,
        assignee: payload.assignee,
        dueAt: payload.dueAt,
        watchers: payload.watchers || [],
      });
      let failed = 0;
      for (const f of files) {
        try { await API.uploadAttachment(request.id, f); }
        catch { failed++; }
      }
      await refreshRequests();
      setModalOpen(false);
      setSelectedId(request.id);
      if (failed > 0) {
        toast(`요청 ${request.id} 등록 — 첨부 ${failed}개 업로드 실패`, "amber");
      } else {
        toast(`요청 ${request.id}가 등록되었습니다`, "green");
      }
    } catch (e) {
      toast(e.message || "요청 등록 실패", "rose");
    }
  };

  const selected = requests.find(r => r.id === selectedId);
  const showDetail = !!selected && (view === "list" || view === "dashboard" || view === "kanban" || view === "timeline");
  const contentRef = useRef(null);
  const modalOpenFlag = modalOpen || pwOpen || !!reasonPromptState;
  useEffect(() => {
    if (!showDetail || modalOpenFlag) return;
    const h = (e) => {
      if (contentRef.current && !contentRef.current.contains(e.target)) setSelectedId(null);
    };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, [showDetail, modalOpenFlag]);
  const isAdminView = view === "admin" || view === "project" || view === "companies";

  // View title
  const viewTitle = LayoutHelpers.VIEWS.find(v => v.key === view)?.label;

  // 세션 복구 중
  if (!authLoaded) return <div style={{ height: "100vh", display: "grid", placeItems: "center", color: "var(--ink-3)" }}>로드 중…</div>;
  if (!currentUser) {
    return <AuthScreen onLogin={(u) => setCurrentUser(u)} project={project} companies={companies} />;
  }
  if (currentUser.mustChangePassword) {
    // 강제 변경 완료 시 /api/auth/me 재조회 → 플래그 갱신되면 앱 진입
    return (
      <ChangePasswordView
        forced
        onDone={async () => {
          try {
            const { user } = await API.me();
            setCurrentUser(user);
          } catch { }
        }}
        onLogout={handleLogout}
      />
    );
  }

  return (
    <div className="app">
      <TopBar
        onNew={() => setModalOpen(true)}
        currentUserId={currentUserId}
        setCurrentUserId={setCurrentUserId}
        currentUser={currentUser}
        onLogout={handleLogout}
        project={project}
        onChangePassword={() => setPwOpen(true)}
        requests={requests}
        pendingUsers={users.filter(u => u.status === "pending")}
        onNavigate={(v) => setView(v)}
        onOpenRequest={(id) => {
          // 현재 뷰가 상세 패널을 같이 보여줄 수 있는 리스트 계열이면 유지,
          // 아니면 (대시보드/어드민/프로젝트 설정) 리스트 뷰로 이동
          if (!["list", "kanban", "timeline"].includes(view)) setView("list");
          // 필터가 걸려 선택 요청이 가려질 수 있으므로 카테고리/필터는 초기화 (sort 은 유지)
          setCategory("ALL");
          setFilters(f => ({ sort: f.sort }));
          setSelectedId(id);
        }}
      />
      <Sidebar view={view} setView={setView} category={category} setCategory={setCategory} counts={counts} isAdmin={isAdmin} pendingCount={users.filter(u => u.status === "pending").length} />
      <div className="main">
        <SubBar
          view={view}
          setView={setView}
          title={isAdminView ? null : (category !== "ALL" ? CATEGORIES[category].fullLabel : null)}
          right={isAdminView ? null : (
            <Btn kind="outline" icon={<Icon.sort />} onClick={exportCsv}>내보내기</Btn>
          )}
        />
        {view !== "dashboard" && !isAdminView && (
          <FilterBar filters={filters} setFilters={setFilters} currentUserId={currentUserId} resultCount={filtered.length} users={users} />
        )}
        <div className={"content " + (showDetail ? "with-detail" : "")} ref={contentRef}>
          <div className="content-main">
            {view === "dashboard" && <DashboardView requests={filtered} onOpen={setSelectedId} />}
            {view === "list" && <ListView requests={filtered} onOpen={setSelectedId} selectedId={selectedId} />}
            {view === "kanban" && <KanbanView requests={filtered} onOpen={setSelectedId} onStatusChange={(id, s) => updateRequest(id, { status: s })} currentUser={currentUser} />}
            {view === "timeline" && <TimelineView requests={filtered} onOpen={setSelectedId} />}
            {view === "admin" && isAdmin && <AdminUsersView users={users} refresh={refreshUsers} toast={toast} />}
            {view === "project" && isAdmin && <ProjectSettingsView project={project} onSaved={setProject} toast={toast} />}
            {view === "companies" && isAdmin && <CompaniesView companies={companies} refresh={refreshCompanies} toast={toast} />}
          </div>
          {showDetail && (
            <DetailPanel
              request={selected}
              onClose={() => setSelectedId(null)}
              onUpdate={updateRequest}
              onRefresh={refreshRequests}
              currentUser={currentUser}
              users={users}
            />
          )}
        </div>
      </div>

      {modalOpen && <NewRequestModal onClose={() => setModalOpen(false)} onCreate={createRequest} currentUser={currentUser} users={users} />}

      {pwOpen && (
        <ChangePasswordView
          onDone={() => { setPwOpen(false); toast("비밀번호가 변경되었습니다", "green"); }}
          onCancel={() => setPwOpen(false)}
        />
      )}

      {reasonPromptState && (
        <ReasonPromptModal
          statusKey={reasonPromptState.statusKey}
          onConfirm={(reason) => {
            const { resolve } = reasonPromptState;
            setReasonPromptState(null);
            resolve(reason);
          }}
          onCancel={() => {
            const { resolve } = reasonPromptState;
            setReasonPromptState(null);
            resolve(null);
          }}
        />
      )}

      {tweakOpen && (
        <div className="tweaks">
          <h4>
            Tweaks
            <span style={{ cursor: "pointer", color: "var(--ink-4)" }} onClick={() => setTweakOpen(false)}><Icon.close /></span>
          </h4>
          <div className="body">
            <label>
              <span>밀도</span>
              <select value={tweaks.density} onChange={e => {
                const v = e.target.value; setTweaks(t => ({ ...t, density: v }));
                document.documentElement.style.setProperty("--h-subheader", v === "compact" ? "36px" : "44px");
              }}>
                <option value="comfortable">Comfortable</option>
                <option value="compact">Compact</option>
              </select>
            </label>
            <label>
              <span>역할 컬러 강조</span>
              <select value={tweaks.accent} onChange={e => setTweaks(t => ({ ...t, accent: e.target.value }))}>
                <option value="neutral">은은하게</option>
                <option value="vivid">선명하게</option>
              </select>
            </label>
            <label>
              <span>샘플 데이터</span>
              <span style={{ color: "var(--ink-4)", fontSize: 11, fontFamily: "var(--mono)" }}>{requests.length}건</span>
            </label>
            <button className="btn btn-outline" style={{ display: "none" }} onClick={() => { }}>
              샘플로 초기화
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

function Root() {
  return <ToastProvider><App /></ToastProvider>;
}

ReactDOM.createRoot(document.getElementById("root")).render(<Root />);
