diff --git a/CLAUDE.md b/CLAUDE.md index 2c2ce86..bc67f70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아 - 반품관리 - 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등) - 개인경비 (`app/modules/expense/`, `expense_db`) -- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용. 출고 묶음은 **박스 계산 화면의 [분배 확정] 으로만** 만든다(신규 등록 폼 없음, `/cupang/new` 는 박스 계산으로 리다이렉트). 확정 조건: 미배분 박스 0 + 담긴 센터마다 출고방식(택배/파렛트) 선택. 확정 시 센터마다 출고 묶음 1건(`status=출고준비`, 센터입고일=출고일). 작업 중 상태는 `cupang_box_calc_drafts` 에 이름 붙여 임시 저장/불러오기 +- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용. 출고 묶음은 **박스 계산 화면의 [분배 확정] 으로만** 만든다(신규 등록 폼 없음, `/cupang/new` 는 박스 계산으로 리다이렉트). 확정 조건: 미배분 박스 0 + 담긴 센터마다 출고방식(택배/파렛트) 선택. 확정 시 센터마다 출고 묶음 1건(`status=출고준비`, 센터입고일=출고일). 작업 중 상태는 `cupang_box_calc_drafts` 에 이름 붙여 임시 저장/불러오기. 쿠팡 발주 엑셀(xlsx) 다중 업로드 지원(`POST /cupang/api/box-calc/upload`, openpyxl) — F13 입고예정일의 하루 전 = 출고일, 22행부터 B=쿠팡상품코드·F=센터명·G=수량을 읽어 `cupang_products.coupang_item_code` 로 제품 매칭 후 센터별 합산 → 센터 단위로 박스 계산·자동 배분 - 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver` - 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia` - 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 박스 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md` diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py index 5970561..7165e2e 100644 --- a/app/modules/cupang/router.py +++ b/app/modules/cupang/router.py @@ -20,7 +20,7 @@ from datetime import date as _date from app.timezone import today_kst -from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request +from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Request, UploadFile from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from .holidays import is_holiday @@ -674,8 +674,8 @@ async def box_calc_page(request: Request) -> HTMLResponse: "user": user, "is_admin": is_admin(user), "nav_items": build_erp_nav(user, active="cupang"), - "page_title": "쿠팡 밀크런 — 박스 계산", - "page_subtitle": "제품명과 수량을 넣으면 박스 수와 남은 낱개를 계산합니다.", + "page_title": "쿠팡 밀크런 — 쿠팡 발주 업로드", + "page_subtitle": "쿠팡 발주 엑셀을 올리면 센터별 수량을 합산해 박스를 계산합니다.", "box_rules": store.list_box_rules(), # 센터 선택 드롭다운은 가나다순 (한글 음절은 코드포인트 순 = 가나다순) "centers": sorted(store.list_centers(), key=lambda c: (c.get("name") or "")), @@ -683,6 +683,200 @@ async def box_calc_page(request: Request) -> HTMLResponse: ) +# ──────────────────────────────────────────────────────────── +# 쿠팡 발주 엑셀 업로드 — F13 입고예정일 / 22행부터 상품 +# B열 = 쿠팡상품코드, F열 = 물류센터, G열 = 발주수량 +# ──────────────────────────────────────────────────────────── +PO_FIRST_ROW = 22 # 상품이 시작되는 행 +PO_ARRIVAL_CELL = "F13" # 입고예정일시 + + +def _po_cell_date(value: Any) -> _date | None: + """F13 값(datetime / date / 문자열) → date. 인식 못 하면 None.""" + from datetime import datetime as _dt # noqa: WPS433 + + if isinstance(value, _dt): + return value.date() + if isinstance(value, _date): + return value + text = str(value or "").strip() + if not text: + return None + text = text.split(" ")[0].replace(".", "-").replace("/", "-") + try: + return _date.fromisoformat(text) + except ValueError: + return None + + +def _po_int(value: Any) -> int: + try: + return int(float(str(value).replace(",", "").strip())) + except (TypeError, ValueError): + return 0 + + +def _parse_po_sheet(ws: Any) -> dict[str, Any]: + """발주서 시트 1장 → {arrival_date, rows:[{coupang_item_code, center_name, quantity}]}.""" + arrival = _po_cell_date(ws[PO_ARRIVAL_CELL].value) + rows: list[dict[str, Any]] = [] + for r in range(PO_FIRST_ROW, (ws.max_row or PO_FIRST_ROW) + 1): + code = str(ws.cell(row=r, column=2).value or "").strip() # B + if not code or code in ("합계", "소계"): + continue + code = code.split(".")[0] if code.replace(".", "").isdigit() else code + center = str(ws.cell(row=r, column=6).value or "").strip() # F + qty = _po_int(ws.cell(row=r, column=7).value) # G + if not center or qty <= 0: + continue + rows.append({"coupang_item_code": code, "center_name": center, "quantity": qty}) + return {"arrival_date": arrival, "rows": rows} + + +@router.post("/api/box-calc/upload") +async def box_calc_upload( + request: Request, + files: list[UploadFile] = File(...), + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + """쿠팡 발주 엑셀 여러 개 → 센터별 제품 수량 합산. + + - 출고일 = F13(입고예정일)의 하루 전 + - 쿠팡상품코드(B) → cupang_products.coupang_item_code 로 제품 매칭 + - 센터명(F) → cupang_centers.name 으로 매칭(못 찾으면 경고만) + """ + from io import BytesIO # noqa: WPS433 + from datetime import timedelta as _timedelta # noqa: WPS433 + + from openpyxl import load_workbook # noqa: WPS433 + + store = _store(request) + if store is None: + raise HTTPException(status_code=503, detail="cupang_db 미설정") + if not files: + raise HTTPException(status_code=400, detail="엑셀 파일을 선택하세요.") + + products = store.list_products(include_inactive=True) + by_coupang = { + (p.get("coupang_item_code") or "").strip(): p + for p in products + if (p.get("coupang_item_code") or "").strip() + } + centers = store.list_centers(include_inactive=True) + by_center_name = {(c.get("name") or "").strip(): c for c in centers} + rules = {r["product_code"]: r for r in store.list_box_rules()} + + warnings: list[str] = [] + file_infos: list[dict[str, Any]] = [] + ship_dates: list[str] = [] + # (센터명, 제품코드) → 수량 + agg: dict[tuple[str, str], dict[str, Any]] = {} + unknown_codes: set[str] = set() + unknown_centers: set[str] = set() + no_rule: set[str] = set() + + for up in files: + raw = await up.read() + name = up.filename or "(이름 없음)" + try: + wb = load_workbook(BytesIO(raw), data_only=True) + except Exception: # noqa: BLE001 - 엑셀이 아니거나 손상 + warnings.append(f"{name}: 엑셀 파일을 읽지 못했습니다.") + continue + parsed = _parse_po_sheet(wb.worksheets[0]) + wb.close() + + arrival = parsed["arrival_date"] + ship = (arrival - _timedelta(days=1)) if arrival else None + if ship: + ship_dates.append(ship.isoformat()) + else: + warnings.append(f"{name}: F13 입고예정일을 읽지 못했습니다.") + + used = 0 + for row in parsed["rows"]: + prod = by_coupang.get(row["coupang_item_code"]) + if not prod: + unknown_codes.add(row["coupang_item_code"]) + continue + cname = row["center_name"] + if cname not in by_center_name: + unknown_centers.add(cname) + code = prod["product_code"] + if code not in rules: + no_rule.add(f'{prod["product_name"]}({code})') + key = (cname, code) + cell = agg.setdefault( + key, + { + "center_name": cname, + "product_code": code, + "product_name": prod["product_name"], + "coupang_item_code": row["coupang_item_code"], + "quantity": 0, + }, + ) + cell["quantity"] += row["quantity"] + used += row["quantity"] + + file_infos.append( + { + "filename": name, + "arrival_date": arrival.isoformat() if arrival else "", + "ship_date": ship.isoformat() if ship else "", + "rows": len(parsed["rows"]), + "quantity": used, + } + ) + + if unknown_codes: + warnings.append( + "등록되지 않은 쿠팡상품코드 " + str(len(unknown_codes)) + "건 제외: " + + ", ".join(sorted(unknown_codes)[:10]) + + (" 외" if len(unknown_codes) > 10 else "") + ) + if unknown_centers: + warnings.append("등록되지 않은 센터: " + ", ".join(sorted(unknown_centers))) + if no_rule: + warnings.append("박스 입수량 미설정: " + ", ".join(sorted(no_rule))) + + uniq_dates = sorted(set(ship_dates)) + if len(uniq_dates) > 1: + warnings.append("파일마다 출고일이 다릅니다: " + ", ".join(uniq_dates)) + + grouped: dict[str, dict[str, Any]] = {} + for (cname, _code), cell in agg.items(): + g = grouped.setdefault( + cname, + { + "center_name": cname, + "center_id": (by_center_name.get(cname) or {}).get("id"), + "items": [], + }, + ) + g["items"].append( + { + "product_code": cell["product_code"], + "product_name": cell["product_name"], + "coupang_item_code": cell["coupang_item_code"], + "quantity": cell["quantity"], + } + ) + for g in grouped.values(): + g["items"].sort(key=lambda it: it["product_code"]) + + return JSONResponse( + { + "ok": True, + "ship_date": uniq_dates[0] if uniq_dates else "", + "ship_dates": uniq_dates, + "files": file_infos, + "centers": sorted(grouped.values(), key=lambda g: g["center_name"]), + "warnings": warnings, + } + ) + + @router.post("/api/box-calc") async def box_calc_api( request: Request, diff --git a/app/modules/cupang/templates/cupang/box_calc.html b/app/modules/cupang/templates/cupang/box_calc.html index de45636..5ea9aad 100644 --- a/app/modules/cupang/templates/cupang/box_calc.html +++ b/app/modules/cupang/templates/cupang/box_calc.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
@@ -27,10 +27,25 @@
-

① 박스 계산

- 수량 칸에서 Tab = 다음 상품 수량, Enter = 계산 +

① 쿠팡 발주 업로드

+ 엑셀 여러 개 선택 가능 · F13 입고예정일의 하루 전 = 출고일
+ +
+
+ + +
+

+ B열 쿠팡상품코드 · F열 센터 · G열 수량을 22행부터 읽습니다. +

+ +
+ +

직접 입력 업로드 결과도 여기서 고칠 수 있습니다

+
@@ -324,6 +339,181 @@ .then(function () { runBtn.disabled = false; }); } + // ── 쿠팡 발주 엑셀 업로드 ───────────────────────── + // 업로드 → 센터별 수량 합산(서버) → 센터마다 박스 계산 → ②③ 자동 채움. + // 박스는 센터별로 포장하므로 계산도 센터 단위로 한다(자투리 혼합 박스 포함). + var poFile = document.getElementById("cpg-po-file"); + var poRun = document.getElementById("cpg-po-run"); + var poMsg = document.getElementById("cpg-po-msg"); + var poResult = document.getElementById("cpg-po-result"); + var uploadShipDate = ""; // F13 − 1일 (확정 대화상자 기본값) + + function calcForItems(items) { + return fetch("/cupang/api/box-calc", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: items }) + }).then(function (r) { + if (!r.ok) throw new Error("계산 실패 (http " + r.status + ")"); + return r.json(); + }); + } + + // 센터별 계산 결과를 하나의 ② 요약으로 합치고, 각 박스를 그 센터에 자동 배분한다. + function applyUploaded(groups, perCenter) { + var mergedProds = {}; // code -> 합산 결과 + var mergedMixes = {}; // box_name -> {box_name, boxes:[]} + var autoAlloc = []; // [{cid, key, count}] + var mixOwner = {}; // mixKey -> 센터명 + + groups.forEach(function (g, gi) { + var cid = String(g.center_id); + var data = perCenter[gi] || {}; + (data.results || []).filter(function (r) { return r.configured; }).forEach(function (r) { + var m = mergedProds[r.product_code]; + if (!m) { + m = mergedProds[r.product_code] = { + product_code: r.product_code, product_name: r.product_name, + box_name: r.box_name, units_per_box: r.units_per_box, + configured: true, quantity: 0, full_boxes: 0, + remainder_units: 0, required_boxes: 0 + }; + } + m.quantity += r.quantity; + m.full_boxes += r.full_boxes; + m.remainder_units += r.remainder_units; + m.required_boxes += r.required_boxes; + if (r.full_boxes > 0) { + autoAlloc.push({ cid: cid, key: prodKey(r.product_code), count: r.full_boxes }); + } + }); + (data.mixes || []).forEach(function (mx) { + var bucket = mergedMixes[mx.box_name]; + if (!bucket) bucket = mergedMixes[mx.box_name] = { box_name: mx.box_name, boxes: [] }; + mx.boxes.forEach(function (b) { + var idx = bucket.boxes.length; + bucket.boxes.push(b); + var k = mixKey(mx.box_name, idx); + mixOwner[k] = g.center_name; + autoAlloc.push({ cid: cid, key: k, count: 1 }); + }); + }); + }); + + calc.results = Object.keys(mergedProds).map(function (k) { return mergedProds[k]; }); + calc.mixes = Object.keys(mergedMixes).map(function (k) { return mergedMixes[k]; }); + + units = {}; labels = {}; contents = {}; + calc.results.forEach(function (r) { + units[prodKey(r.product_code)] = r.units_per_box; + labels[prodKey(r.product_code)] = r.product_name; + }); + calc.mixes.forEach(function (m) { + m.boxes.forEach(function (b, i) { + var n = 0; + b.items.forEach(function (it) { n += it.quantity; }); + var k = mixKey(m.box_name, i); + units[k] = n; + labels[k] = m.box_name + " 혼합 #" + (i + 1) + + (mixOwner[k] ? " · " + mixOwner[k] : ""); + contents[k] = b.items.map(function (it) { + return { product_code: it.product_code, product_name: it.product_name, quantity: it.quantity }; + }); + }); + }); + + // ③ 센터 열고 자동 배분 + alloc = {}; stamped = {}; allStamped = false; + openCenters = []; + groups.forEach(function (g) { + var cid = String(g.center_id); + if (openCenters.indexOf(cid) < 0) openCenters.push(cid); + }); + autoAlloc.forEach(function (a) { addAlloc(a.cid, a.key, a.count); }); + + // ① 표에는 제품별 합계를 채운다(수정 후 재계산 가능). + tbody.innerHTML = ""; + calc.results.slice().sort(function (a, b) { + return String(a.product_name).localeCompare(String(b.product_name), "ko"); + }).forEach(function (r) { addRow(r.product_code, r.quantity); }); + if (!calc.results.length) addRow(); + + render(); + } + + function renderPoResult(data, skipped) { + if (!poResult) return; + var html = '
' + + '출고일 ' + esc(data.ship_date || "미확인") + "" + + '파일 ' + ((data.files || []).length) + "개" + + '센터 ' + ((data.centers || []).length) + "곳" + + "
"; + html += '
    '; + (data.files || []).forEach(function (f) { + html += "
  • " + esc(f.filename) + "" + + "입고 " + esc(f.arrival_date || "?") + " → 출고 " + esc(f.ship_date || "?") + "" + + "" + f.quantity + "개
  • "; + }); + html += "
"; + var warns = (data.warnings || []).slice(); + if (skipped && skipped.length) { + warns.push("등록되지 않은 센터라 자동 배분에서 제외: " + skipped.join(", ")); + } + if (warns.length) { + html += '
    ' + warns.map(function (w) { + return "
  • " + esc(w) + "
  • "; + }).join("") + "
"; + } + poResult.innerHTML = html; + poResult.hidden = false; + } + + function runUpload() { + if (!poFile || !poFile.files || !poFile.files.length) { + poMsg.textContent = "엑셀 파일을 선택하세요."; return; + } + var fd = new FormData(); + Array.prototype.forEach.call(poFile.files, function (f) { fd.append("files", f); }); + poRun.disabled = true; + poMsg.textContent = "분석 중…"; + fetch("/cupang/api/box-calc/upload", { method: "POST", body: fd }) + .then(function (r) { + return r.json().catch(function () { return {}; }).then(function (d) { + if (!r.ok) throw new Error(d.detail || "업로드 실패 (http " + r.status + ")"); + return d; + }); + }) + .then(function (data) { + uploadShipDate = data.ship_date || ""; + var groups = (data.centers || []).filter(function (g) { return g.center_id != null; }); + var skipped = (data.centers || []).filter(function (g) { return g.center_id == null; }) + .map(function (g) { return g.center_name; }); + if (!groups.length) { + renderPoResult(data, skipped); + poMsg.textContent = "배분할 수 있는 센터가 없습니다."; + return; + } + return Promise.all(groups.map(function (g) { + return calcForItems(g.items.map(function (it) { + return { product_code: it.product_code, quantity: it.quantity }; + })); + })).then(function (perCenter) { + applyUploaded(groups, perCenter); + renderPoResult(data, skipped); + var pieces = 0; + groups.forEach(function (g) { + g.items.forEach(function (it) { pieces += it.quantity; }); + }); + poMsg.textContent = "분석 완료 — 센터 " + groups.length + "곳 · " + pieces + "개 자동 배분"; + msg.textContent = "업로드 결과로 계산했습니다. 센터마다 출고방식을 고르면 확정할 수 있습니다."; + }); + }) + .catch(function (err) { poMsg.textContent = (err && err.message) || "업로드 실패"; }) + .then(function () { poRun.disabled = false; }); + } + + if (poRun) poRun.addEventListener("click", runUpload); + // ── 배분 집계 ───────────────────────────────────── function allocatedFor(key) { var n = 0; @@ -928,8 +1118,10 @@ if (why) { msg.textContent = why; return; } var t = new Date(); picked = iso(t.getFullYear(), t.getMonth(), t.getDate()); - calY = t.getFullYear(); - calM = t.getMonth(); + if (uploadShipDate) picked = uploadShipDate; // 업로드한 발주서의 출고일(F13 − 1일) + var pp = picked.split("-"); + calY = parseInt(pp[0], 10); + calM = parseInt(pp[1], 10) - 1; cfmMsg.textContent = ""; cfmList.innerHTML = buildPlans().map(function (pl) { var pieces = pl.items.reduce(function (n, it) { return n + it.quantity; }, 0); diff --git a/app/modules/cupang/templates/cupang/box_rules.html b/app/modules/cupang/templates/cupang/box_rules.html index 86dcf56..c4ee099 100644 --- a/app/modules/cupang/templates/cupang/box_rules.html +++ b/app/modules/cupang/templates/cupang/box_rules.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/centers.html b/app/modules/cupang/templates/cupang/centers.html index fbe2a2d..b6be68e 100644 --- a/app/modules/cupang/templates/cupang/centers.html +++ b/app/modules/cupang/templates/cupang/centers.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/detail.html b/app/modules/cupang/templates/cupang/detail.html index c20b218..ca0110f 100644 --- a/app/modules/cupang/templates/cupang/detail.html +++ b/app/modules/cupang/templates/cupang/detail.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/form.html b/app/modules/cupang/templates/cupang/form.html index 90c832b..f192ddb 100644 --- a/app/modules/cupang/templates/cupang/form.html +++ b/app/modules/cupang/templates/cupang/form.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/index.html b/app/modules/cupang/templates/cupang/index.html index 882573f..901d8b3 100644 --- a/app/modules/cupang/templates/cupang/index.html +++ b/app/modules/cupang/templates/cupang/index.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/products.html b/app/modules/cupang/templates/cupang/products.html index 2ef0ddf..77ebc1c 100644 --- a/app/modules/cupang/templates/cupang/products.html +++ b/app/modules/cupang/templates/cupang/products.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/static/cupang.css b/app/static/cupang.css index da7b8fb..230b990 100644 --- a/app/static/cupang.css +++ b/app/static/cupang.css @@ -1265,3 +1265,39 @@ body.cpg-dragging { cursor: grabbing; user-select: none; } .cpg-lg-dot { background: var(--color-deep-black); } .cpg-lg-today { background: var(--color-deep-black); box-shadow: 0 0 0 2px #fff, 0 0 0 3px var(--color-subtle-ash); } .cpg-lg-sel { background: var(--color-ghost-gray); box-shadow: inset 0 0 0 1.5px var(--color-deep-black); } + +/* ── 쿠팡 발주 업로드 (① 카드) ── */ +.cpg-po-box { + border: 1px solid var(--color-subtle-ash); + border-radius: 10px; + padding: 12px; + margin-bottom: 12px; + background: var(--color-ghost-gray); +} +.cpg-po-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.cpg-po-file { + flex: 1 1 220px; min-width: 0; + font-size: 12px; + padding: 6px; + background: #fff; + border: 1px solid var(--color-subtle-ash); + border-radius: 8px; +} +.cpg-po-msg { margin: 8px 0 0; font-size: 12px; } +.cpg-po-result { margin-top: 10px; } +.cpg-po-sum { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; } +.cpg-po-files, .cpg-po-warn { list-style: none; margin: 0; padding: 0; font-size: 12px; } +.cpg-po-files > li { + display: flex; align-items: baseline; gap: 8px; + padding: 4px 0; border-top: 1px solid var(--color-subtle-ash); +} +.cpg-po-files > li > span { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cpg-po-files > li > em { font-style: normal; color: var(--color-midtone-gray); white-space: nowrap; } +.cpg-po-files > li > b { font-family: var(--font-geist-mono); white-space: nowrap; } +.cpg-po-warn { + margin-top: 8px; padding: 8px 10px; + border-radius: 8px; + background: color-mix(in srgb, var(--color-callout-red) 10%, #fff); + color: var(--color-callout-red); +} +.cpg-po-warn > li { padding: 2px 0; }