## 업종 교체 (tour → clinic) PlaceCategory 코드 4번의 의미를 바꾼다. 아직 배포 전이라 데이터 마이그레이션은 없다. - category_schema: tour_activity.json → clinic.json. 체험 스키마(안전 유의사항·우천 시 운영·준비물)를 진료 스키마(진료과목·의료진·상담료·보험 적용·야간/주말진료)로 바꿨다. unit 은 프로그램 → 시술이다(마취 방식·회복 기간·권장 횟수·시술 후 주의사항). - 소개문 계열만 allow_llm 이다. 시술 효과·비용 같은 값은 LLM 이 못 쓴다 — 이 레포의 "검증 전에는 발행 금지" 규칙이 의료 문구에서 특히 중요하다. - jsonld: TouristAttraction → MedicalClinic. 프론트 AeoReadiness 의 같은 표도 맞췄다. - 색 팔레트를 병원 톤(클린 블루·세이지·누드·모노)으로, 아이콘을 Compass → Stethoscope 로. - mock_adapter 목데이터를 시술 기준으로 교체. 스키마에 없는 key 를 쓰면 수집이 죽는다. - site_payload 의 기본 섹션표를 에디터(industryData)와 같게 맞췄다 — test_site_theme 이 이 둘을 대조한다. ## 로그인 관문 되돌리기 (b94daa9·d6a6c8e revert) 두 커밋이 /builder 를 통째로 RequireAuth 뒤로 옮겨 `/` 가 곧바로 로그인 화면이 됐다. `/` 는 자기 화면 없이 /builder 로 넘기기만 하므로, 문 앞 가드는 곧 루트 가드다. 위저드를 열어 두고 에디터 진입에서 한 번 받는969fb67설계로 되돌린다.d6a6c8e가 스스로 "969fb67 과 정면으로 다른 설계"라고 적어 두었다. ## 그 밖 - test_site_theme 의 경로가 solution/front 로 남아 있었다(frontend 개명 누락). - .dockerignore: 이 머신에 buildx 가 없어 레거시 빌더가 돌고, 그러면 nginx/Dockerfile.dockerignore 가 무시된다. 루트 것 하나로 두 이미지를 다 커버한다. 검증: frontend·admin·site lint·build 0. 백엔드 534 passed / 4 failed — 그 4개(test_build_publish 3 · test_snapshot 1)는 이 변경 전부터 실패하던 것이다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xa8ME5FQJy4VA8pPokTo1a
210 lines
8.6 KiB
TypeScript
210 lines
8.6 KiB
TypeScript
import {useState} from 'react';
|
|
import {Link} from 'react-router';
|
|
import {keepPreviousData} from '@tanstack/react-query';
|
|
import {
|
|
Building2,
|
|
Loader2,
|
|
Pencil,
|
|
Plus,
|
|
ShieldCheck,
|
|
ShieldQuestion,
|
|
SlidersHorizontal,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import {PlaceCategory, PlaceStatus} from '@o2o/shared';
|
|
import {deletePlace, useListPlaces} from '@/api';
|
|
import {EmptyState, PageContainer} from '@/components/layout/AppShell';
|
|
import {builderUrl} from '@admin/lib/solutionUrl';
|
|
import {Badge} from '@/components/ui/badge';
|
|
import {Button} from '@/components/ui/button';
|
|
import {Input} from '@/components/ui/input';
|
|
import {notify, notifyApiError} from '@/lib/notify';
|
|
|
|
const CATEGORY_LABEL: Record<number, string> = {
|
|
[PlaceCategory.LODGING]: '숙박',
|
|
[PlaceCategory.CAFE]: '카페',
|
|
[PlaceCategory.RESTAURANT]: '음식점',
|
|
[PlaceCategory.CLINIC]: '피부과·성형외과',
|
|
};
|
|
|
|
const STATUS_LABEL: Record<number, string> = {
|
|
[PlaceStatus.DRAFT]: '등록됨',
|
|
[PlaceStatus.COLLECTING]: '수집 중',
|
|
[PlaceStatus.REVIEW]: '발행 전',
|
|
[PlaceStatus.PUBLISHED]: '발행됨',
|
|
[PlaceStatus.SUSPENDED]: '중지',
|
|
};
|
|
|
|
/**
|
|
* 사업장 목록 — 이 제품의 허브다.
|
|
*
|
|
* 흐름은 하나뿐이다:
|
|
* 빌더(위저드)로 만든다 → **여기 생긴다** → 여기서 에디터로 들어가 고친다 → 재발행하면 HTML 이 다시 구워진다.
|
|
*
|
|
* ★ 그래서 줄을 누르면 사업장 상세가 아니라 **에디터**로 간다. 목록에 온 사장님의
|
|
* 용건은 열에 아홉 "내 사이트 고치기"다. fact 를 하나씩 확인하는 상세 화면은
|
|
* [정보 확인] 으로 따로 둔다 — 발행 게이트에 걸렸을 때 가는 곳이다.
|
|
*/
|
|
export function PlaceListPage() {
|
|
const [search, setSearch] = useState('');
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
|
|
const {data, isLoading, isError, error, refetch} = useListPlaces(
|
|
{search: search || undefined, size: 50},
|
|
// 검색어를 칠 때마다 목록이 빈 화면으로 깜빡이지 않게 직전 렌더값을 유지한다(캐시가 아니다).
|
|
{query: {placeholderData: keepPreviousData}},
|
|
);
|
|
|
|
const places = data?.places ?? [];
|
|
|
|
const handleDelete = async (placeId: string, name: string) => {
|
|
if (!window.confirm(`'${name}' 사업장을 삭제할까요?`)) return;
|
|
setDeletingId(placeId);
|
|
try {
|
|
const res = await deletePlace(placeId);
|
|
if (!res.result?.success) {
|
|
notifyApiError({data: res}, '사업장을 삭제하지 못했습니다.');
|
|
return;
|
|
}
|
|
notify.success('사업장을 삭제했습니다.');
|
|
await refetch();
|
|
} catch (deleteError) {
|
|
notifyApiError(deleteError, '사업장을 삭제하지 못했습니다.');
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PageContainer
|
|
title="사업장"
|
|
description="빌더로 만든 사이트가 여기 쌓입니다. 줄을 누르면 에디터에서 바로 고칠 수 있어요."
|
|
actions={
|
|
<>
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="상호명 · 주소 검색"
|
|
className="w-56"
|
|
/>
|
|
{/* 새로 만드는 길은 위저드다 — `?new=1` 이 지난번 편집 상태를 비우고 1단계부터 연다. */}
|
|
<a
|
|
href={builderUrl({isNew: true})}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
|
>
|
|
<Plus className="size-3.5" />
|
|
<span>새 사이트 만들기</span>
|
|
</a>
|
|
</>
|
|
}
|
|
>
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
|
<Loader2 className="size-5 animate-spin" />
|
|
</div>
|
|
) : isError ? (
|
|
<EmptyState
|
|
icon={Building2}
|
|
title="사업장을 불러오지 못했습니다"
|
|
description={
|
|
(error as Error)?.message ??
|
|
'백엔드(기본 http://localhost:9800)가 떠 있는지 확인해 주세요.'
|
|
}
|
|
/>
|
|
) : places.length === 0 ? (
|
|
<EmptyState
|
|
icon={Building2}
|
|
title="아직 만든 사이트가 없습니다"
|
|
description="상호명 하나만 있으면 시작할 수 있습니다. 주소·좌표는 동일 업소 검증이 채웁니다."
|
|
action={
|
|
<a
|
|
href={builderUrl({isNew: true})}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
|
>
|
|
<Plus className="size-3.5" />
|
|
<span>새 사이트 만들기</span>
|
|
</a>
|
|
}
|
|
/>
|
|
) : (
|
|
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
|
|
{places.map((place) => (
|
|
<li key={place.place_id} className="flex items-center">
|
|
{/* 줄 전체가 에디터로 가는 링크다 — 목록에 온 용건이 편집이라서다. */}
|
|
<a
|
|
href={builderUrl({placeId: place.place_id})}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="flex min-w-0 flex-1 items-center justify-between gap-4 px-4 py-3 transition-colors hover:bg-muted/50"
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold">{place.name}</p>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{place.road_address ?? place.address ?? '주소 미확정'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<Badge variant="outline">{CATEGORY_LABEL[place.category] ?? '기타'}</Badge>
|
|
<Badge
|
|
variant={place.status === PlaceStatus.PUBLISHED ? 'success' : 'default'}
|
|
>
|
|
{STATUS_LABEL[place.status] ?? '알 수 없음'}
|
|
</Badge>
|
|
{/* ★ verified_at 이 NULL 이면 수집·발행 진입 금지. 목록에서 바로 보이게 둔다. */}
|
|
{place.verified_at ? (
|
|
<span title="동일 업소 검증 완료" className="text-success">
|
|
<ShieldCheck className="size-4" />
|
|
</span>
|
|
) : (
|
|
<span title="동일 업소 검증 전 — 수집이 열리지 않습니다" className="text-warning">
|
|
<ShieldQuestion className="size-4" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</a>
|
|
|
|
<div className="flex shrink-0 items-center gap-1 pr-3">
|
|
<a
|
|
href={builderUrl({placeId: place.place_id})}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-2.5 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
<span className="hidden sm:inline">사이트 편집</span>
|
|
</a>
|
|
{/* 발행 게이트에 걸렸을 때 가는 곳 — 수집된 값을 확인·수정한다. */}
|
|
<Link
|
|
to={`/places/${place.place_id}`}
|
|
title="수집 정보 확인 · 채널 관리"
|
|
className="inline-flex size-8 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
>
|
|
<SlidersHorizontal className="size-3.5" />
|
|
</Link>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-muted-foreground hover:text-destructive"
|
|
aria-label={`${place.name} 삭제`}
|
|
title="사업장 삭제"
|
|
isLoading={deletingId === place.place_id}
|
|
disabled={deletingId !== null}
|
|
onClick={() => handleDelete(place.place_id, place.name)}
|
|
>
|
|
<Trash2 />
|
|
</Button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|