이미 알고 있는 컨텍스트: 코덱스 같은 세션이므로 P1 작업지시서의 룰 (색깔=오렌지, /diagnose 격리, untracked 보존, sbSelect/PostgREST 패턴, params Promise, Next.js 15) 모두 살아있음. 이 문서는 새 스코프만 명시.
// app/keeper-out/layout.tsx (현재 — root keeper-out 공통)
export const metadata = {
title: "매장보안 진단 | Keeper", // 사장님 진단용 카피
description: "10초 진단 + N페이 1만원 즉시지급",
};
export default function KeeperOutLayout({ children }) {
return (
<div className="min-h-screen bg-white text-[#1a1a1a]">
<AppHeader /> // ← cashon 로고 + "매장 지원금 진단" 뱃지
{children}
</div>
);
}
이 layout이 /keeper-out/admin/*에도 자동 적용 → 영업원 대시보드에 마케팅 헤더 끼어듦.
| 신규 | app/keeper-out/admin/layout.tsx | admin 전용 layout — Next.js nested layout으로 root override |
// app/keeper-out/admin/layout.tsx (신규)
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Keeper Out Dashboard",
description: "키퍼 아웃바운드 영업 워크벤치",
};
export default function AdminLayout({
children,
}: { children: React.ReactNode }) {
// admin은 키퍼 오렌지 톤 영업원 컨텍스트.
// AppHeader (cashon 마케팅 헤더) 끼우지 않음 — root layout의 헤더 격리.
return <>{children}</>;
}
/keeper-out/layout.tsx를 대체하지 않고 안에 중첩됨. 단 root의 AppHeader는 그대로 위에 깔림.<AppHeader />를 admin path에서 hide. 두 방법:
/keeper-out/(public) route group으로 옮기고, admin은 sibling으로 분리. 파일 이동 필요.usePathname()으로 admin 경로면 null 반환./keeper-out/diagnose 등 사장님 진단 영역에 영향 없게 유지 필수.커밋 4: refactor(keeper-out/admin): root layout에서 마케팅 헤더 격리
기존 LEAD_DETAIL_SELECT 배열에 추가 (이미 const 분리되어있음 — push만):
const LEAD_DETAIL_SELECT = [
// ... 기존 6 항목 유지
// 신규 3 항목
"visit_logs:keeper_outbound_visit_logs(*)",
"place:keeper_outbound_places!place_id(*)",
"owner:keeper_outbound_owners!owner_id(id,phone,name,total_payback)",
].join(",");
// 추가 query 파라미터
const query = new URLSearchParams({
select: LEAD_DETAIL_SELECT,
id: `eq.${id}`,
"events.order": "created_at.asc",
"payouts.order": "created_at.asc",
"visit_logs.order": "visited_at.desc", // 최근 방문 위로
});
owner.id로 다른 leads를 PostgREST self-reference 하면 복잡. 별도 query로 단순하게:
// owner_id가 있을 때만 추가 fetch
let other_leads: Lead[] = [];
if (rows[0].owner_id) {
const otherQuery = new URLSearchParams({
select: "id,store_name,status,scenario,expected_benefit,created_at",
owner_id: `eq.${rows[0].owner_id}`,
id: `neq.${id}`, // 자기 자신 제외
order: "created_at.desc",
limit: "10",
});
other_leads = await sbSelect<Lead>("keeper_outbound_leads", otherQuery.toString());
}
return NextResponse.json({ ok: true, lead: { ...rows[0], other_leads } });
export interface LeadDetail extends Lead {
// 기존 5 필드 유지
agent_1_data: Agent | null;
agent_2_data: Agent | null;
agent_3_data: Agent | null;
payouts: Payout[];
events: FunnelEvent[];
// 신규 4 필드
visit_logs: VisitLog[];
place: Place | null;
owner: Owner | null;
other_leads: Lead[];
}
VisitLog · Place · Owner는 이미 types.ts에 정의되어있음 (P1 작업 시 미사용). import만 추가.
| # | 섹션 | 데이터 | 비고 |
|---|---|---|---|
| 9 | 방문 로그 (visit_logs) | visited_at(최근→오래된) / promoter_id (agent JOIN 안 됨, ID만 표시) / step / outcome / notes / photo_urls 그리드 / audio_url 링크 / GPS | 빈 배열이면 "방문 기록 없음" + 메모 추가 폼만 표시 |
| 10 | 점주 다른 매장 (other_leads) | owner.name + owner.phone + total_payback / 다른 leads 카드 그리드 (store_name, status badge, scenario 혜택, created_at) | owner 자체가 null이면 섹션 hide. other_leads 빈 배열이면 "다른 매장 없음" |
| 11 | 매장 마스터 (place) | address / road_address / district / closure_rate_pct (반경500m 폐업률) / priority_score / area_sqm / ceiling_height_m / business_reg_year | place null이면 섹션 hide. 핵심: closure_rate_pct + priority_score는 강조 표시 (영업 anchoring) |
| 13 | 방문 메모 추가 폼 | step 드롭다운 (STEP1~4) / outcome 드롭다운 (completed/rejected/pending) / notes textarea / submit | 방문 로그 섹션 안에 박기. POST 후 GET refetch |
이미 app/api/keeper-out/visits/route.ts에 POST 존재 — body는
{ lead_id, step, outcome, notes, photo_urls? }. 새 endpoint 만들지 말고 이걸 그대로 사용.
P1 단계에선 photo_urls / audio_url / GPS는 안 받음 (P3). 단순 텍스트 메모만.
feat(keeper-out/admin): GET endpoint에 visit_logs/place/owner embed + other_leadsfeat(keeper-out/admin): lead detail P2 — 방문/점주/매장마스터 3섹션feat(keeper-out/admin): lead detail 메모 추가 폼 (visits POST 재사용)npm run build 한 번 더 PASSgit push origin staging 후 Vercel preview URL 200 OK 응답 확인 (대략 2분 대기 — curl -s -o /dev/null -w "%{http_code}\n" <preview-url>)git checkout main && git merge staging --ff-only — fast-forward만 (충돌 시 stop)git push origin main — Vercel production 자동 배포curl -s -o /dev/null -w "%{http_code}\n" https://www.keeper-direct.ceo/keeper-out/admin 응답 200--no-ff로 merge commit 만들지 말 것 (fast-forward만)--force push 절대 X/keeper-out/admin 직접 열어서 마케팅 헤더 사라진 것 시각 확인.
www.keeper-direct.ceo/keeper-out/admin 200 확인.
P1 PASS 받았다. 이어서 3단 진행:
[STAGE 1 — P1.5 admin layout 분리, 1 commit, ~15분]
목표: /keeper-out/admin/* 에 root keeper-out layout의 AppHeader (cashon 마케팅 헤더)
가 끼어드는 걸 격리한다. /keeper-out/diagnose 등 사장님 진단 영역엔 영향 없게.
방법 둘 중 선택:
(A) Route group 분리 — /keeper-out/(public)/ 로 기존 page들 이동, admin은 sibling
(B) AppHeader를 client component로 만들어 usePathname()으로 admin 경로면 null 반환
→ B가 파일 이동 없어 안전. B 우선 시도. 단 AppHeader가 이미 "use client"인지 확인 후 결정.
추가로 app/keeper-out/admin/layout.tsx도 신설:
- metadata title="Keeper Out Dashboard", description="키퍼 아웃바운드 영업 워크벤치"
- return <>{children}>
검증: /keeper-out/admin → 마케팅 헤더 사라짐 / /keeper-out/diagnose → 헤더 그대로
커밋: refactor(keeper-out/admin): root layout에서 마케팅 헤더 격리
[STAGE 2 — P2 4섹션, 3 commits, ~90분]
(2.1) GET endpoint 확장 — app/api/keeper-out/lead/[id]/route.ts
LEAD_DETAIL_SELECT 배열에 push:
"visit_logs:keeper_outbound_visit_logs(*)",
"place:keeper_outbound_places!place_id(*)",
"owner:keeper_outbound_owners!owner_id(id,phone,name,total_payback)",
URLSearchParams에 추가:
"visit_logs.order": "visited_at.desc"
owner_id가 있을 때만 다른 매장 별도 fetch:
if (rows[0].owner_id) {
const otherQuery = new URLSearchParams({
select: "id,store_name,status,scenario,expected_benefit,created_at",
owner_id: `eq.${rows[0].owner_id}`,
id: `neq.${id}`,
order: "created_at.desc",
limit: "10",
});
const other_leads = await sbSelect("keeper_outbound_leads", otherQuery.toString());
return NextResponse.json({ ok: true, lead: { ...rows[0], other_leads } });
}
types.ts LeadDetail에 추가 (VisitLog/Place/Owner는 이미 정의돼있음, import만 추가):
visit_logs: VisitLog[];
place: Place | null;
owner: Owner | null;
other_leads: Lead[];
커밋: feat(keeper-out/admin): GET endpoint에 visit_logs/place/owner embed + other_leads
(2.2) 3섹션 추가 — app/keeper-out/admin/leads/[id]/LeadDetailClient.tsx
섹션 9 방문 로그:
- visit_logs.map (visited_at 최근→오래된 — 이미 서버 정렬됨)
- 각 항목: visited_at relative / promoter_id (있으면 단축 표시) / step badge / outcome badge
/ notes 인용 / photo_urls 그리드 (있으면) / audio_url 다운로드 링크 / GPS (있으면 좌표)
- 빈 배열이면 "방문 기록 없음" 텍스트 + 메모 추가 폼 영역만
섹션 10 점주 다른 매장:
- lead.owner null이면 섹션 hide
- lead.owner 카드: name (없으면 phone 일부 마스킹) / phone (tel:) / total_payback (₩ format)
- lead.other_leads.map 카드 그리드:
"→ {store_name}" (Link href={`/keeper-out/admin/leads/${id}`}) / status badge / scenario 혜택 / created_at relative
other_leads 빈 배열이면 "다른 매장 없음"
섹션 11 매장 마스터:
- lead.place null이면 섹션 hide
- 강조: closure_rate_pct (반경500m 폐업률) + priority_score (영업 우선순위) — 큰 stat 카드
- 보조: address / road_address / district / area_sqm / ceiling_height_m / business_reg_year
색깔: 키퍼 오렌지 #ff5c00 / hover #cc4900 / light #fff6f1 / border #e8e4db. 청록 절대 X.
커밋: feat(keeper-out/admin): lead detail P2 — 방문/점주/매장마스터 3섹션
(2.3) 섹션 13 메모 추가 폼
위치: 섹션 9 방문 로그 영역 안 (또는 그 직후)
폼 필드:
- step 드롭다운 (옵션: STEP1, STEP2, STEP3, STEP4)
- outcome 드롭다운 (옵션: pending, completed, rejected)
- notes textarea (max 500자, required)
- submit 버튼 (busy 상태 표시)
POST 호출:
fetch("/api/keeper-out/visits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lead_id: lead.id,
step,
outcome,
notes,
// promoter_id는 P3
}),
});
성공 시: 폼 초기화 + GET 재호출 (visit_logs 갱신). 실패 시 alert.
커밋: feat(keeper-out/admin): lead detail 메모 추가 폼 (visits POST 재사용)
[STAGE 3 — main 머지, ~5분]
순서 (절대 어기지 말 것):
1. git status — staging clean (의도한 untracked만 남음) 확인
2. npm run build — PASS
3. git push origin staging
4. (대기 ~2분) Vercel preview URL 확인 — 가장 최신 deployment 가져와서 200 응답 확인
curl -s -o /dev/null -w "%{http_code}\n" /keeper-out/admin
5. git checkout main
6. git pull origin main (최신 동기화)
7. git merge staging --ff-only ← fast-forward만
- 충돌 시 즉시 stop, PM에게 보고. 임의 해결 금지.
8. git push origin main
9. (대기 ~2분) https://www.keeper-direct.ceo/keeper-out/admin 200 확인
10. git checkout staging (작업 위치 복귀)
[가드레일 재확인]
- 청록 #009090 / #00b4b4 / #006B6B / #007575 / #e6f7f7 사용 X
- /diagnose / globals.css scraped 클래스 / "한화"·"키퍼"·"하나비전" 텍스트 X
- next.config / package.json 수정 X
- main 강제 푸시 / --no-verify / --no-ff X
- untracked 룰: P1 때 보존한 13개 파일/디렉토리 그대로 둘 것
(CLAUDE.md, lib/keeper-out/catchrank.ts, app/landing/, context/,
data/, scripts/, docs/, public/assets/, app/api/keeper-out/catchrank/sync-places/)
[검증 — 각 stage 끝마다]
1. npx tsc --noEmit → 0
2. npx eslint <변경 파일들> → 0
3. npm run build → 성공
4. (가능 시) 실제 lead URL 열어서 신규 섹션 데이터 확인
[보고 형식 — STAGE 3 끝나면 한 번에]
## P1.5 + P2 + 머지 작업 결과 보고
### 1. 커밋 목록 (sha + 메시지, 4개)
- refactor(keeper-out/admin): root layout에서 마케팅 헤더 격리
- feat(keeper-out/admin): GET endpoint에 visit_logs/place/owner embed + other_leads
- feat(keeper-out/admin): lead detail P2 — 방문/점주/매장마스터 3섹션
- feat(keeper-out/admin): lead detail 메모 추가 폼 (visits POST 재사용)
### 2. layout 분리 — 어느 방법(A/B) 채택, 이유
### 3. 변경 파일 (신규/수정 분리)
### 4. 검증 결과 — 각 stage별
- STAGE 1 끝: tsc PASS / build PASS / 시각 확인
- STAGE 2 끝: tsc PASS / build PASS / 실제 lead URL에서 신규 섹션 보이는지
- STAGE 3 끝: staging push 후 preview URL 200 / main fast-forward 성공 /
production URL 200 (https://www.keeper-direct.ceo/keeper-out/admin)
### 5. 결정 사항 (자체 판단한 것 명시)
### 6. 막힌 곳 / 의문 (있으면)
### 7. 다음 후속 후보 (있으면)
[시작]
지금 STAGE 1부터 진행. 각 STAGE 완료 후 다음으로. 충돌 발생 시 즉시 stop.
git log origin/main -1)www.keeper-direct.ceo/keeper-out/admin/leads/<id> 8섹션 → 12섹션 확장됐는지