o2o-site-AEO/admin/frontend/src/pages/LocalContentPage.tsx
Mina Choi 64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:02 +09:00

222 lines
11 KiB
TypeScript

import {useEffect, useMemo, useState} from 'react';
import {CalendarDays, Check, Download, ExternalLink, MapPin, Search} from 'lucide-react';
import {PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card, CardContent} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {customFetch} from '@/api/mutator/custom-fetch';
import {toast} from 'sonner';
type Status = 1 | 2 | 3;
// LocalContentType — common/enums.py 와 값을 맞춘다. WEATHER(1) 은 사업장 발행본에 실시간으로
// 붙는 별도 흐름이라 이 화면에서는 다루지 않는다(services/local_content_service.get_weather).
type ContentType = 2 | 3 | 4;
type LocalContent = {
id: string;
contentType: ContentType;
title: string;
region: string;
period: string;
source: string;
status: Status;
selected: boolean;
displayStart?: string;
displayEnd?: string;
imageUrl?: string;
};
const STATUS_LABEL: Record<Status, string> = {1: '검수 대기', 2: '발행됨', 3: '종료됨'};
const TYPE_LABEL: Record<ContentType, string> = {2: '축제', 3: '관광지', 4: '맛집'};
const TYPE_BADGE_VARIANT: Record<ContentType, 'accent' | 'default' | 'outline'> = {
2: 'accent', 3: 'default', 4: 'outline',
};
type ApiContent = {
local_content_id: string;
region_code: string;
content_type: ContentType;
external_id?: string;
title?: string;
body: Record<string, unknown>;
status: Status;
source: number;
display_start_at?: string;
display_end_at?: string;
};
function formatDate(value: unknown) {
const text = String(value ?? '');
return /^\d{8}$/.test(text) ? `${text.slice(0, 4)}. ${Number(text.slice(4, 6))}. ${Number(text.slice(6, 8))}` : text;
}
function toContent(item: ApiContent): LocalContent {
// 기간은 축제만 있다(eventstartdate/eventenddate) — 관광지·맛집은 상시 정보라 비어 있는 게 정상이다.
const start = formatDate(item.body.eventstartdate);
const end = formatDate(item.body.eventenddate);
const period = [start, end].filter(Boolean).join(' ~ ') || (item.content_type === 2 ? '기간 미정' : '상시');
return {
id: item.local_content_id,
contentType: item.content_type,
title: item.title || '제목 없음',
region: String(item.body.addr1 || item.region_code),
period,
source: `한국관광공사 TourAPI · ${item.external_id ?? '-'}`,
status: item.status,
selected: false,
displayStart: item.display_start_at,
displayEnd: item.display_end_at,
imageUrl: String(item.body.image_url || item.body.firstimage || ''),
};
}
export function LocalContentPage() {
const [contents, setContents] = useState<LocalContent[]>([]);
const [query, setQuery] = useState('');
const [syncing, setSyncing] = useState(false);
const [loading, setLoading] = useState(true);
const filtered = useMemo(() => contents.filter((item) => `${item.title} ${item.region}`.includes(query.trim())), [contents, query]);
const selectedCount = contents.filter((item) => item.selected).length;
const allFilteredSelected = filtered.length > 0 && filtered.every((item) => item.selected);
const toggle = (id: string) => setContents((items) => items.map((item) => item.id === id ? {...item, selected: !item.selected} : item));
const toggleAll = () => {
const visibleIds = new Set(filtered.map((item) => item.id));
setContents((items) => items.map((item) => visibleIds.has(item.id) ? {...item, selected: !allFilteredSelected} : item));
};
const load = async () => {
setLoading(true);
try {
const res = await customFetch<{contents?: ApiContent[]}>({url: '/v1/admin/local-content', method: 'GET'});
setContents((res.contents ?? []).map(toContent));
} catch {
toast.error('지역 콘텐츠를 불러오지 못했습니다. 개발자 계정인지 확인해주세요.');
} finally {
setLoading(false);
}
};
useEffect(() => { void load(); }, []);
const publishSelected = async () => {
const ids = contents.filter((item) => item.selected).map((item) => item.id);
try {
await customFetch({url: '/v1/admin/local-content/publish', method: 'POST', data: {content_ids: ids}});
toast.success(`${ids.length}건을 발행했습니다.`);
await load();
} catch { toast.error('발행하지 못했습니다.'); }
};
const sync = async () => {
// ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다.
// 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다.
const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim();
if (!placeId) return;
setSyncing(true);
try {
const res = await customFetch<{
result?: {success?: boolean}; msg?: string;
festivals?: number; attractions?: number; restaurants?: number; changed?: boolean;
}>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'});
if (res.result?.success === false) throw new Error(res.msg);
// ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다.
const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`;
if (!res.changed) {
toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`);
} else {
toast.success(`${summary} 반영 — 다음 빌드부터 발행본에 실립니다.`);
}
await load();
} catch (error) {
toast.error(error instanceof Error ? error.message : '공공데이터 수집에 실패했습니다.');
} finally { setSyncing(false); }
};
const edit = async (item: LocalContent) => {
const title = window.prompt('노출 제목을 입력하세요.', item.title)?.trim();
if (!title) return;
const start = window.prompt('노출 시작 시각(예: 2026-09-01T09:00, 비우면 미설정)', item.displayStart?.slice(0, 16) ?? '')?.trim();
const end = window.prompt('노출 종료 시각(예: 2026-09-07T22:00, 비우면 미설정)', item.displayEnd?.slice(0, 16) ?? '')?.trim();
try {
await customFetch({
url: `/v1/admin/local-content/${item.id}`,
method: 'PATCH',
data: {title, display_start_at: start ? new Date(start).toISOString() : null, display_end_at: end ? new Date(end).toISOString() : null},
});
toast.success('콘텐츠와 노출 일정을 저장했습니다.');
await load();
} catch { toast.error('콘텐츠를 수정하지 못했습니다.'); }
};
const endContent = async (item: LocalContent) => {
if (!window.confirm(`'${item.title}' 발행을 종료할까요?`)) return;
try {
await customFetch({url: `/v1/admin/local-content/${item.id}/end`, method: 'POST'});
toast.success('발행을 종료했습니다.');
await load();
} catch { toast.error('발행을 종료하지 못했습니다.'); }
};
return (
<PageContainer
title="지역 콘텐츠"
description="한국관광공사 TourAPI 에서 지역 축제·관광지·맛집을 가져와 자동 발행합니다. 필요하면 여기서 수정하거나 발행을 종료할 수 있습니다."
actions={<Button onClick={sync} disabled={syncing}><Download className="size-4" />{syncing ? '수집 중…' : '공공데이터 수집'}</Button>}
>
<div className="mb-4 grid gap-3 sm:grid-cols-3">
<Summary label="전체 콘텐츠" value={contents.length} />
<Summary label="검수 대기" value={contents.filter((item) => item.status === 1).length} />
<Summary label="발행 중" value={contents.filter((item) => item.status === 2).length} />
</div>
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex w-full items-center gap-3 sm:max-w-lg">
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium">
<input type="checkbox" checked={allFilteredSelected} onChange={toggleAll} disabled={!filtered.length} className="size-4 accent-primary" />
전체 선택
</label>
<div className="relative min-w-0 flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="축제명 또는 지역 검색" className="pl-9" />
</div>
</div>
<Button onClick={publishSelected} disabled={!selectedCount} variant="primary">
<Check className="size-4" /> 선택 {selectedCount}건 발행
</Button>
</div>
<div className="space-y-2">
{loading && <p className="py-10 text-center text-xs text-muted-foreground">콘텐츠를 불러오는 중…</p>}
{!loading && !filtered.length && <p className="rounded-xl border border-dashed py-12 text-center text-xs text-muted-foreground">수집된 지역 콘텐츠가 없습니다.</p>}
{filtered.map((item) => (
<Card key={item.id} className={item.selected ? 'border-primary' : ''}>
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<input type="checkbox" checked={item.selected} onChange={() => toggle(item.id)} className="size-4 accent-primary" aria-label={`${item.title} 선택`} />
{item.imageUrl && <img src={item.imageUrl} alt="" className="h-20 w-28 rounded-lg object-cover" />}
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold">{item.title}</h2>
<Badge variant={TYPE_BADGE_VARIANT[item.contentType]}>{TYPE_LABEL[item.contentType]}</Badge>
<Badge variant={item.status === 2 ? 'success' : 'warning'}>{STATUS_LABEL[item.status]}</Badge>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><MapPin className="size-3.5" />{item.region}</span>
<span className="flex items-center gap-1"><CalendarDays className="size-3.5" />{item.period}</span>
</div>
<p className="mt-1.5 text-[11px] text-muted-foreground">출처: {item.source} · 외부 ID {item.id}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => void edit(item)}><ExternalLink className="size-3.5" /> 수정·스케줄</Button>
{item.status === 2 && <Button variant="destructive" size="sm" onClick={() => void endContent(item)}>발행 종료</Button>}
</div>
</CardContent>
</Card>
))}
</div>
</PageContainer>
);
}
function Summary({label, value}: {label: string; value: number}) {
return <Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">{label}</p><p className="mt-1 text-2xl font-semibold">{value}</p></CardContent></Card>;
}