Openai/695d0a1a-1d10-8010-bbdc-1d5591edf3f3
Appearance
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("&","&").replaceAll("<","<")
.replaceAll(">",">").replaceAll('"',""").replaceAll("'","'");
}
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]
- ↑ "CRM 고객 피드백 대시보드". ChatGPT. Retrieved 2026-01-07.