// 코드 값 → 한국어 라벨, 값 형식 도우미 (모든 화면 공통)
//
// 기준: 용어 통일안 3절·4절 (2026-09-17). 화면마다 라벨 맵을 따로 만들지 말고
// 여기 있는 표만 쓰세요. 서버 enum 이름은 주석에 적었습니다(yumyumpickup common-util).
//
// 서버에 없는 개념(환불·정산·매니저·신고)의 코드는 아래쪽 "서버 없음" 절에 있습니다.

// ---------------------------------------------------------------- 서버 enum

// 매장 영업상태 store.openStatus (StoreOpenStatusEnum)
const OPEN_STATUS = {
  OPEN:    { label: '영업중',   tone: 'success', dot: 'success' },
  BREAK:   { label: '휴게시간', tone: 'warn',    dot: 'warn' },
  CLOSED:  { label: '영업종료', tone: 'neutral', dot: 'neutral' },
  HOLIDAY: { label: '휴무',     tone: 'neutral', dot: 'neutral' },
  PAUSED:  { label: '일시정지', tone: 'danger',  dot: 'danger' },   // 사장님 일시정지 (서버 구현 예정)
};

// 사장님 운영 상태 store.operationStatus (서버 구현 예정, operation-status-design.md)
// operationStatusUntil: 'yyyy-MM-dd HH:mm' 또는 null. NORMAL 이면 항상 null.
const OPERATION_STATUS = {
  NORMAL: { label: '보통',     tone: 'neutral' },
  BUSY:   { label: '바쁨',     tone: 'warn' },
  PAUSED: { label: '일시정지', tone: 'danger' },
};
// 보통이 아닐 때만 "바쁨 · 12:30까지" 같은 문구. 보통·값 없음이면 ''.
const operationStatusText = (s) => {
  const op = OPERATION_STATUS[s && s.operationStatus];
  if (!op || s.operationStatus === 'NORMAL') return '';
  return s.operationStatusUntil ? `${op.label} · ${fmtDT(s.operationStatusUntil)}까지` : `${op.label} · 해제할 때까지`;
};

// 메뉴 판매상태 menu.saleStatus (SaleStatusEnum)
const SALE_STATUS = {
  SALE:    { label: '판매중', tone: 'success' },
  SOLDOUT: { label: '품절',   tone: 'warn' },
  HIDDEN:  { label: '숨김',   tone: 'neutral' },
};

// 검수 상태 — store.status / store_image.status / menu_image.status / category.status
// (CommonStatusEnum). 리뷰 상태(REVIEW_STATUS)와 다른 표입니다.
const INSPECTION_STATUS = {
  AWAIT:    { label: '검수대기', tone: 'warn' },
  APPROVAL: { label: '승인',     tone: 'success' },
  REJECT:   { label: '반려',     tone: 'danger' },
  DELETE:   { label: '삭제',     tone: 'neutral' },
};

// 사장님 민감 서류 — merchant_document.status / .prev_status
// 서류는 덮어쓰지 않고 쌓입니다. 새 사본이 올라오면 같은 종류의 이전 행이 전부 REPLACED 가 되므로
// **REPLACED 가 아닌 행은 종류당 정확히 하나**이고 그것이 "현재 서류"입니다.
const DOCUMENT_STATUS = {
  SUBMITTED: { label: '심사대기', tone: 'warn' },
  APPROVED:  { label: '승인',     tone: 'success' },
  REJECTED:  { label: '반려',     tone: 'danger' },
  REPLACED:  { label: '교체됨',   tone: 'neutral' },
};

// 서류 종류 — merchant_document.doc_type
const DOC_TYPE = {
  BANKBOOK:         { label: '통장 사본' },
  BUSINESS_LICENSE: { label: '사업자등록증' },
};
const docTypeLabel = (t) => (DOC_TYPE[t] ? DOC_TYPE[t].label : t || EMPTY_VALUE);

// 교체된 서류가 **교체되기 전에** 무엇이었는지. 서버가 prevStatus 로 알려 줍니다(V34).
// 값이 없으면 키가 아예 없으므로(MyBatis Map) 값으로 비교합니다.
// ※ reviewedAt·reviewReason 유무로 추론하지 않습니다 — 그 방식은 "승인에는 사유를 쓰지 않는다"는
//   규칙에 기대고, 그 규칙이 깨지는 날 승인된 사본이 조용히 "이전에 반려"로 그려집니다.
const docPrevLabel = (prevStatus) => {
  if (prevStatus === 'APPROVED') return '이전에 승인';
  if (prevStatus === 'REJECTED') return '이전에 반려';
  if (prevStatus === 'SUBMITTED') return '심사 전';
  return '';
};

// 서류 열람 사유 — 자주 쓰는 것을 버튼으로 고르게 합니다(리더 결정 2026-09-26).
// 매번 문장을 치게 하면 운영자가 아무 글자나 치고, 그러면 기록이 있어도 쓸모가 없습니다.
// **기본 선택은 없습니다.** 비우고 열 수도 있지만 한 단계 더 눌러야 합니다 — 비우는 것이
// 기본 경로가 되면 선택이 아니라 사실상 없는 것입니다.
// 고정 문자열로 보냅니다. 나중에 "입점 심사 때문에 열린 것이 몇 건인가"를 셀 수 있게요.
const DOC_VIEW_REASONS = ['입점 심사', '정산 계좌 확인', '사장님 문의 확인'];

// 파일 크기: 서버 byteSize 를 사람이 읽는 값으로. 업로드 상한은 한 파일 5MB 입니다.
const fmtBytes = (n) => {
  if (n == null) return EMPTY_VALUE;
  if (n < 1024) return `${n}B`;
  if (n < 1024 * 1024) return `${Math.round(n / 1024)}KB`;
  return `${(n / 1024 / 1024).toFixed(1)}MB`;
};

// 계정 상태 — user_info / merchant_info / admin_info .status (UserStatusEnum)
const ACCOUNT_STATUS = {
  ACTIVE:   { label: '정상',             tone: 'success', dot: 'success' },
  INACTIVE: { label: '휴면',             tone: 'neutral', dot: 'neutral' },
  BLOCKED:  { label: '정지',             tone: 'danger',  dot: 'danger' },
  DELETE:   { label: '탈퇴',             tone: 'neutral', dot: 'neutral' },
  PWDRESET: { label: '비밀번호 초기화',  tone: 'info',    dot: 'neutral' },
  FIRST:    { label: '최초 로그인 전',   tone: 'info',    dot: 'neutral' },
};

// 주문상태 orders_status.status (OrderStatusEnum)
// 취소 코드의 접미사 _M(사장님)/_U(사용자)/_A(어드민) 는 취소 주체입니다(actor).
const ORDER_STATUS = {
  CRTE:            { label: '결제전',     tone: 'neutral', dot: 'neutral' },
  PAY_CMPL:        { label: '주문확인중', tone: 'neutral', dot: 'neutral' },
  COOKING:         { label: '조리중',     tone: 'warn',    dot: 'warn' },
  PK_RDY:          { label: '픽업대기',   tone: 'info',    dot: 'success' },
  PK_CMPL:         { label: '픽업완료',   tone: 'success', dot: 'success' },
  // REQ 는 "취소는 요청됐지만 돈이 아직 안 돌아간" 주문입니다(terms.md 3절, 2026-09-22 User#35 · yumyumpickup#165).
  // 환불이 성공하면 서버가 곧바로 CMPL 까지 적으므로, REQ 로 남은 주문은 환불이 실패했거나 PG 응답을 못 받은 것 —
  // 운영자가 확인할 대상입니다. 예전 라벨 "취소요청"은 "요청이 들어와 처리 중"으로 읽혀 뜻이 맞지 않았습니다.
  ORD_CNCL_REQ_M:  { label: '환불 처리 중', tone: 'warn',    dot: 'warn',   actor: '사장님' },
  ORD_CNCL_REQ_U:  { label: '환불 처리 중', tone: 'warn',    dot: 'warn',   actor: '사용자' },
  ORD_CNCL_REQ_A:  { label: '환불 처리 중', tone: 'warn',    dot: 'warn',   actor: '어드민' },
  ORD_CNCL_CMPL_M: { label: '주문취소',   tone: 'danger',  dot: 'danger', actor: '사장님' },
  ORD_CNCL_CMPL_U: { label: '주문취소',   tone: 'danger',  dot: 'danger', actor: '사용자' },
  ORD_CNCL_CMPL_A: { label: '주문취소',   tone: 'danger',  dot: 'danger', actor: '어드민' },
  // 배치가 닫은 만료 주문도 이 상태로 옵니다. 문구는 failCode 로 갈립니다 — orderStatusView 참조.
  PAY_FAIL:        { label: '결제실패',   tone: 'danger',  dot: 'danger' },
};
const isCancelRequest  = (code) => /^ORD_CNCL_REQ_/.test(code || '');
const isCancelComplete = (code) => /^ORD_CNCL_CMPL_/.test(code || '');

// 주문 상태 행을 **누가 썼는지**(orders_status.reg_from). 2026-10-04 dev 전수: USER 234 · MERCHANT 111 ·
// BATCH 58 · ADMIN 10 — 이 넷뿐입니다(ServiceTypeEnum 에 ASSIST 도 있으나 주문 상태엔 0건).
// 상태코드만으로는 자동 취소와 운영자 취소를 구분할 수 없어(둘 다 ORD_CNCL_*_A) 이 값이 필요합니다.
const REG_FROM = { USER: '손님', MERCHANT: '사장님', ADMIN: '운영자', BATCH: '시스템' };
// 모르는 값이 오면 **지어내지 않고 원문을 그대로** 보여 줍니다. 값이 없으면 null (화면이 칸을 비웁니다).
const regFromLabel = (v) => (v ? (REG_FROM[v] || v) : null);
// ⚠️ dev 에 **라벨이 틀리는 행이 3건** 있습니다(주문 23·41·48, 2026-09-20~21).
// 옛 빌드가 취소 완료행을 BATCH/reg_id=NULL 로 써서, 요청행은 ADMIN(운영자가 취소)인데
// 완료행만 BATCH 로 남았습니다. 지금 코드는 어드민 취소의 완료행을 ADMIN 으로 쓰므로
// **새로 쌓이는 데이터는 정확합니다**(2026-10-04 backend 확인). 옛 데이터라 고치지 않습니다.

// 환불 상태 — 멈춤 목록 행 (backend #322). 취소가 걸린 뒤 환불(PG 취소)이 어떻게 됐는지.
//   RETRYING        : 환불이 아직 안 끝났고 배치가 다시 시도함(일시). 운영자는 기다리면 됩니다.
//   MANUAL_REQUIRED : 재시도해도 안 되는 토스 거절 코드(영구). 사람이 PG 콘솔에서 처리해야 합니다.
//   NONE / 그 밖     : 뱃지 없음(환불 실패가 없거나 성공).
// 🔴 MANUAL_REQUIRED 판정은 서버 설정(refundManualFailCodes)의 **코드 집합으로만** 합니다 —
//    환불 "기간"이 아니라 토스가 돌려준 거절 코드로. 그 집합에 없는 코드는 전부 RETRYING 으로 옵니다
//    (모르는 코드를 "영구"로 단정하지 않으려고). 그래서 화면은 두 상태 모두에 refundFailCode 원문을
//    같이 보여 줍니다 — 어떤 코드가 실제로 영구인지 운영 데이터로 드러나게(새 영구 코드가 보이면 backend 설정에 추가).
const REFUND_STATE = {
  RETRYING:        { label: '환불 재시도 중', tone: 'warn' },
  MANUAL_REQUIRED: { label: '환불 불가 · CS 수동', tone: 'danger' },
};
// 행 하나의 환불 표시({label, tone}) 또는 null. 서버가 refundState 를 아직 안 주면(배포 전)
// 옛 refundFailed 로 물러섭니다 — 한쪽만 바뀌어도 화면이 조용히 비지 않게.
const refundView = (o) => {
  if (!o) return null;
  if (o.refundState) return REFUND_STATE[o.refundState] || null;   // NONE → 뱃지 없음
  return o.refundFailed ? { label: '환불 실패', tone: 'danger' } : null;
};

// 멈춘 시간(분) → 사람이 읽는 길이. 서버가 센 값을 그대로 받습니다(화면이 시계를 믿지 않습니다).
const fmtStuck = (minutes) => {
  const m = Number(minutes);
  if (!Number.isFinite(m) || m < 0) return EMPTY_VALUE;
  if (m < 60) return `${Math.floor(m)}분`;
  const h = Math.floor(m / 60);
  if (h < 24) return `${h}시간`;
  const d = Math.floor(h / 24);
  const rest = h % 24;
  return rest ? `${d}일 ${rest}시간` : `${d}일`;
};
// 돈이 오간 적 없는 주문 — 결제전(CRTE)·결제실패(PAY_FAIL, 만료 포함). 금액 칸을 비웁니다.
const isUnpaidOrder = (code) => code === 'CRTE' || code === 'PAY_FAIL';
// 결제 시도 — 돈이 오간 적 없는 주문(결제전·결제실패·만료). 사용자 결정(2026-09-22, A 안): 기본 목록·대시보드·
// 검색에서 빼고, 운영자가 결제 문제를 추적할 때만 "결제 시도" 필터로 봅니다. 그래서 "주문 시간 만료" 라벨도
// 이제 그 필터 안에서만 보입니다. 서버 statuses 로 거릅니다(화면에서 한 페이지만 거르면 틀린 목록).
const PAYMENT_ATTEMPT_STATUSES = ['CRTE', 'PAY_FAIL'];
// 기본 조회에 넣는 상태 = 결제 시도를 뺀 나머지 전부. 서버가 기본값에서 둘을 빼 주더라도(backend 예정) 명시해 보냅니다.
const ORDER_STATUSES_DEFAULT = Object.keys(ORDER_STATUS).filter((c) => !PAYMENT_ATTEMPT_STATUSES.includes(c));

// 결제 실패 사유 payment.fail_code — enum 이 아니라 varchar(50) 입니다.
// PG 가 준 코드(PAY_PROCESS_CANCELED 등)가 그대로 들어오기도 하고, 서버가 직접 적는 값도 있습니다.
// **주의: 두 층이 다릅니다.** payment-contract.md 4-1 절 표는 *결제 행*의 fail_code 목록이고,
// 주문 API(`/admin/orders/...`)에 실리는 것은 서버가 주문 단위로 한 번 더 고른 값이라
// 값 집합이 같지 않습니다. 아래는 **주문 단위로 실제로 나올 수 있는 값**입니다
// (backend 확정 2026-09-21, 라벨 문구도 그쪽 확정안):
//
//   ORDER_EXPIRED   → 주문 상태 PAY_FAIL
//   PG 코드          → 주문 상태 PAY_FAIL, 또는 재시도 전 CRTE
//   CANCEL_PENDING  → 주문 상태 ORD_CNCL_REQ_* · 이후 ORD_CNCL_CMPL_*
//   NOT_FOUND_PAYMENT → 주문 상태 CRTE 뿐. 만료되면 ORDER_EXPIRED 로 덮입니다
//   SUPERSEDED_BY_PAYMENT → **주문 단위로는 나오지 않습니다.** 그래서 여기 없습니다.
//     (4-1 표에는 있지만 결제 행 선택에서 결말 난 행이 늘 이기므로 올라올 경로가 없습니다.
//      운영자가 영영 안 뜨는 라벨을 기다리지 않도록 일부러 뺐습니다.)
//
// needsAction 은 운영자가 손댈 것이 있는지입니다. CANCEL_PENDING 하나만 true 입니다 —
// 돈이 아직 안 돌아갔을 수 있어 사람이 확인해야 합니다. 이 구분이 이 표의 요점입니다.
const PAYMENT_FAIL_CODE = {
  ORDER_EXPIRED: {
    label: '주문 시간 만료', tone: 'neutral', needsAction: false,
    desc: '결제하지 않고 30분이 지나 배치가 주문을 닫았습니다. 정상적인 정리입니다.',
  },
  NOT_FOUND_PAYMENT: {
    label: 'PG 에 결제 기록 없음', tone: 'neutral', needsAction: false,
    desc: '정리 배치가 PG 에 물었더니 "그런 결제가 없다"고 확정했습니다. 승인이 시작된 적 없어, 사용자가 같은 주문으로 다시 결제할 수 있습니다.',
  },
  // 라벨은 terms.md 3절 "환불 처리 중(REQ)의 사유 문구"(2026-09-22, User-yumyum#36).
  CANCEL_PENDING: {
    label: '환불 확인 필요', tone: 'danger', needsAction: true,
    desc: 'PG 취소를 불렀는데 응답을 받지 못했습니다. 돈이 돌아갔는지 모릅니다 — PG 대시보드에서 확인해야 합니다.',
  },
};
// 표에 없는 코드는 PG 가 준 값입니다(dev 에서 PAY_PROCESS_CANCELED · NEED_CARD_PAYMENT_DETAIL 확인).
// 원문 코드는 화면에서 배지 옆에 따로 보여 주고, 사유 문장은 지어내지 말고 서버가 준 failMessage 에 맡깁니다.
// 배지 문구는 맥락에 따라 다릅니다(terms.md 3절):
// - 결제 맥락(PAY_FAIL 등): "결제 실패"
// - 환불 처리 중(ORD_CNCL_REQ_*) 맥락: "환불 실패" — PG 가 취소를 거절해 돈이 아직 가맹점에 있습니다.
//   결제는 성공했고 환불이 안 된 것이라 "결제 실패"라고 쓰면 틀립니다. 운영자가 손댈 일이라 needsAction.
// 값이 없으면 null — 화면에서 줄 자체가 사라집니다.
const REFUND_FAILED_VIEW = {
  label: '환불 실패', tone: 'danger', needsAction: true,
  desc: 'PG 가 취소를 거절했습니다. 돈은 아직 가맹점에 있습니다 — PG 대시보드와 사유를 확인해야 합니다.',
};
const failCodeView = (failCode, orderStatus) => {
  if (!failCode) return null;
  const known = PAYMENT_FAIL_CODE[failCode];
  if (known) return { ...known, known: true };
  if (isCancelRequest(orderStatus)) return { ...REFUND_FAILED_VIEW, known: false };
  return { label: '결제 실패', tone: 'danger', needsAction: false, desc: '', known: false };
};

// 만료된 주문과 진짜 결제 실패는 **같은 상태값(PAY_FAIL)** 이고 failCode 로만 갈립니다.
// 사용자 결정(2026-09-21): 상태값을 새로 만들지 않고 문구만 바꿉니다.
const isExpiredOrder = (code, failCode) => code === 'PAY_FAIL' && failCode === 'ORDER_EXPIRED';

// 주문 상태 배지에 쓸 { label, tone, dot }. "주문취소 (사장님)" 처럼 주체까지 붙습니다.
// failCode 는 선택입니다 — 서버가 주지 않으면(undefined) 기존 라벨 그대로입니다.
const orderStatusView = (code, failCode) => {
  const s = ORDER_STATUS[code];
  if (!s) return { label: code || '—', tone: 'neutral' };
  if (isExpiredOrder(code, failCode)) {
    const e = PAYMENT_FAIL_CODE.ORDER_EXPIRED;
    return { label: e.label, tone: e.tone, dot: 'neutral' };
  }
  return { label: s.actor ? `${s.label} (${s.actor})` : s.label, tone: s.tone, dot: s.dot };
};
const orderStatusLabel = (code, failCode) => orderStatusView(code, failCode).label;

// 결제 상태 payment.status (PaymentStatusEnum). 주문 상세의 paymentStatus 에는
// 결제 전이면 null 이 옵니다(값을 지어내지 말고 비워 둡니다).
const PAYMENT_STATUS = {
  PAY_RDY:  { label: '결제준비', tone: 'neutral' },
  PAY_REQ:  { label: '결제요청', tone: 'warn' },
  PAY_CMPL: { label: '결제완료', tone: 'success' },
  PAY_FAIL: { label: '결제실패', tone: 'danger' },
  PAY_CNCL: { label: '결제취소', tone: 'neutral' },
};

// 픽업방식 orders.takeOutInStore (TakeOutInStoreEnum)
const TAKEOUT_INSTORE = {
  T: { label: '포장',     tone: 'brand' },
  S: { label: '매장식사', tone: 'info' },
};

// 리뷰 user_review.status (ReviewStatusEnum)
// INACTIVE 는 "숨김"이 아니라 **"비공개"** 로 적습니다(사용자 결정 2026-09-30, terms.md).
// yumyumpickup#282(2026-09-29 배포)부터 비공개 처리된 리뷰가 **작성자 본인에게는** 사유와 함께
// 보입니다. 본인 눈에 보이는 글을 "숨김"이라 부르면 거짓이고, 달라지는 것은 남에게 안 보인다는
// 점이라 "비공개"가 사실에 맞습니다. 손님 앱도 `[비공개 처리됨]` 으로 그립니다.
// **메뉴 판매상태 SALE_STATUS.HIDDEN 의 "숨김"은 그대로입니다** — 메뉴는 정말로 아무에게도
// 보이지 않습니다. 같은 낱말이지만 다른 것이니 한꺼번에 찾아 바꾸지 마세요.
const REVIEW_STATUS = {
  ACTIVE:   { label: '노출',   tone: 'success' },
  INACTIVE: { label: '비공개', tone: 'neutral' },
};
// 리뷰 좋아요/싫어요 user_review.feedbackType (FeedbackTypeEnum) — 라벨은 서버 enum 의 이름 그대로
const FEEDBACK_TYPE = {
  UP:   { label: '좋아요', tone: 'success' },
  DOWN: { label: '싫어요', tone: 'danger' },
};
// 리뷰의 메뉴별 좋아요/싫어요 — feedbacks: [{menuId, menuName, feedbackType}] (yumyumpickup#170, 2026-09-23).
// 새 리뷰는 최상위 menuId·menuName·feedbackType 이 null 이고 feedbacks 에만 있습니다. 옛 리뷰도 feedbacks 에 한 건이
// 실려 오므로 늘 feedbacks 를 읽고, 비어 있을 때만 최상위 값으로 한 건을 만듭니다(배포 전 응답 대비).
const reviewFeedbacks = (r) => {
  if (r && Array.isArray(r.feedbacks) && r.feedbacks.length) return r.feedbacks;
  return r && (r.menuName || r.feedbackType) ? [{ menuId: r.menuId, menuName: r.menuName, feedbackType: r.feedbackType }] : [];
};
// 목록 한 칸용: "착즙 오렌지주스 · 훈제연어 샌드위치"
const reviewMenuNames = (r) => reviewFeedbacks(r).map((f) => f.menuName).filter(Boolean).join(' · ');

// 리뷰 삭제요청 user_review_request_remove.requestStatus (ReviewRequestStatusEnum)
// INACTIVE = 사장님이 삭제를 요청한 상태(처리 전).
const REVIEW_REMOVE_REQUEST_STATUS = {
  INACTIVE: { label: '요청됨', tone: 'warn' },
  ACCEPT:   { label: '승인',   tone: 'success' },
  REJECT:   { label: '반려',   tone: 'danger' },
};

// 가입 방식 user_auth.authType (AuthTypeEnum). terms.md 에 표가 없어 서비스 이름을 그대로 씁니다.
const AUTH_TYPE = {
  YUMYUM: { label: '이메일' },
  KAKAO:  { label: '카카오' },
  NAVER:  { label: '네이버' },
  APPLE:  { label: '애플' },
};

// 탈퇴 사유 (backend #183). NONE 은 사유를 고르지 않은 건입니다.
// 자유 입력(reasonText)은 개인정보가 섞일 수 있어 서버가 통계로 내보내지 않습니다.
const WITHDRAWAL_REASON = {
  NOT_USING:   '안 써서',
  FEW_STORES:  '주변 매장이 적어서',
  PRICE:       '가격',
  APP_ERROR:   '앱 오류',
  PRIVACY:     '개인정보 걱정',
  OTHER:       '기타',
  NONE:        '미입력',
};

// 입점 신청 상태 (2026-09-23 신설, 파트너스 → 어드민). 라벨은 리더 지시 문구이고 terms.md 3절 등재를 backend 에 요청함.
// 계약: yumyumpickup#171.
const APPLICATION_STATUS = {
  RECEIVED:  { label: '접수',      tone: 'warn' },
  CONTACTED: { label: '연락함',    tone: 'info' },
  DONE:      { label: '입점 완료', tone: 'success' },
  REJECTED:  { label: '반려',      tone: 'danger' },
};
// 입점 신청 경로(source) — backend #171 확인: 두 값뿐입니다.
const APPLICATION_SOURCE = { PARTNERS: '파트너스', PORTAL: '관리 웹' };
// 화면이 제안하는 다음 상태(서버가 최종 판단 — 허용 안 되는 전이는 업무 코드로 거절). 끝 상태는 빈 배열.
const APPLICATION_NEXT = {
  RECEIVED:  ['CONTACTED', 'REJECTED'],
  CONTACTED: ['DONE', 'REJECTED'],
  DONE:      [],
  REJECTED:  [],
};

// 약관·정책 판 상태 (V18, yumyumpickup#173). 서버가 요청마다 한국 날짜로 판정합니다.
// CURRENT 는 종류마다 최대 1개. UPCOMING 과 CURRENT 는 함께 있을 수 있습니다.
const TERMS_STATUS = {
  DRAFT:    { label: '작성 중',   tone: 'warn' },
  UPCOMING: { label: '시행 예정', tone: 'info' },
  CURRENT:  { label: '시행 중',   tone: 'success' },
  PAST:     { label: '지난 판',   tone: 'neutral' },
};
// 문서 대상(types.audience)
const TERMS_AUDIENCE = { MERCHANT: '사장님', USER: '사용자', COMMON: '공통', TEST: '검증용' };

// 정산 회차 상태 (backend V25). 회차는 가게 단위(가게 하나 × 한 주)입니다.
const SETTLEMENT_STATUS = {
  SCHEDULED: { label: '정산 예정', tone: 'warn' },
  CONFIRMED: { label: '확정',      tone: 'info' },
  PAID:      { label: '지급 완료', tone: 'success' },
  HOLD:      { label: '보류',      tone: 'danger' },
  FAILED:    { label: '지급 실패', tone: 'danger' },
};
// 화면이 제안하는 다음 상태(서버가 최종 판단 — 허용 안 되면 업무 코드로 거절).
// 지급 완료(PAID)는 끝 상태입니다. 되돌리려면 서버 쪽 정정이 필요합니다.
const SETTLEMENT_NEXT = {
  SCHEDULED: ['CONFIRMED', 'HOLD'],
  CONFIRMED: ['PAID', 'HOLD', 'FAILED'],
  HOLD:      ['CONFIRMED', 'FAILED'],
  FAILED:    ['CONFIRMED', 'HOLD'],
  PAID:      [],
};
const SETTLEMENT_TABS = [
  { value: '', label: '전체' },
  ...Object.keys(SETTLEMENT_STATUS).map((k) => ({ value: k, label: SETTLEMENT_STATUS[k].label })),
];
// 은행 코드 → 이름. 서버 표(/admin/settlement/banks)가 우선이고, 못 불러왔을 때만 화면 상수를 씁니다.
// 어느 쪽에도 없는 코드는 코드를 그대로 보여 줍니다(없는 은행 이름을 지어내지 않습니다).
// 서버 표를 쓰는 이유: 화면 상수는 6종뿐이고 이름도 어긋났습니다(004 KB국민은행 → 서버 "국민은행",
// 011 NH농협은행 → "농협은행"). 031 은 실제 사명이 바뀌어(대구은행 → iM뱅크(대구)) 옛 이름으로는 못 찾습니다.
const bankLabel = (code, serverMap) => {
  if (!code) return '';
  if (serverMap && serverMap[code]) return serverMap[code];
  return (BANK_NAME[code] || {}).label || BANK_NAME[code] || code;
};

// 어드민 역할 admin_info.role (AdminRoleEnum)
const ADMIN_ROLE = {
  ADMIN:   { label: '전체관리자',   tone: 'brand' },
  SERVICE: { label: '서비스관리자', tone: 'info' },
  STORE:   { label: '매장관리자',   tone: 'neutral' },
};

// 1:1 문의 상태 user_counsel.cs_status (CsStatusEnum)
// 서버가 바꾸는 전이: 어드민 답변 등록 시 WAIT → COMPLETE 하나뿐입니다.
//
// ⚠️ DELETE 가 **없는 것이 맞습니다.** 손님의 삭제는 상태가 아니라 user_counsel.is_delete 가
//    따로 들고, 응답도 isDelete 로 줍니다(V44 · yumyumpickup#295·#298, 2026-10-01 배포).
//    서버의 CsStatusEnum 에서도 DELETE 가 지워져 **csStatus=DELETE 로 검색하면 2103** 입니다
//    (실측). 화면은 isDelete 로 그립니다 — page-counsel.jsx 의 csDeleted() 를 보세요.
//
// 🔴 **값만 되살리지 마세요.** 되살리면 서버 exists() 는 통과하지만 어떤 행도 그 값을 갖지 않아
//    **조용히 0건**이 옵니다(backend javadoc). 터지지 않고 틀리는 쪽이라 더 나쁩니다.
const CS_STATUS = {
  WAIT:     { label: '답변대기', tone: 'warn' },
  PROCESS:  { label: '처리중',   tone: 'info' },
  HOLD:     { label: '보류',     tone: 'neutral' },
  COMPLETE: { label: '답변완료', tone: 'success' },
};
// 1:1 문의 유형 user_counsel.cs_category (CsCategoryEnum)
const CS_CATEGORY = {
  NORMAL:        { label: '일반' },
  ORDER:         { label: '주문' },
  CANCEL_REFUND: { label: '취소/환불' },
  COUPON_POINT:  { label: '쿠폰/포인트' },
  MENU:          { label: '메뉴' },
  MEMBER:        { label: '사용자 정보 관련' },
  ETC:           { label: '기타' },
};

// 은행 코드 store_bank.bankCode (BankCodeEnum — 목에 나오는 것만)
const BANK_NAME = {
  '004': 'KB국민은행', '011': 'NH농협은행', '020': '우리은행',
  '088': '신한은행', '090': '카카오뱅크', '092': '토스뱅크',
};

// ---------------------------------------------------------------- 서버 없음

// 환불 상태 — 서버 없음. 주문상태의 "주문취소"와 다른 개념입니다.
const REFUND_STATUS = {
  // 상담사가 매장 귀책으로 사장님께 승인 요청, 사장님 앱에서 승인 대기
  MERCHANT_PENDING: { label: '사장님대기', tone: 'warn' },
  // 사장님이 회사(플랫폼)에 부담 요청 → 회사 상담사가 검토
  COMPANY_REVIEW:   { label: '회사검토',   tone: 'info' },
  // 이슈가 있어 잠시 멈춰둔 건
  HOLD:             { label: '보류',       tone: 'neutral' },
  // 환불 처리 완료 (자동/승인 후 자동 모두 포함)
  COMPLETED:        { label: '환불완료',   tone: 'success' },
  // 어드민이 요청 자체를 거둔 종결 상태 (환불 안 함)
  WITHDRAWN:        { label: '요청철회',   tone: 'neutral' },
};
// 환불 귀책 — 서버 없음
const REFUND_FAULT = {
  STORE:    { label: '매장 귀책',   desc: '조리 실수, 픽업 메뉴 누락, 이물질 등' },
  PLATFORM: { label: '플랫폼 귀책', desc: '시스템 오류, 결제 이슈 등' },
};
// 환불 요청 출처 — 서버 없음
const REFUND_SOURCE = {
  ADMIN:    { label: '상담사' },
  MERCHANT: { label: '사장님 앱' },
};
// 환불 범위 — 서버 없음
const REFUND_SCOPE = {
  FULL:    { label: '전체 환불' },
  PARTIAL: { label: '부분 환불' },
};
// 요청인 역할 — 서버 없음 (매니저 개념이 서버에 없음)
const REQUESTER_ROLE = {
  MERCHANT: { label: '사장님', tone: 'brand' },
  MANAGER:  { label: '매니저', tone: 'info' },
};

// ---------------------------------------------------------------- 어드민 변경 이력 (yumyumpickup#287)
//
// GET /admin/audit/list 의 action · targetType 라벨입니다. **이 표가 "이력이 남는 것"의 전체
// 목록입니다** — 서버 AdminAuditActionEnum 과 하나씩 짝입니다(2026-09-30 그 파일을 읽고 옮겼습니다).
//
// 🔴 1단계 범위입니다. 여기 없는 도메인은 **아직 이력을 남기지 않습니다** —
//    정산 · 쿠폰 · 주문 · 가게 · 메뉴 · 이미지 · 카테고리 · 어드민 계정 · 약관 나머지.
//    그 도메인으로 조회해 비어 있는 것은 "조작이 없었다"는 뜻이 아니라 "아직 안 남는다"는 뜻이라,
//    화면이 그 말을 먼저 합니다(AUDIT_UNTRACKED_TEXT). 서버가 2단계를 붙이면 이 표에 더하세요.
//
// detail 은 서버 AdminAuditDetailPolicy 입니다 — before/after 에 무엇까지 담는지.
//   full    어드민이 쓴 값 그대로(FAQ·공지 제목·내용, 금칙어, 약관 초안)
//   status  상태값 또는 바뀐 칸 이름만. 남의 데이터라 값을 담지 않습니다
//   none    두 칸이 늘 빕니다. **담을 수 있는 것이 개인정보뿐**이라 일부러 비웁니다
//           → 화면이 빈 칸을 결함처럼 보이지 않게 이유를 적습니다
const AUDIT_ACTION = {
  FAQ_MODIFY:                 { label: 'FAQ 수정',            tone: 'info',    detail: 'full' },
  FAQ_DELETE:                 { label: 'FAQ 삭제',            tone: 'danger',  detail: 'full' },
  FAQ_RESTORE:                { label: 'FAQ 복구',            tone: 'success', detail: 'full' },
  USER_NOTICE_MODIFY:         { label: '손님 공지 수정',       tone: 'info',    detail: 'full' },
  USER_NOTICE_DELETE:         { label: '손님 공지 삭제',       tone: 'danger',  detail: 'full' },
  MERCHANT_NOTICE_MODIFY:     { label: '사장님 공지 수정',     tone: 'info',    detail: 'full' },
  MERCHANT_NOTICE_DELETE:     { label: '사장님 공지 삭제',     tone: 'danger',  detail: 'full' },
  REVIEW_STATUS_CHANGE:       { label: '리뷰 상태 변경',       tone: 'danger',  detail: 'status' },
  MERCHANT_APPLICATION_STATUS:{ label: '입점신청 상태 변경',   tone: 'info',    detail: 'status' },
  MERCHANT_ACCOUNT_ISSUE:     { label: '사장님 계정 발급',     tone: 'success', detail: 'none' },
  MERCHANT_STATUS_CHANGE:     { label: '사장님 상태 변경',     tone: 'danger',  detail: 'status' },
  MERCHANT_PASSWORD_RESET:    { label: '사장님 비밀번호 초기화', tone: 'warn',  detail: 'none' },
  MERCHANT_DOCUMENT_REVIEW:   { label: '사장님 서류 심사',     tone: 'info',    detail: 'status' },
  USER_INFO_MODIFY:           { label: '손님 정보 수정',       tone: 'warn',    detail: 'status' },
  USER_PASSWORD_RESET:        { label: '손님 비밀번호 초기화', tone: 'warn',    detail: 'none' },
  BAN_WORD_DELETE:            { label: '금칙어 삭제',          tone: 'danger',  detail: 'full' },
  RANDOM_NICKNAME_DELETE:     { label: '랜덤 닉네임 삭제',     tone: 'danger',  detail: 'full' },
  TERMS_DRAFT_DELETE:         { label: '약관 초안 삭제',       tone: 'danger',  detail: 'full' },
};

// targetType → 사람이 읽는 이름. 서버 AdminAuditActionEnum 의 targetType 값과 같습니다.
const AUDIT_TARGET_TYPE = {
  FAQ:                  'FAQ',
  USER_NOTICE:          '손님 공지',
  MERCHANT_NOTICE:      '사장님 공지',
  REVIEW:               '리뷰',
  MERCHANT_APPLICATION: '입점 신청',
  MERCHANT:             '사장님',
  MERCHANT_DOCUMENT:    '사장님 서류',
  USER:                 '손님',
  BAN_WORD:             '금칙어',
  RANDOM_NICKNAME:      '랜덤 닉네임',
  TERMS_VERSION:        '약관 버전',
};

// 대상 종류 고르기 — 목록에 있는 순서 그대로 씁니다(위 표의 키 순서는 action 순서라 다릅니다).
const AUDIT_TARGET_TYPE_OPTIONS = Object.keys(AUDIT_TARGET_TYPE);
// action → targetType. 서버 AdminAuditActionEnum 의 짝을 그대로 옮긴 것입니다.
const AUDIT_ACTION_TARGET = {
  FAQ_MODIFY: 'FAQ', FAQ_DELETE: 'FAQ', FAQ_RESTORE: 'FAQ',
  USER_NOTICE_MODIFY: 'USER_NOTICE', USER_NOTICE_DELETE: 'USER_NOTICE',
  MERCHANT_NOTICE_MODIFY: 'MERCHANT_NOTICE', MERCHANT_NOTICE_DELETE: 'MERCHANT_NOTICE',
  REVIEW_STATUS_CHANGE: 'REVIEW',
  MERCHANT_APPLICATION_STATUS: 'MERCHANT_APPLICATION', MERCHANT_ACCOUNT_ISSUE: 'MERCHANT_APPLICATION',
  MERCHANT_STATUS_CHANGE: 'MERCHANT', MERCHANT_PASSWORD_RESET: 'MERCHANT',
  MERCHANT_DOCUMENT_REVIEW: 'MERCHANT_DOCUMENT',
  USER_INFO_MODIFY: 'USER', USER_PASSWORD_RESET: 'USER',
  BAN_WORD_DELETE: 'BAN_WORD',
  RANDOM_NICKNAME_DELETE: 'RANDOM_NICKNAME',
  TERMS_DRAFT_DELETE: 'TERMS_VERSION',
};
const auditTargetTypeOf = (action) => AUDIT_ACTION_TARGET[action] || '';
// 조작 고르기 — 대상 종류를 먼저 고르면 그 종류의 action 만 남깁니다.
const auditActionsFor = (targetType) =>
  Object.keys(AUDIT_ACTION).filter((k) => !targetType || auditTargetTypeOf(k) === targetType);
const auditActionLabel = (action) => (AUDIT_ACTION[action] || {}).label || action || '—';
const auditActionTone = (action) => (AUDIT_ACTION[action] || {}).tone || 'neutral';
const auditTargetTypeLabel = (t) => AUDIT_TARGET_TYPE[t] || t || '—';

// 이력이 시작한 날. V43__admin_audit_log.sql(사용자 승인 2026-09-30)과 dev 의 가장 오래된 행
// (2026-09-30 17:46)이 같은 날입니다. 이 날 이전의 수정·삭제는 **기록이 아예 없습니다** —
// 빈 목록을 "아무 일도 없었다"로 읽지 않게 화면이 이 날짜를 적습니다.
const AUDIT_SINCE = '2026-09-30';
// 아직 이력을 남기지 않는 도메인. 서버 AdminAuditActionEnum 의 "아직 덮지 않은 것" 그대로입니다.
const AUDIT_UNTRACKED_TEXT =
  '정산 · 쿠폰 · 주문 · 가게 · 메뉴 · 카테고리 · 관리자 계정은 아직 이력을 남기지 않습니다(1단계 범위 밖). '
  + '그 도메인이 비어 있는 것은 조작이 없었다는 뜻이 아닙니다.';
// 이력에 개인정보를 일부러 담지 않습니다. 화면이 빈 칸을 만들지 않도록 어디서 보는지 적습니다.
const AUDIT_NO_PII_TEXT =
  '이력에는 개인정보와 자유 입력 사유가 담기지 않습니다(리뷰 본문 · 닉네임 · 이메일 · 연락처 · '
  + '사업자등록번호 · 계좌번호 · 반려 사유). 일부러 뺀 것이며, 그 값은 원래 화면에서 봅니다.';

// ---------------------------------------------------------------- 서버 없는 값 비우기
//
// 어드민은 목/실서버 스위치가 없고 늘 실서버에 붙습니다. 그런데 일부 블록은 아직 서버가 없어
// 목(api.js 의 api.live.* 가 아닌 것)을 읽습니다. 그런 블록이 실서버 블록과 같은 모양으로
// 숫자를 보여 주면 운영자가 둘을 구분할 수 없고, 진짜 숫자까지 의심하게 됩니다.
// 그래서 목을 읽는 블록은 비워 그리고 왜 비었는지 말합니다. 0 이나 지난 값으로 채우지 않습니다
// ("서버가 0 이라고 답했다"로 읽힙니다). 목 데이터 자체는 지우지 않습니다 — 서버가 생기면
// 그 블록만 바꾸면 됩니다. (CLAUDE.md 3번)
//
// 문구·기호는 사장님 관리 웹(Store-yumyum 10-portal HomePage)·사장님 앱(20-app
// src/lib/notConnected.ts)과 **같아야 합니다.** 네 곳이 다른 말을 하면 안 됩니다. 바꿀 때 함께 바꾸세요.
// 목록 응답의 두 모양 — 배열(옛 방식)과 `{totalCount, list}`(backend 가 순차적으로 바꾸는 중).
// 배포 순서와 무관하게 깨지지 않도록 **둘 다** 읽습니다. 총건수를 주면 페이지 수를 정확히 쓰고,
// 안 주면 예전처럼 페이지 단위로만 셉니다 — **없는 총계를 지어내지 않습니다**.
const listRows = (d) => (Array.isArray(d) ? d : (d && Array.isArray(d.list) ? d.list : []));
const listTotal = (d) => (!d || Array.isArray(d) || d.totalCount == null ? null : Number(d.totalCount));
// 총건수를 알면 마지막 페이지를, 모르면 null 을 돌려줍니다.
const listLastPage = (d, size) => {
  const t = listTotal(d);
  return t == null ? null : Math.max(1, Math.ceil(t / size));
};

const EMPTY_VALUE = '—';                                   // 숫자 자리. 레이아웃은 그대로 두고 값만 비웁니다
const NOT_CONNECTED_TEXT = '아직 연결되지 않은 정보입니다';   // 값 하나가 비었을 때. 마침표 없음
const NOT_CONNECTED_FEATURE_TEXT =                          // 기능 전체가 없을 때
  '아직 서버에 연결되지 않은 기능입니다. 연결되면 이곳에서 관리할 수 있습니다.';

// ---------------------------------------------------------------- 값 형식

// 금액: "12,000원" (₩ 기호 쓰지 않음)
const fmtKRW = (n) => `${(n || 0).toLocaleString('ko-KR')}원`;
const fmtNum = (n) => (n || 0).toLocaleString('ko-KR');

// 일시: 서버 문자열 'yyyy-MM-dd HH:mm[:ss]' (KST). new Date(문자열) 로 파싱하지 않습니다.
const DT_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/;
const parseDT = (s) => {
  const m = DT_RE.exec(s || '');
  if (!m) return null;
  // KST 벽시계 → epoch ms (KST = UTC+9)
  return Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] || 0) - 9, +(m[5] || 0), +(m[6] || 0));
};
const fmtHM = (s) => { const m = DT_RE.exec(s || ''); return m && m[4] ? `${m[4]}:${m[5]}` : '-'; };
const fmtDT = (s) => { const m = DT_RE.exec(s || ''); return m ? `${m[1]}-${m[2]}-${m[3]}${m[4] ? ` ${m[4]}:${m[5]}` : ''}` : '-'; };
const ymd   = (s) => { const m = DT_RE.exec(s || ''); return m ? `${m[1]}-${m[2]}-${m[3]}` : ''; };
// 연락처: 목록에서는 가리고(maskPhone), 상세에서만 전체를 보여 줍니다(fmtPhone).
// 연락처는 저장 형식이 섞여 있고(01012345678 · 010-1234-5678), 탈퇴 계정은 전화번호가 아닌
// 자리표시 문자열(withdrawn-30000016)이 들어옵니다. 전화번호 모양이 아닌 값을 전화번호처럼
// 그리면(300-****-0016) 없는 번호를 지어낸 것처럼 보이므로, 그럴 때는 원래 값을 그대로 둡니다.
const looksLikePhone = (p) => /^[\d\s()+-]+$/.test(String(p || '').trim());
// 서버가 값을 모를 때 문자열 'none'·'null' 을 그대로 넣어 둔 행이 있습니다(사장님 계정 일부, 2026-09-23 확인).
// 그대로 그리면 연락처 칸에 "none" 이 적힌 것처럼 보여 값이 있는 줄 알게 됩니다 — 빈 값으로 다룹니다.
// 'withdrawn-30000016'(탈퇴 자리표시) 처럼 뜻이 있는 값은 그대로 보여 줍니다.
const PHONE_PLACEHOLDERS = ['none', 'null', 'undefined', '-', 'n/a'];
const isBlankish = (v) => !v || PHONE_PLACEHOLDERS.includes(String(v).trim().toLowerCase());
const maskPhone = (p) => {
  if (isBlankish(p)) return '—';
  if (!looksLikePhone(p)) return p;
  const d = String(p).replace(/\D/g, '');
  if (d.length < 8) return p;
  return `${d.slice(0, 3)}-****-${d.slice(-4)}`;
};
// 이메일: 목록에서는 로컬파트를 가리고 도메인만 남깁니다(maskEmail), 상세에서만 전체를 보여 줍니다.
// 전화(maskPhone)와 같은 규칙입니다. 빈값·none·null 은 '—', @ 가 없는 비정상/자리표시 값은 원본 그대로.
// 로컬파트는 길이를 드러내지 않도록 항상 '****' 로 고정하고 앞 1~2자만 남깁니다(d****@… · de****@…).
const maskEmail = (e) => {
  if (isBlankish(e)) return '—';
  const s = String(e).trim();
  const at = s.lastIndexOf('@');
  if (at <= 0 || at === s.length - 1) return e;      // @ 없거나 비정상 → 원본(탈퇴 자리표시 등)
  const local = s.slice(0, at);
  const domain = s.slice(at + 1);
  const keep = local.length <= 2 ? 1 : 2;
  return `${local.slice(0, keep)}****@${domain}`;
};
const fmtPhone = (p) => {
  if (isBlankish(p)) return '—';
  if (p && !looksLikePhone(p)) return p;
  const d = String(p || '').replace(/\D/g, '');
  if (d.length === 11) return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7)}`;
  if (d.length === 10) return `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`;
  return p || '—';
};

// ---------------------------------------------------------------- 조리 시간 · 지연 (2026-09-22 계약)
//
// 서버: cookMinutes(수락 때 고른 분) · expectedReadyDate('yyyy-MM-dd HH:mm' KST, 수락 시각 + 분).
// 수락 때 한 번 정해지고 바뀌지 않습니다(v1). null 이면 수락 전 · 기능 이전 주문 · 수락 전 취소 — 비웁니다.
// 시각은 parseDT 로 KST 벽시계로 읽어 브라우저 시간대와 무관하게 비교합니다.
//
// 표준 문구(terms.md 3절): "조리 완료 예정 HH:mm", 주문일과 날짜가 다르면 "조리 완료 예정 M월 d일 HH:mm".
const readyTimeText = (expectedReadyDate, regDate) => {
  const m = DT_RE.exec(expectedReadyDate || '');
  if (!m || !m[4]) return '';
  const same = ymd(regDate) === `${m[1]}-${m[2]}-${m[3]}`;
  return same ? `조리 완료 예정 ${m[4]}:${m[5]}` : `조리 완료 예정 ${+m[2]}월 ${+m[3]}일 ${m[4]}:${m[5]}`;
};
// 지연 표시(리더 확정 2026-09-22): 1~9분 주황, 10분 이상 빨강. 기준은 처음 약속한 예상 완료 시각 하나.
// 색은 늘 '분'으로 정합니다 — 아래 단위 규칙은 문구에만 씁니다.
const LATE_DANGER_MIN = 10;
const lateTone = (min) => (min >= LATE_DANGER_MIN ? 'danger' : 'warn');
// 지난 양의 문구 단위(리더 확정 2026-09-23, 사장님 앱 Store#63 과 같은 규칙):
// 1~59분 "N분", 60분 이상 "N시간"(내림), 24시간 이상 "N일"(내림).
// 하루 지난 주문이 "985분"처럼 읽기 어렵게 나오던 것을 고칩니다.
const overdueAmount = (min) => {
  if (min >= 1440) return `${Math.floor(min / 1440)}일`;
  if (min >= 60) return `${Math.floor(min / 60)}시간`;
  return `${min}분`;
};
// { label, tone } 또는 null.
// - 조리중: 지금과 비교 → "N분/N시간/N일 지남"
// - 픽업대기·픽업완료: 실제로 준비된 시각(readyAt, 상태 이력의 픽업대기)과 비교 → "N분/N시간/N일 늦게 준비됨" / "제시간 준비".
//   이미 끝난 주문의 "지남"이 시간이 갈수록 늘지 않게 지금이 아니라 준비 시각과 비교합니다.
//   목록에는 상태 이력이 없어 readyAt 을 모르므로 이 경우는 표시하지 않습니다(null).
// - 그 밖(수락 전·취소·결제실패): 표시 없음.
const cookDelay = (order, readyAt, now = Date.now()) => {
  const exp = parseDT(order && order.expectedReadyDate);
  if (exp == null) return null;
  if (order.status === 'COOKING') {
    const min = Math.floor((now - exp) / 60000);
    return min >= 1 ? { label: `${overdueAmount(min)} 지남`, tone: lateTone(min) } : null;
  }
  if (order.status === 'PK_RDY' || order.status === 'PK_CMPL') {
    const r = parseDT(readyAt);
    if (r == null) return null;
    const min = Math.floor((r - exp) / 60000);
    return min >= 1 ? { label: `${overdueAmount(min)} 늦게 준비됨`, tone: lateTone(min) } : { label: '제시간 준비', tone: 'neutral' };
  }
  return null;
};
// "지남"은 새로고침 없이도 넘어가는 순간 보여야 해서, 1분마다 화면만 다시 그립니다(서버는 부르지 않음).
const useMinuteTick = () => {
  const [, set] = React.useState(0);
  React.useEffect(() => { const id = setInterval(() => set((n) => n + 1), 60000); return () => clearInterval(id); }, []);
};

// 목록·대시보드·검색의 금액 한 칸 (사용자 요청 2026-09-22 "결제전은 적어도 금액은 없어야"):
// - 결제전·결제실패·만료: "—" — 돈이 오간 적이 없는데 금액이 결제완료와 똑같이 찍히면 매출로 읽힙니다.
// - 주문취소(CMPL, 환불까지 끝남): 금액 + 취소선 — 결제된 돈이 있었고 돌려줬습니다.
// - 환불 처리 중(REQ): 취소선 없이 금액 — 돈이 아직 안 돌아갔습니다.
// 주문하려던 금액 자체는 상세의 "주문 금액"에 남깁니다.
const OrderAmount = ({ o, style }) => {
  if (isUnpaidOrder(o.status)) return <span style={{ color:'var(--text-3)', fontWeight:400, ...style }} title="결제되지 않은 주문">{EMPTY_VALUE}</span>;
  if (isCancelComplete(o.status)) return <span style={{ textDecoration:'line-through', color:'var(--text-3)', ...style }} title="환불된 주문">{fmtKRW(o.totalAmount)}</span>;
  return <span style={style}>{fmtKRW(o.totalAmount)}</span>;
};

// 상대 시각은 데이터로 두지 않고 표시할 때 계산합니다.
const fmtAgo = (s, now = Date.now()) => {
  const t = parseDT(s);
  if (t == null) return '-';
  const min = Math.floor((now - t) / 60000);
  if (min < 1) return '방금 전';
  if (min < 60) return `${min}분 전`;
  const h = Math.floor(min / 60);
  if (h < 24) return `${h}시간 전`;
  const d = Math.floor(h / 24);
  if (d < 30) return `${d}일 전`;
  const mo = Math.floor(d / 30);
  if (mo < 12) return `${mo}개월 전`;
  return `${Math.floor(mo / 12)}년 전`;
};

// 영업시간·휴게시간 표시. 서버는 'HH:mm[:ss]' 만 주므로 자정을 넘는지는 시각 비교로 판단합니다.
// 규칙(사용자 결정): 종료가 시작보다 이르거나 같으면 종료 앞에 "다음날".
// 휴게시간은 영업이 자정을 넘는 날, 영업 시작보다 이른 시각이면 다음날로 봅니다.
const hhmm = (t) => (t ? String(t).slice(0, 5) : '');
const fmtTimeRange = (start, end, dayStart) => {
  const s = hhmm(start), e = hhmm(end);
  if (!s || !e) return `${s || '—'} – ${e || '—'}`;
  const base = hhmm(dayStart);
  const startNext = !!base && s < base;             // 휴게 시작이 영업 시작보다 이르면 다음날
  const endNext = startNext || e <= s || (!!base && e < base);
  return `${startNext ? '다음날 ' : ''}${s} – ${endNext ? '다음날 ' : ''}${e}`;
};

Object.assign(window, {
  fmtTimeRange, OPERATION_STATUS, operationStatusText,
  OPEN_STATUS, SALE_STATUS, INSPECTION_STATUS, ACCOUNT_STATUS, ORDER_STATUS, TAKEOUT_INSTORE,
  WITHDRAWAL_REASON,
  SETTLEMENT_STATUS, SETTLEMENT_NEXT, SETTLEMENT_TABS, bankLabel,
  REVIEW_STATUS, REVIEW_REMOVE_REQUEST_STATUS, FEEDBACK_TYPE, reviewFeedbacks, reviewMenuNames, APPLICATION_STATUS, APPLICATION_NEXT, APPLICATION_SOURCE, TERMS_STATUS, TERMS_AUDIENCE, ADMIN_ROLE, AUTH_TYPE, BANK_NAME, CS_STATUS, CS_CATEGORY, PAYMENT_STATUS,
  REFUND_STATUS, REFUND_FAULT, REFUND_SOURCE, REFUND_SCOPE, REQUESTER_ROLE, SETTLEMENT_STATUS,
  isCancelRequest, isCancelComplete, orderStatusLabel, orderStatusView,
  isUnpaidOrder, PAYMENT_ATTEMPT_STATUSES, ORDER_STATUSES_DEFAULT, OrderAmount,
  REG_FROM, regFromLabel, fmtStuck, REFUND_STATE, refundView,
  PAYMENT_FAIL_CODE, failCodeView, isExpiredOrder,
  listRows, listTotal, listLastPage,
  AUDIT_ACTION, AUDIT_TARGET_TYPE, AUDIT_TARGET_TYPE_OPTIONS, auditActionsFor,
  auditTargetTypeOf, auditActionLabel, auditActionTone, auditTargetTypeLabel,
  AUDIT_SINCE, AUDIT_UNTRACKED_TEXT, AUDIT_NO_PII_TEXT,
  DOCUMENT_STATUS, DOC_TYPE, docTypeLabel, docPrevLabel, DOC_VIEW_REASONS, fmtBytes,
  EMPTY_VALUE, NOT_CONNECTED_TEXT, NOT_CONNECTED_FEATURE_TEXT,
  fmtKRW, fmtNum, fmtHM, fmtDT, ymd, fmtAgo, parseDT, maskPhone, maskEmail, fmtPhone,
  readyTimeText, cookDelay, lateTone, overdueAmount, useMinuteTick,
});
