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 = {1: '검수 대기', 2: '발행됨', 3: '종료됨'}; const TYPE_LABEL: Record = {2: '축제', 3: '관광지', 4: '맛집'}; const TYPE_BADGE_VARIANT: Record = { 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; 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([]); 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 ( {syncing ? '수집 중…' : '공공데이터 수집'}} >
item.status === 1).length} /> item.status === 2).length} />
setQuery(event.target.value)} placeholder="축제명 또는 지역 검색" className="pl-9" />
{loading &&

콘텐츠를 불러오는 중…

} {!loading && !filtered.length &&

수집된 지역 콘텐츠가 없습니다.

} {filtered.map((item) => ( toggle(item.id)} className="size-4 accent-primary" aria-label={`${item.title} 선택`} /> {item.imageUrl && }

{item.title}

{TYPE_LABEL[item.contentType]} {STATUS_LABEL[item.status]}
{item.region} {item.period}

출처: {item.source} · 외부 ID {item.id}

{item.status === 2 && }
))}
); } function Summary({label, value}: {label: string; value: number}) { return

{label}

{value}

; }