Jump to content

Openai/695d0a1a-1d10-8010-bbdc-1d5591edf3f3

From freem


This conversation was exported from ChatGPT[1]

gpt-5-2-thinking[edit | edit source]

User: 역할 : 너는 마케팅 CRM 고객 분석 20년차 전문가야.[edit | edit source]

역할 : 너는 마케팅 CRM 고객 분석 20년차 전문가야. 작업/목표 : 고객 피드백 자료를 올려주면, 오프라인, 온라인(딜리버리), 고객 피드백을 긍정/부정 5점 만점 평가점수로 매장별 평가를 해주고, 해야할 일들을 우선순위로 정리해줘. 맥락 : 현재는 마케팅 직원이 하나하나 고객 댓글을 읽고 매장별로 개선점을 엑셀로 정리하고 있어서, 시간도 오래걸리고, 어느 매장의 어떤 것을 우선적으로 해결해야하는지 정리가 잘 안돼. HTML 소스로 만들어줘.

Assistant: 아래 HTML은 업로드하신 주간 고객피드백PDF 기준으로, 매장별로 오프라인Google / 온라인Grab·Foodpanda 리뷰를 5점 만점 평균점수로 집계하고, 이슈 유형푸드세이프티/대기/누락/콜드푸드/패키징/서…[edit | edit source]

아래 HTML은 업로드하신 주간 고객피드백(PDF) 기준으로, 매장별로 오프라인(Google) / 온라인(Grab·Foodpanda) 리뷰를 5점 만점 평균점수로 집계하고, 이슈 유형(푸드세이프티/대기/누락/콜드푸드/패키징/서비스태도/위생 등)을 자동 태깅해서 우선순위 액션을 뽑아주는 “1페이지 대시보드” 템플릿입니다.

  • 이번 주 데이터에서 푸드 세이프티(언더쿡·식중독·입원 언급) 같은 “Critical” 케이스가 실제로 존재해, 해당 매장은 점수와 무관하게 최상위 우선순위로 고정되도록 설계했습니다. weekly_reviews_20251229_to_2026…
  • 온라인(딜리버리) 영역에서는 미봉인/누락/지연/콜드푸드가 반복적으로 보이며(예: 버거 누락+미봉인), 온라인 운영 프로세스 점검이 우선 과제로 잡히도록 되어 있습니다. weekly_reviews_20251229_to_2026…
  • 오프라인에서는 대기시간(예: 1시간, 35분, 40분) 및 응대 태도/위생(쓰레기 냄새) 유형이 확인됩니다. weekly_reviews_20251229_to_2026… weekly_reviews_20251229_to_2026…
  • 일부 매장/플랫폼에서 스트로우/커틀러리 등 기본 구성품 누락이 반복 언급됩니다. weekly_reviews_20251229_to_2026… weekly_reviews_20251229_to_2026…
<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
  <title>Weekly Reviews Dashboard (Offline/Online) – Outlet Scoring & Action Priority</title>
  <style>
    :root{
      --bg:#0b0f14; --card:#111826; --muted:#8aa0b5; --text:#e8f0f7;
      --line:#1e2a3a; --chip:#0e2234; --warn:#ffcc66; --bad:#ff6b6b; --good:#66ffb3;
      --accent:#7dd3fc;
    }
    *{box-sizing:border-box}
    body{margin:0;background:var(--bg);color:var(--text);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,"Apple SD Gothic Neo","Noto Sans KR","Malgun Gothic",sans-serif;}
    .wrap{max-width:1180px;margin:0 auto;padding:20px 16px 40px;}
    h1{font-size:20px;margin:0 0 8px;}
    .sub{color:var(--muted);font-size:13px;line-height:1.45;margin-bottom:16px;}
    .row{display:grid;grid-template-columns:1.2fr 0.8fr;gap:12px;align-items:start;}
    @media (max-width: 980px){ .row{grid-template-columns:1fr} }
    .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:14px;}
    .controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
    .controls label{font-size:12px;color:var(--muted)}
    input[type="search"], select{
      background:#0c1420;border:1px solid var(--line);color:var(--text);
      padding:10px 10px;border-radius:12px;outline:none;min-width:220px;
    }
    .toggle{
      display:flex;gap:8px;align-items:center;padding:8px 10px;border:1px solid var(--line);
      border-radius:999px;background:#0c1420;
    }
    .toggle input{transform:scale(1.05)}
    .kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
    @media (max-width: 980px){ .kpis{grid-template-columns:repeat(2,1fr)} }
    .kpi{background:#0c1420;border:1px solid var(--line);border-radius:14px;padding:12px;}
    .kpi .t{color:var(--muted);font-size:12px}
    .kpi .v{font-size:18px;margin-top:6px}
    .kpi .v small{font-size:12px;color:var(--muted)}
    table{width:100%;border-collapse:separate;border-spacing:0}
    thead th{
      font-size:12px;color:var(--muted);text-align:left;padding:10px;border-bottom:1px solid var(--line);
      position:sticky;top:0;background:linear-gradient(#111826,#111826);
      z-index:2;
    }
    tbody td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top;font-size:13px}
    tbody tr:hover{background:#0c1420}
    .pill{
      display:inline-flex;gap:6px;align-items:center;padding:6px 10px;border-radius:999px;
      background:var(--chip);border:1px solid #15324b;color:var(--text);font-size:12px;white-space:nowrap;
    }
    .pill.critical{border-color:#5b1b1b;background:#1a0f12}
    .pill.critical b{color:var(--bad)}
    .score{font-variant-numeric:tabular-nums}
    .score.good{color:var(--good)}
    .score.bad{color:var(--bad)}
    .score.mid{color:var(--warn)}
    details{border:1px solid var(--line);border-radius:14px;background:#0c1420;margin-top:10px}
    details summary{cursor:pointer;padding:10px 12px;color:var(--accent);font-size:13px}
    .detail{padding:0 12px 12px;color:var(--text)}
    .chips{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 10px}
    .chips .pill{background:#0a1a2a}
    .actions{margin:0;padding-left:18px;color:#d8e6f3}
    .actions li{margin:6px 0}
    .ex{border:1px solid var(--line);border-radius:12px;padding:10px;margin-top:10px;background:#091321}
    .ex .meta{display:flex;flex-wrap:wrap;gap:8px;color:var(--muted);font-size:12px;margin-bottom:6px}
    .ex .msg{font-size:13px;line-height:1.45}
    .muted{color:var(--muted)}
    .right .card{position:sticky;top:12px}
    .foot{margin-top:12px;color:var(--muted);font-size:12px;line-height:1.5}
    .btn{
      background:#0c1420;border:1px solid var(--line);color:var(--text);border-radius:12px;
      padding:9px 10px;cursor:pointer;font-size:12px;
    }
    .btn:hover{border-color:#27435f}
    .hr{height:1px;background:var(--line);margin:12px 0}
    code.inline{background:#0c1420;border:1px solid var(--line);padding:2px 6px;border-radius:8px;color:#d6f0ff}
  </style>
</head>
<body>
<div class="wrap">
  <h1>Weekly Reviews – Outlet Scoring & Action Priority</h1>
  <div class="sub">
    오프라인(Google) / 온라인(Grab·Foodpanda) 리뷰를 5점 만점 평균으로 집계하고, 메시지 키워드로 이슈를 자동 분류해
    “어느 매장의 무엇부터” 해결해야 하는지 우선순위로 보여줍니다.<br/>
    <span class="muted">*Critical(푸드 세이프티 등) 이슈가 있으면 점수와 무관하게 최상단 고정</span>
  </div>

  <div class="row">
    <div class="left">
      <div class="card">
        <div class="controls">
          <label>Search</label>
          <input id="q" type="search" placeholder="예: Suntec / Orchard / wait / missing / cold" />
          <label>Sort</label>
          <select id="sort">
            <option value="priority">Priority (Critical → Highest)</option>
            <option value="offline">Offline score (low → high)</option>
            <option value="online">Online score (low → high)</option>
            <option value="overall">Overall score (low → high)</option>
            <option value="volume">Review volume (high → low)</option>
          </select>

          <div class="toggle">
            <input id="onlyCritical" type="checkbox"/>
            <label for="onlyCritical">Critical only</label>
          </div>
          <div class="toggle">
            <input id="negOnly" type="checkbox"/>
            <label for="negOnly">Show “needs-fix” first</label>
          </div>

          <button class="btn" id="reset">Reset</button>
        </div>

        <div class="hr"></div>

        <div class="kpis" id="kpis"></div>

        <div class="hr"></div>

        <div style="overflow:auto; max-height: 560px; border-radius:14px;">
          <table>
            <thead>
              <tr>
                <th style="min-width:190px;">Outlet</th>
                <th>Flags</th>
                <th>Offline (Google)</th>
                <th>Online (Delivery)</th>
                <th>Overall</th>
                <th>Top Issues</th>
                <th style="min-width:120px;">Priority</th>
              </tr>
            </thead>
            <tbody id="rows"></tbody>
          </table>
        </div>

        <div class="foot">
          <b>Scoring logic</b><br/>
* Offline/Online Score: 해당 채널 평균 별점(1~5).<br/>
* Priority: 이슈 유형별 가중치 × (불만강도=6-별점) × 빈도 기반 지표.<br/>
* Critical: “언더쿡/식중독/입원/살모넬라 등” 키워드 감지 시 최우선 처리.
        </div>
      </div>
    </div>

    <div class="right">
      <div class="card">
        <div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start;">
          <div>
            <div class="muted" style="font-size:12px;">This week playbook</div>
            <div style="font-size:16px;margin-top:4px;"><b>Recommended workflow</b></div>
          </div>
        </div>

        <div class="hr"></div>

        <ol class="actions">
          <li><b>Critical first:</b> Food safety/contamination/undercook은 24시간 내 CAPA(원인·조치·재발방지) 완료.</li>
          <li><b>Online ops:</b> Seal/packing checklist + item-scan + “missing item” 재발 매장 집중 점검.</li>
          <li><b>Speed & comms:</b> 피크 타임 WIP 제한 + 예상 대기시간 고지 + 픽업존 동선 정리.</li>
          <li><b>Service basics:</b> 응대 톤/클레임 처리 스크립트 + 매니저 에스컬레이션 룰.</li>
          <li><b>Close loop:</b> “NOT REPLIED” 고위험 건은 우선 응답 + 보상/재발방지 안내 템플릿 적용.</li>
        </ol>

        <div class="hr"></div>

        <div class="muted" style="font-size:12px;">
          필요 시, 다음 단계로 “Owner/ETA/Status” 칼럼까지 붙여서
          <code class="inline">Action Tracker</code> 형태로 확장 가능합니다.
        </div>
      </div>
    </div>
  </div>
</div>

<script>
/**
 * Data source: weekly_reviews_20251229_to_20260104.pdf
 * Offline = Google, Online = Grab/Foodpanda
 * Note: priority_score is a heuristic indicator; Critical flag overrides sorting.
 */
const DATA = ${json_data};

const ISSUE_TO_ACTIONS = {
  "Food safety / undercooked": [
    "즉시: 해당 SKU(치킨/패티) 조리온도·타이머·홀딩타임 전수 점검 (매니저 서명).",
    "원인: 피크타임 생산량/스태핑/그릴 로딩/두께 편차/온도계 교정 여부 확인.",
    "재발방지: QA 체크(첫 30분·피크 중 2회) + 교육 리마인드 + 감사(spot check) 2주 운영."
  ],
  "Long wait / queue": [
    "피크타임 WIP 제한(버거/프라이 동시 처리 기준) + 병목 스테이션 재배치.",
    "예상 대기시간 안내(카운터/픽업존) + 픽업 호출 프로세스 정리(번호/이름).",
    "인력: 피크 2시간 구간(주말/행사) 스태핑 보강 + 러너(픽업존) 지정."
  ],
  "Missing item / condiments": [
    "패킹 체크리스트(2-step: packer + checker) 도입/강제.",
    "소스/스트로우/커틀러리 등 ‘기본 구성품’은 번들로 묶어 자동 투입.",
    "재발 매장은 POS 아이템 스캔→백 확인(visual) 루틴 교육."
  ],
  "Cold food": [
    "핫/콜드 분리 패킹(음료 위에 프라이/치킨 금지) + 단열/환기 기준 점검.",
    "라이더 픽업 대기 시 보관 위치(워머/선반) 표준화.",
    "장시간 지연 주문은 리메이크 룰(임계시간) 적용."
  ],
  "Packaging leak / spill": [
    "컵/소스/쉐이크 씰링 자재 점검 + 봉투 적재 방식 교육.",
    "액체류는 별도 캐리어/칸막이 고정, 스태커블 구조로 변경.",
    "‘스필’ 빈발 시간대/메뉴 추적해 포장 스펙 수정."
  ],
  "Service attitude / rude": [
    "클레임 응대 스크립트(3-step: 사과→확인→해결) 교육 + 매니저 에스컬레이션 룰.",
    "피크타임 스트레스 구간에 ‘프론트 리더’ 배치해 톤 관리.",
    "직원 칭찬 건은 주간 Kudos로 공유해 긍정행동 강화."
  ],
  "Cleanliness / smell / pests": [
    "쓰레기/트레이/픽업존 청소 주기 재정의(피크 30분 단위).",
    "백오브하우스 폐기물 동선·보관·배출 시간 점검 (냄새 원인 차단).",
    "해충(조류/벌레) 유입 포인트(출입문/외부 좌석) 차단 및 시설 점검."
  ],
  "App / order status": [
    "‘준비완료’/‘픽업 가능’ 상태 업데이트 타이밍 표준화.",
    "라이더/고객 대기 발생 시: 상시 담당자 지정 + 커뮤니케이션 문구 통일.",
    "취소/환불 이슈는 플랫폼별 SOP(증빙/응답 템플릿) 적용."
  ],
  "Menu quality / taste": [
    "특정 재료(피클/소스/산미) 불만은 레시피 범위 내 조정(옵션 안내 포함).",
    "품질 편차(오버/언더, 식감) 원인: 홀딩·배치·리메이크 기준 점검."
  ]
};

function fmtScore(v){
  if (v === null || v === undefined || Number.isNaN(v)) return <code><span class="muted">n/a</span></code>;
  const cls = v >= 4.4 ? "good" : (v <= 3.6 ? "bad" : "mid");
  return <code><span class="score ${cls}">${v.toFixed(2)}</span></code>;
}

function topIssuesHtml(issues){
  if (!issues || !issues.length) return <code><span class="muted">-</span></code>;
  return issues.slice(0,3).map(x => <code><span class="pill">${x.tag} <span class="muted">(${x.count})</span></span></code>).join(" ");
}

function buildActions(topIssues){
  const uniq = [...new Set((topIssues||[]).map(x=>x.tag))].slice(0,4);
  const lines = [];
  for (const tag of uniq){
    const actions = ISSUE_TO_ACTIONS[tag];
    if (actions && actions.length){
      lines.push(<code><li><b>${tag}</b><ul class="actions">${actions.map(a=></code><li>${a}</li><code>).join("")}</ul></li></code>);
    }
  }
  if (!lines.length) return <code><div class="muted">특이 이슈가 적어, 표준 운영 유지 + 칭찬 리뷰 중심으로 로열티 강화 활동 추천</div></code>;
  return <code><ol class="actions">${lines.join("")}</ol></code>;
}

function examplesHtml(examples){
  if (!examples || !examples.length) return <code><div class="muted">No sample comments</div></code>;
  return examples.slice(0,3).map(ex => {
    const tags = (ex.issue_tags || []).map(t=><code><span class="pill">${t}</span></code>).join(" ");
    return <code>
      <div class="ex">
        <div class="meta">
          <span class="pill">${ex.channel}</span>
          <span class="pill">${ex.platform}</span>
          <span class="pill"> ${ex.rating}</span>
          <span class="muted">Ticket ${ex.ticket}</span>
          <span class="muted">${ex.created}</span>
        </div>
        <div class="chips">${tags || </code><span class="muted">-</span><code>}</div>
        <div class="msg">${escapeHtml(ex.message || "")}</div>
      </div>
    </code>;
  }).join("");
}

function escapeHtml(s){
  return (s||"")
    .replaceAll("&","&amp;").replaceAll("<","&lt;")
    .replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&#039;");
}

function calcKPIs(list){
  const totalOutlets = list.length;
  const critical = list.filter(x=>x.critical_food_safety).length;
  const totalReviews = list.reduce((a,x)=>a + (x.total_n||0), 0);
  const avgOverall = totalOutlets ? (list.reduce((a,x)=>a + (x.overall_avg||0),0)/totalOutlets) : 0;
  return { totalOutlets, critical, totalReviews, avgOverall };
}

function renderKPIs(list){
  const {totalOutlets, critical, totalReviews, avgOverall} = calcKPIs(list);
  const el = document.getElementById("kpis");
  el.innerHTML = <code>
    <div class="kpi"><div class="t">Outlets</div><div class="v">${totalOutlets}</div></div>
    <div class="kpi"><div class="t">Critical outlets</div><div class="v"><span style="color:var(--bad)">${critical}</span></div></div>
    <div class="kpi"><div class="t">Total reviews</div><div class="v">${totalReviews}</div></div>
    <div class="kpi"><div class="t">Avg overall score</div><div class="v">${avgOverall.toFixed(2)} <small>/ 5</small></div></div>
  </code>;
}

function sortData(list, mode){
  const arr = [...list];
  if (mode === "priority"){
    // Critical first, then higher priority_score, then lower overall score
    arr.sort((a,b)=>{
      if (!!a.critical_food_safety !== !!b.critical_food_safety) return (b.critical_food_safety?1:0) - (a.critical_food_safety?1:0);
      if ((b.priority_score||0) !== (a.priority_score||0)) return (b.priority_score||0) - (a.priority_score||0);
      return (a.overall_avg||0) - (b.overall_avg||0);
    });
  } else if (mode === "offline"){
    arr.sort((a,b)=>(a.offline_avg ?? 99) - (b.offline_avg ?? 99));
  } else if (mode === "online"){
    arr.sort((a,b)=>(a.online_avg ?? 99) - (b.online_avg ?? 99));
  } else if (mode === "overall"){
    arr.sort((a,b)=>(a.overall_avg ?? 99) - (b.overall_avg ?? 99));
  } else if (mode === "volume"){
    arr.sort((a,b)=>(b.total_n||0) - (a.total_n||0));
  }
  return arr;
}

function needsFixFirst(list){
  // heuristic: any avg <= 3.8 or any critical
  const bad = list.filter(x=>x.critical_food_safety || (x.overall_avg ?? 5) <= 3.8);
  const good = list.filter(x=>!(x.critical_food_safety || (x.overall_avg ?? 5) <= 3.8));
  return [...bad, ...good];
}

function render(){
  const q = (document.getElementById("q").value || "").toLowerCase().trim();
  const sortMode = document.getElementById("sort").value;
  const onlyCritical = document.getElementById("onlyCritical").checked;
  const negOnly = document.getElementById("negOnly").checked;

  let list = DATA.filter(x=>{
    if (onlyCritical && !x.critical_food_safety) return false;
    if (!q) return true;
    const hay = [
      x.outlet,
      ...(x.top_issues||[]).map(t=>t.tag),
      ...(x.examples||[]).map(e=>e.message)
    ].join(" ").toLowerCase();
    return hay.includes(q);
  });

  list = sortData(list, sortMode);
  if (negOnly) list = needsFixFirst(list);

  renderKPIs(list);

  const tbody = document.getElementById("rows");
  tbody.innerHTML = list.map(x=>{
    const flags = x.critical_food_safety
      ? <code><span class="pill critical"><b>CRITICAL</b> Food safety</span></code>
      : <code><span class="muted">-</span></code>;

    const issues = topIssuesHtml(x.top_issues);
    const priority = x.critical_food_safety
      ? <code><span class="pill critical"><b>Critical</b></span></code>
      : <code><span class="pill">Score <span class="score mid">${(x.priority_score||0).toFixed(1)}</span></span></code>;

    const detailId = <code>d_${x.outlet.replace(/\s+/g,'_')}</code>;

    return <code>
      <tr>
        <td><b>${escapeHtml(x.outlet)}</b><div class="muted">${x.total_n} reviews</div></td>
        <td>${flags}</td>
        <td>${fmtScore(x.offline_avg)} <span class="muted">(${x.offline_n})</span></td>
        <td>${fmtScore(x.online_avg)} <span class="muted">(${x.online_n})</span></td>
        <td>${fmtScore(x.overall_avg)} <span class="muted">(${x.total_n})</span></td>
        <td>${issues}</td>
        <td>${priority}</td>
      </tr>
      <tr>
        <td colspan="7" style="padding-top:0;border-bottom:1px solid var(--line);">
          <details id="${detailId}">
            <summary>Show details: action plan + sample comments</summary>
            <div class="detail">
              <div class="chips">
                ${(x.top_issues||[]).map(t=></code><span class="pill">${t.tag} <span class="muted">(${t.count})</span></span><code>).join("") || </code><span class="muted">No issue tags</span><code>}
              </div>

              <div style="margin-top:6px;"><b>Recommended actions (priority order)</b></div>
              ${buildActions(x.top_issues)}

              <div style="margin-top:12px;"><b>Sample comments (worst-first)</b></div>
              ${examplesHtml(x.examples)}
            </div>
          </details>
        </td>
      </tr>
    </code>;
  }).join("");
}

document.getElementById("q").addEventListener("input", render);
document.getElementById("sort").addEventListener("change", render);
document.getElementById("onlyCritical").addEventListener("change", render);
document.getElementById("negOnly").addEventListener("change", render);
document.getElementById("reset").addEventListener("click", ()=>{
  document.getElementById("q").value="";
  document.getElementById("sort").value="priority";
  document.getElementById("onlyCritical").checked=false;
  document.getElementById("negOnly").checked=false;
  render();
});

render();
</script>
</body>
</html>

원하시면, 이 HTML을 다음 형태로 더 “실무용”으로 업그레이드해드릴게요(질문 안 하셔도 됩니다. 저는 바로 작업 가능합니다).

  • Owner / ETA / Status(Open/In progress/Done)까지 붙여서 “주간 Action Tracker”로 확장
  • 매장별로 Offline vs Online 갭(Δ) 자동 표시 + “원인 가설”까지 추천
  • “직원 이름 언급(칭찬 직원 리포트)” 자동 추출(예: One Fullerton에서 특정 직원 칭찬처럼) weekly_reviews_20251229_to_2026…

References[edit | edit source]

  1. "CRM 고객 피드백 대시보드". ChatGPT. Retrieved 2026-01-07.