From 67a8201d82f03806b549981eca1de049d48863d4 Mon Sep 17 00:00:00 2001 From: son0307 <162094052+son0307@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:58:54 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=95=9C=EA=B5=AD=EC=96=B4?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=ED=8E=B8=EC=A7=91=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 2 +- frontend/src/api.ts | 2 + frontend/src/pages/AdminPage.tsx | 961 ++++++++++++------ frontend/src/pages/FixtureDetailPage.tsx | 6 +- frontend/src/pages/PlayerDetailPage.tsx | 2 +- frontend/src/pages/TeamDetailPage.tsx | 9 +- frontend/src/styles.css | 130 ++- .../soccerStreaming/admin/dto/AdminDto.java | 4 + .../admin/service/AdminService.java | 33 +- .../fixture/dto/FixtureResponseDto.java | 1 + .../fixture/entity/Fixture.java | 5 + .../fixture/service/FixtureService.java | 1 + .../team/dto/TeamResponseDto.java | 1 + .../soccerStreaming/team/entity/Venue.java | 5 + .../team/service/TeamService.java | 1 + .../admin/service/AdminServiceTest.java | 1 + 16 files changed, 792 insertions(+), 372 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f23d3ef..a884a6a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -77,7 +77,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 66d38bb..11cafcc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -5,6 +5,7 @@ export type FixtureSummary = { round: number | null; referee: string | null; venueName: string | null; + venueNameKo: string | null; homeTeamId: number | null; awayTeamId: number | null; homeTeamName: string | null; @@ -404,6 +405,7 @@ export type TeamNewsTranslationResult = { export type TeamVenue = { venueId: number | null; venueName: string | null; + venueNameKo: string | null; venueAddress: string | null; venueCity: string | null; capacity: number | null; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 7d1f2c1..2783ed4 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useRef, useState } from "react"; import { ChevronDown, LoaderCircle, X } from "lucide-react"; -import { Navigate, useSearchParams } from "react-router-dom"; +import { Navigate, NavLink, useLocation, useSearchParams } from "react-router-dom"; import { clearApiMemoryCache, fetchFixtures, @@ -16,6 +16,7 @@ type AdminPageProps = { }; type AdminTab = "team" | "player" | "fixture"; +type AdminSection = "editor" | "sync" | "logs"; type AdminMediaTargetType = "PLAYER_PHOTO" | "TEAM_LOGO" | "VENUE_IMAGE"; type AdminMediaToast = { message: string; @@ -41,6 +42,14 @@ function adminTab(value: string | null): AdminTab | null { return value === "team" || value === "player" || value === "fixture" ? value : null; } +function adminSection(pathname: string): AdminSection | null { + const normalizedPath = pathname.replace(/\/+$/, "") || "/"; + if (normalizedPath === "/admin/editor") return "editor"; + if (normalizedPath === "/admin/sync") return "sync"; + if (normalizedPath === "/admin/logs") return "logs"; + return null; +} + type FieldKind = "text" | "number" | "datetime" | "date" | "boolean" | "select"; type FieldOption = { @@ -163,6 +172,7 @@ type FixtureTeamStatAdmin = Record & { type FixturePlayerStatAdmin = Record & { playerId: number; + teamId: number; playerName: string | null; playerNameKo: string | null; teamName: string | null; @@ -235,6 +245,14 @@ type SyncJob = { errors: SyncJobError[]; }; +type AdminSyncResponse = { + jobId?: number; + task?: string; + success?: boolean; + queued?: boolean; + message: string; +}; + type AuditLogPage = { logs: AuditLog[]; page: number; @@ -248,165 +266,223 @@ const SYNC_JOB_POLL_INTERVAL_MS = 2_000; const SYNC_JOB_RETRY_INTERVAL_MS = 5_000; const ADMIN_SEARCH_KEYWORD_MAX_LENGTH = 80; const MANUAL_SYNC_COOLDOWN_MS = 30_000; +const MANUAL_SYNC_COOLDOWN_STORAGE_PREFIX = "admin-sync-cooldown"; const ADMIN_IMAGE_MAX_BYTES = 2 * 1024 * 1024; const ADMIN_IMAGE_CONTENT_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); +function syncCooldownStorageKey(cooldownKey: string) { + return `${MANUAL_SYNC_COOLDOWN_STORAGE_PREFIX}:${cooldownKey}`; +} + +function readStoredSyncCooldowns(): Record { + const cooldowns: Record = {}; + const now = Date.now(); + const storagePrefix = `${MANUAL_SYNC_COOLDOWN_STORAGE_PREFIX}:`; + try { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const storageKey = window.localStorage.key(index); + if (!storageKey?.startsWith(storagePrefix)) { + continue; + } + const cooldownUntil = Number(window.localStorage.getItem(storageKey)); + if (Number.isFinite(cooldownUntil) && cooldownUntil > now) { + cooldowns[storageKey.slice(storagePrefix.length)] = cooldownUntil; + } else { + window.localStorage.removeItem(storageKey); + } + } + } catch { + // Keep the page usable when localStorage is unavailable. + } + return cooldowns; +} + +function storeSyncCooldown(cooldownKey: string, cooldownUntil: number) { + try { + window.localStorage.setItem(syncCooldownStorageKey(cooldownKey), String(cooldownUntil)); + } catch { + // The in-memory cooldown still applies while this page remains open. + } +} + +function removeStoredSyncCooldown(cooldownKey: string) { + try { + window.localStorage.removeItem(syncCooldownStorageKey(cooldownKey)); + } catch { + // Expired in-memory cooldowns can still be removed when storage is unavailable. + } +} + const teamFields: FieldConfig[] = [ - { name: "name", label: "Name" }, - { name: "koreanName", label: "Korean Name" }, - { name: "code", label: "Code" }, - { name: "country", label: "Country" }, - { name: "founded", label: "Founded", kind: "number" }, - { name: "logoUrl", label: "Logo URL" }, - { name: "venueId", label: "Venue ID", kind: "number" }, - { name: "venueName", label: "Venue Name" }, - { name: "venueAddress", label: "Venue Address" }, - { name: "venueCity", label: "Venue City" }, - { name: "capacity", label: "Capacity", kind: "number" }, - { name: "surface", label: "Surface" }, - { name: "venueImageUrl", label: "Venue Image URL" }, + { name: "name", label: "이름" }, + { name: "koreanName", label: "한글 이름" }, + { name: "code", label: "팀 코드" }, + { name: "country", label: "국가" }, + { name: "founded", label: "창단 연도", kind: "number" }, + { name: "logoUrl", label: "로고 URL" }, + { name: "venueName", label: "경기장 이름" }, + { name: "venueNameKo", label: "경기장 한글 이름" }, + { name: "venueAddress", label: "경기장 주소" }, + { name: "venueCity", label: "경기장 도시" }, + { name: "capacity", label: "수용 인원", kind: "number" }, + { name: "surface", label: "경기장 표면" }, + { name: "venueImageUrl", label: "경기장 이미지 URL" }, ]; const playerFields: FieldConfig[] = [ - { name: "name", label: "Name" }, - { name: "koreanName", label: "Korean Name" }, - { name: "firstname", label: "Firstname" }, - { name: "lastname", label: "Lastname" }, - { name: "age", label: "Age", kind: "number" }, - { name: "birthDate", label: "Birth Date", kind: "date" }, - { name: "birthPlace", label: "Birth Place" }, - { name: "birthCountry", label: "Birth Country" }, - { name: "nationality", label: "Nationality" }, - { name: "height", label: "Height", kind: "number" }, - { name: "weight", label: "Weight", kind: "number" }, - { name: "position", label: "Position" }, - { name: "number", label: "Number", kind: "number" }, - { name: "photoUrl", label: "Photo URL" }, + { name: "name", label: "이름" }, + { name: "koreanName", label: "한글 이름" }, + { name: "firstname", label: "이름 부분" }, + { name: "lastname", label: "성" }, + { name: "age", label: "나이", kind: "number" }, + { name: "birthDate", label: "생년월일", kind: "date" }, + { name: "birthPlace", label: "출생지" }, + { name: "birthCountry", label: "출생 국가" }, + { name: "nationality", label: "국적" }, + { name: "height", label: "키", kind: "number" }, + { name: "weight", label: "몸무게", kind: "number" }, + { name: "position", label: "포지션" }, + { name: "number", label: "등번호", kind: "number" }, + { name: "photoUrl", label: "사진 URL" }, ]; const fixtureFields: FieldConfig[] = [ - { name: "fixtureDate", label: "경기 일시", kind: "datetime", help: "한국 시간(KST) 기준으로 입력됩니다." }, - { name: "referee", label: "Referee" }, - { name: "venueId", label: "Venue ID", kind: "number" }, - { name: "venueName", label: "Venue Name" }, - { name: "venueCity", label: "Venue City" }, - { name: "homeFormation", label: "Home Formation" }, - { name: "awayFormation", label: "Away Formation" }, - { name: "homeCoachName", label: "Home Coach" }, - { name: "awayCoachName", label: "Away Coach" }, - { name: "homePlayerColorPrimary", label: "Home Player Primary", preview: "color" }, - { name: "homePlayerColorNumber", label: "Home Player Number", preview: "color" }, - { name: "homePlayerColorBorder", label: "Home Player Border", preview: "color" }, - { name: "homeGoalkeeperColorPrimary", label: "Home GK Primary", preview: "color" }, - { name: "homeGoalkeeperColorNumber", label: "Home GK Number", preview: "color" }, - { name: "homeGoalkeeperColorBorder", label: "Home GK Border", preview: "color" }, - { name: "awayPlayerColorPrimary", label: "Away Player Primary", preview: "color" }, - { name: "awayPlayerColorNumber", label: "Away Player Number", preview: "color" }, - { name: "awayPlayerColorBorder", label: "Away Player Border", preview: "color" }, - { name: "awayGoalkeeperColorPrimary", label: "Away GK Primary", preview: "color" }, - { name: "awayGoalkeeperColorNumber", label: "Away GK Number", preview: "color" }, - { name: "awayGoalkeeperColorBorder", label: "Away GK Border", preview: "color" }, + { name: "fixtureDate", label: "경기 일시", kind: "datetime", help: "한국 표준시 기준으로 입력됩니다." }, + { name: "referee", label: "주심" }, + { name: "venueId", label: "경기장 ID", kind: "number" }, + { name: "venueName", label: "경기장 이름" }, + { name: "venueNameKo", label: "경기장 한글 이름" }, + { name: "venueCity", label: "경기장 도시" }, + { name: "homeFormation", label: "홈팀 포메이션" }, + { name: "awayFormation", label: "원정팀 포메이션" }, + { name: "homeCoachName", label: "홈팀 감독" }, + { name: "awayCoachName", label: "원정팀 감독" }, + { name: "homePlayerColorPrimary", label: "홈팀 선수 유니폼", preview: "color" }, + { name: "homePlayerColorNumber", label: "홈팀 선수 등번호", preview: "color" }, + { name: "homePlayerColorBorder", label: "홈팀 선수 테두리", preview: "color" }, + { name: "homeGoalkeeperColorPrimary", label: "홈팀 골키퍼 유니폼", preview: "color" }, + { name: "homeGoalkeeperColorNumber", label: "홈팀 골키퍼 등번호", preview: "color" }, + { name: "homeGoalkeeperColorBorder", label: "홈팀 골키퍼 테두리", preview: "color" }, + { name: "awayPlayerColorPrimary", label: "원정팀 선수 유니폼", preview: "color" }, + { name: "awayPlayerColorNumber", label: "원정팀 선수 등번호", preview: "color" }, + { name: "awayPlayerColorBorder", label: "원정팀 선수 테두리", preview: "color" }, + { name: "awayGoalkeeperColorPrimary", label: "원정팀 골키퍼 유니폼", preview: "color" }, + { name: "awayGoalkeeperColorNumber", label: "원정팀 골키퍼 등번호", preview: "color" }, + { name: "awayGoalkeeperColorBorder", label: "원정팀 골키퍼 테두리", preview: "color" }, ]; const eventFields: FieldConfig[] = [ - { name: "teamId", label: "Team", kind: "select" }, - { name: "playerId", label: "Player", kind: "select" }, - { name: "assistPlayerId", label: "Assist Player", kind: "select" }, - { name: "elapsed", label: "Elapsed", kind: "number", min: 0, max: 90 }, - { name: "extra", label: "Extra", kind: "number", min: 0, max: 20 }, - { name: "eventType", label: "Type", kind: "select" }, - { name: "eventDetail", label: "Detail", kind: "select" }, - { name: "comments", label: "Comments" }, + { name: "teamId", label: "팀", kind: "select" }, + { name: "playerId", label: "선수", kind: "select" }, + { name: "assistPlayerId", label: "도움 선수", kind: "select" }, + { name: "elapsed", label: "경기 시간", kind: "number", min: 0, max: 90 }, + { name: "extra", label: "추가 시간", kind: "number", min: 0, max: 20 }, + { name: "eventType", label: "유형", kind: "select" }, + { name: "eventDetail", label: "상세 유형", kind: "select" }, + { name: "comments", label: "설명" }, ]; -const eventTypeOptions: FieldOption[] = ["Goal", "Card", "Subst", "Var"].map((value) => ({ value, label: value })); +const eventTypeOptions: FieldOption[] = [ + { value: "Goal", label: "득점" }, + { value: "Card", label: "카드" }, + { value: "Subst", label: "교체" }, + { value: "Var", label: "VAR" }, +]; const eventDetailOptionsByType: Record = { - Goal: ["Normal Goal", "Own Goal", "Penalty", "Missed Penalty"].map((value) => ({ value, label: value })), - Card: ["Yellow Card", "Red card"].map((value) => ({ value, label: value })), + Goal: [ + { value: "Normal Goal", label: "일반 득점" }, + { value: "Own Goal", label: "자책골" }, + { value: "Penalty", label: "페널티골" }, + { value: "Missed Penalty", label: "페널티 실축" }, + ], + Card: [ + { value: "Yellow Card", label: "옐로카드" }, + { value: "Red card", label: "레드카드" }, + ], Subst: Array.from({ length: 10 }, (_, index) => { const value = `Substitution ${index + 1}`; - return { value, label: value }; + return { value, label: `${index + 1}번째 교체` }; }), - Var: ["Goal cancelled", "Penalty confirmed"].map((value) => ({ value, label: value })), + Var: [ + { value: "Goal cancelled", label: "득점 취소" }, + { value: "Penalty confirmed", label: "페널티 확정" }, + ], }; const lineupFields: FieldConfig[] = [ - { name: "position", label: "Position" }, - { name: "grid", label: "Grid" }, - { name: "starter", label: "Starter", kind: "boolean" }, + { name: "position", label: "포지션" }, + { name: "grid", label: "배치 위치" }, + { name: "starter", label: "선발 여부", kind: "boolean" }, ]; const teamStatFields: FieldConfig[] = [ - "shotsOnGoal", - "shotsOffGoal", - "totalShots", - "blockedShots", - "shotsInsideBox", - "shotsOutsideBox", - "fouls", - "cornerKicks", - "offsides", - "ballPossession", - "yellowCards", - "redCards", - "goalkeeperSaves", - "totalPasses", - "passesAccurate", -].map((name) => ({ name, label: labelize(name), kind: "number" as const })).concat({ - name: "expectedGoals", - label: "Expected Goals", - kind: "number", -}); + { name: "shotsOnGoal", label: "유효 슈팅", kind: "number" }, + { name: "shotsOffGoal", label: "빗나간 슈팅", kind: "number" }, + { name: "totalShots", label: "전체 슈팅", kind: "number" }, + { name: "blockedShots", label: "막힌 슈팅", kind: "number" }, + { name: "shotsInsideBox", label: "박스 안 슈팅", kind: "number" }, + { name: "shotsOutsideBox", label: "박스 밖 슈팅", kind: "number" }, + { name: "fouls", label: "파울", kind: "number" }, + { name: "cornerKicks", label: "코너킥", kind: "number" }, + { name: "offsides", label: "오프사이드", kind: "number" }, + { name: "ballPossession", label: "점유율", kind: "number" }, + { name: "yellowCards", label: "옐로카드", kind: "number" }, + { name: "redCards", label: "레드카드", kind: "number" }, + { name: "goalkeeperSaves", label: "골키퍼 선방", kind: "number" }, + { name: "totalPasses", label: "전체 패스", kind: "number" }, + { name: "passesAccurate", label: "정확한 패스", kind: "number" }, + { name: "expectedGoals", label: "기대 득점(xG)", kind: "number" }, +]; const playerStatFields: FieldConfig[] = [ - { name: "minutesPlayed", label: "Minutes", kind: "number" }, - { name: "rating", label: "Rating", kind: "number" }, - { name: "captain", label: "Captain", kind: "boolean" }, - { name: "substitute", label: "Substitute", kind: "boolean" }, - ...[ - "goals", - "assists", - "conceded", - "saves", - "shotsTotal", - "shotsOnTarget", - "passesTotal", - "passesKey", - "passesAccurate", - "tacklesTotal", - "blocks", - "interceptions", - "duelsTotal", - "duelsWon", - "dribblesAttempts", - "dribblesSuccess", - "dribblesPast", - "foulsDrawn", - "foulsCommitted", - "yellowCards", - "redCards", - "offsides", - "penaltyWon", - "penaltyCommitted", - "penaltyScored", - "penaltyMissed", - "penaltySaved", - ].map((name) => ({ name, label: labelize(name), kind: "number" as const })), + { name: "minutesPlayed", label: "출전 시간", kind: "number" }, + { name: "rating", label: "평점", kind: "number" }, + { name: "captain", label: "주장 여부", kind: "boolean" }, + { name: "substitute", label: "교체 선수 여부", kind: "boolean" }, + { name: "goals", label: "득점", kind: "number" }, + { name: "assists", label: "도움", kind: "number" }, + { name: "conceded", label: "실점", kind: "number" }, + { name: "saves", label: "선방", kind: "number" }, + { name: "shotsTotal", label: "전체 슈팅", kind: "number" }, + { name: "shotsOnTarget", label: "유효 슈팅", kind: "number" }, + { name: "passesTotal", label: "전체 패스", kind: "number" }, + { name: "passesKey", label: "키 패스", kind: "number" }, + { name: "passesAccurate", label: "정확한 패스", kind: "number" }, + { name: "tacklesTotal", label: "태클", kind: "number" }, + { name: "blocks", label: "블록", kind: "number" }, + { name: "interceptions", label: "인터셉트", kind: "number" }, + { name: "duelsTotal", label: "전체 경합", kind: "number" }, + { name: "duelsWon", label: "경합 승리", kind: "number" }, + { name: "dribblesAttempts", label: "드리블 시도", kind: "number" }, + { name: "dribblesSuccess", label: "드리블 성공", kind: "number" }, + { name: "dribblesPast", label: "드리블 돌파 허용", kind: "number" }, + { name: "foulsDrawn", label: "얻은 파울", kind: "number" }, + { name: "foulsCommitted", label: "범한 파울", kind: "number" }, + { name: "yellowCards", label: "옐로카드", kind: "number" }, + { name: "redCards", label: "레드카드", kind: "number" }, + { name: "offsides", label: "오프사이드", kind: "number" }, + { name: "penaltyWon", label: "페널티 획득", kind: "number" }, + { name: "penaltyCommitted", label: "페널티 허용", kind: "number" }, + { name: "penaltyScored", label: "페널티 성공", kind: "number" }, + { name: "penaltyMissed", label: "페널티 실축", kind: "number" }, + { name: "penaltySaved", label: "페널티 선방", kind: "number" }, ]; const syncTasks = [ - { task: "seasons", label: "Seasons" }, - { task: "teams", label: "Teams" }, - { task: "standings", label: "Standings" }, - { task: "fixtures", label: "Fixtures" }, - { task: "fixture-details", label: "Season Details" }, - { task: "players", label: "Players" }, - { task: "injuries", label: "Injuries" }, + { task: "seasons", label: "지원 시즌" }, + { task: "teams", label: "팀" }, + { task: "standings", label: "순위" }, + { task: "fixtures", label: "경기" }, + { task: "fixture-details", label: "시즌 경기 상세" }, + { task: "players", label: "선수" }, + { task: "injuries", label: "부상자" }, ]; export function AdminPage({ authState }: AdminPageProps) { + const location = useLocation(); const [searchParams] = useSearchParams(); + const activeSection = adminSection(location.pathname); const [activeTab, setActiveTab] = useState("team"); const [teamKeyword, setTeamKeyword] = useState(""); const [teams, setTeams] = useState([]); @@ -436,10 +512,8 @@ export function AdminPage({ authState }: AdminPageProps) { detail: null, }); const [savingKey, setSavingKey] = useState(null); - const [syncFixtureId, setSyncFixtureId] = useState(""); - const [syncMessage, setSyncMessage] = useState(""); const [syncingTask, setSyncingTask] = useState(null); - const [syncCooldownUntil, setSyncCooldownUntil] = useState>({}); + const [syncCooldownUntil, setSyncCooldownUntil] = useState>(readStoredSyncCooldowns); const [syncClock, setSyncClock] = useState(Date.now()); const [syncStatuses, setSyncStatuses] = useState([]); const [seasonCoverages, setSeasonCoverages] = useState([]); @@ -478,12 +552,16 @@ export function AdminPage({ authState }: AdminPageProps) { useEffect(() => { if (authState.authStatus === "authenticated" && authState.currentUser?.role === "ADMIN") { - void reloadAuditLogs(0); void reloadSyncJobs(false); - void loadSyncStatuses(authState.season, setSyncStatuses); - void loadSeasonCoverages(setSeasonCoverages, setCoverageStatus); + if (activeSection === "sync") { + void loadSyncStatuses(authState.season, setSyncStatuses); + void loadSeasonCoverages(setSeasonCoverages, setCoverageStatus); + } + if (activeSection === "logs") { + void reloadAuditLogs(0); + } } - }, [authState.authStatus, authState.currentUser?.role, authState.season]); + }, [activeSection, authState.authStatus, authState.currentUser?.role, authState.season]); const hasActiveSyncJobs = syncJobs.some((job) => job.active); @@ -514,6 +592,34 @@ export function AdminPage({ authState }: AdminPageProps) { return () => window.clearInterval(timerId); }, [syncCooldownUntil, syncClock]); + useEffect(() => { + const expiredKeys = Object.entries(syncCooldownUntil) + .filter(([, until]) => until <= syncClock) + .map(([key]) => key); + if (expiredKeys.length === 0) { + return; + } + setSyncCooldownUntil((current) => { + const next = { ...current }; + expiredKeys.forEach((key) => delete next[key]); + return next; + }); + expiredKeys.forEach(removeStoredSyncCooldown); + }, [syncCooldownUntil, syncClock]); + + useEffect(() => { + function handleStorage(event: StorageEvent) { + if (event.key !== null && !event.key.startsWith(`${MANUAL_SYNC_COOLDOWN_STORAGE_PREFIX}:`)) { + return; + } + setSyncClock(Date.now()); + setSyncCooldownUntil(readStoredSyncCooldowns()); + } + + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + useEffect(() => { if (!mediaToast) { return; @@ -526,13 +632,18 @@ export function AdminPage({ authState }: AdminPageProps) { if ( authState.authStatus === "authenticated" && authState.currentUser?.role === "ADMIN" + && activeSection === "editor" ) { void loadFixtureTeams(authState.season); } - }, [authState.authStatus, authState.currentUser?.role, authState.season]); + }, [activeSection, authState.authStatus, authState.currentUser?.role, authState.season]); useEffect(() => { - if (authState.authStatus !== "authenticated" || authState.currentUser?.role !== "ADMIN") { + if ( + activeSection !== "editor" + || authState.authStatus !== "authenticated" + || authState.currentUser?.role !== "ADMIN" + ) { return; } const tab = adminTab(searchParams.get("tab")); @@ -556,7 +667,7 @@ export function AdminPage({ authState }: AdminPageProps) { return; } void selectFixture(id); - }, [authState.authStatus, authState.currentUser?.role, searchParams]); + }, [activeSection, authState.authStatus, authState.currentUser?.role, searchParams]); useEffect(() => { const target = pendingAdminTargetRef.current; @@ -613,6 +724,10 @@ export function AdminPage({ authState }: AdminPageProps) { return ; } + if (activeSection === null) { + return ; + } + async function searchTeams(event: FormEvent) { event.preventDefault(); await runRequest(async () => { @@ -747,7 +862,7 @@ export function AdminPage({ authState }: AdminPageProps) { try { const response = await fetchFixtures({ season: authState.season, teamId, size: 100 }); if (fixtureListRequestIdRef.current === requestId) { - setFixtures(response.content ?? []); + setFixtures(sortAdminFixturesByRound(response.content ?? [])); setFixtureListStatus("ready"); } } catch (nextError) { @@ -876,14 +991,16 @@ export function AdminPage({ authState }: AdminPageProps) { return; } const teamId = selectedTeam.teamId; - const body = formBody(event.currentTarget, teamFields); - await runSave(`team:${teamId}`, async () => { + const body = { + ...formBody(event.currentTarget, teamFields), + venueId: selectedTeam.venueId, + }; + await runToastSave(`team:${teamId}`, async () => { const updated = await adminJson(`/api/v1/admin/teams/${teamId}`, "PUT", body); clearApiMemoryCache(); setTeamSelection({ teamId, status: "ready", detail: updated }); - setMessage("팀 정보를 저장했습니다."); await reloadAuditLogs(); - }); + }, "팀 정보를 수정했습니다.", "팀 정보를 수정하지 못했습니다."); } async function savePlayer(event: FormEvent) { @@ -893,13 +1010,12 @@ export function AdminPage({ authState }: AdminPageProps) { } const playerId = selectedPlayer.playerId; const body = formBody(event.currentTarget, playerFields); - await runSave(`player:${playerId}`, async () => { + await runToastSave(`player:${playerId}`, async () => { const updated = await adminJson(`/api/v1/admin/players/${playerId}`, "PUT", body); clearApiMemoryCache(); setPlayerSelection({ playerId, status: "ready", detail: updated }); - setMessage("선수 정보를 저장했습니다."); await reloadAuditLogs(); - }); + }, "선수 정보를 수정했습니다.", "선수 정보를 수정하지 못했습니다."); } async function uploadAdminMedia( @@ -1050,6 +1166,33 @@ export function AdminPage({ authState }: AdminPageProps) { } } + async function runToastSave( + key: string, + action: () => Promise, + successMessage: string, + fallbackErrorMessage: string, + ): Promise { + if (savingKey !== null) { + const busyMessage = "다른 관리자 요청을 처리하고 있습니다. 잠시 후 다시 시도해주세요."; + setMediaToast({ message: busyMessage, type: "error" }); + return busyMessage; + } + setSavingKey(key); + setError(""); + setMessage(""); + try { + await action(); + setMediaToast({ message: successMessage, type: "success" }); + return null; + } catch (nextError) { + const requestError = nextError instanceof Error ? nextError.message : fallbackErrorMessage; + setMediaToast({ message: requestError, type: "error" }); + return requestError; + } finally { + setSavingKey(null); + } + } + async function runMediaSave(key: string, action: () => Promise, successMessage: string): Promise { if (savingKey !== null) { const busyMessage = "다른 관리자 요청을 처리하고 있습니다. 잠시 후 다시 시도해주세요."; @@ -1077,33 +1220,32 @@ export function AdminPage({ authState }: AdminPageProps) { return `${task}:${authState.season}`; } - function fixtureDetailCooldownKey(fixtureId: string) { - return `fixture-detail:${fixtureId}`; - } - function syncCooldownSeconds(key: string) { return Math.max(0, Math.ceil(((syncCooldownUntil[key] ?? 0) - syncClock) / 1000)); } function markSyncCooldown(key: string) { const now = Date.now(); + const cooldownUntil = now + MANUAL_SYNC_COOLDOWN_MS; + storeSyncCooldown(key, cooldownUntil); setSyncClock(now); setSyncCooldownUntil((current) => ({ ...current, - [key]: now + MANUAL_SYNC_COOLDOWN_MS, + [key]: cooldownUntil, })); } async function runSync(task: string) { const availability = syncAvailability(task, authState.season, seasonCoverages, coverageStatus); if (!availability.enabled) { - setSyncMessage(availability.message); + setMediaToast({ message: availability.message, type: "error" }); return; } const cooldownKey = manualSyncCooldownKey(task); const cooldownSeconds = syncCooldownSeconds(cooldownKey); if (cooldownSeconds > 0) { - setSyncMessage(`${cooldownSeconds}초 후 다시 요청할 수 있습니다.`); + const cooldownMessage = `${cooldownSeconds}초 후 다시 요청할 수 있습니다.`; + setMediaToast({ message: cooldownMessage, type: "error" }); return; } const league = 39; @@ -1119,54 +1261,46 @@ export function AdminPage({ authState }: AdminPageProps) { }; const url = urls[task]; if (!url) { - setSyncMessage("Fixture Detail 동기화는 먼저 경기를 선택해야 합니다."); + const missingTargetMessage = "경기 상세 동기화는 먼저 경기를 선택해야 합니다."; + setMediaToast({ message: missingTargetMessage, type: "error" }); return; } markSyncCooldown(cooldownKey); setSyncingTask(task); + setError(""); + setMessage(""); try { - await runRequest(async () => { - const result = await adminJson<{ jobId?: number; message: string }>(url, "POST"); - clearApiMemoryCache(); - setSyncMessage(result.message); - if (task === "seasons") { - await loadSeasonCoverages(setSeasonCoverages, setCoverageStatus); - } - await loadSyncStatuses(authState.season, setSyncStatuses); - await reloadAuditLogs(); - if (result.jobId) { - await reloadSyncJobs(true); - } - }); + const result = await adminJson(url, "POST"); + if (result.success === false) { + throw new Error(result.message); + } + clearApiMemoryCache(); + if (task === "seasons") { + await loadSeasonCoverages(setSeasonCoverages, setCoverageStatus); + } + await reloadAuditLogs(); + if (result.jobId) { + await reloadSyncJobs(true); + } + if (result.jobId || result.queued) { + setMediaToast({ message: `${syncRequestDisplayKey(task, season)} 동기화 요청됨`, type: "success" }); + } else { + setMediaToast({ message: `${syncRequestDisplayKey(task, season)} 동기화 완료`, type: "success" }); + } + } catch (nextError) { + const requestError = requestErrorMessage(nextError); + setMediaToast({ message: requestError, type: "error" }); } finally { + await reloadSyncStatusesSafely(); setSyncingTask(null); } } - async function syncFixtureDetail(event: FormEvent) { - event.preventDefault(); - const fixtureId = syncFixtureId.trim(); - if (!fixtureId) { - setSyncMessage("Fixture ID를 입력해주세요."); - return; - } - const cooldownKey = fixtureDetailCooldownKey(fixtureId); - const cooldownSeconds = syncCooldownSeconds(cooldownKey); - if (cooldownSeconds > 0) { - setSyncMessage(`${cooldownSeconds}초 후 다시 요청할 수 있습니다.`); - return; - } - markSyncCooldown(cooldownKey); - setSyncingTask("fixture-detail"); + async function reloadSyncStatusesSafely() { try { - await runRequest(async () => { - const result = await adminJson<{ message: string }>(`/api/v1/admin/sync/fixture-details/${encodeURIComponent(fixtureId)}`, "POST"); - clearApiMemoryCache(); - setSyncMessage(result.message); - await reloadAuditLogs(); - }); - } finally { - setSyncingTask(null); + await loadSyncStatuses(authState.season, setSyncStatuses); + } catch { + // 상태 조회 실패가 이미 완료된 동기화 요청의 성공·실패 결과를 바꾸지 않도록 분리한다. } } @@ -1180,8 +1314,6 @@ export function AdminPage({ authState }: AdminPageProps) { const queuedSyncJobs = syncJobs.filter((job) => job.status === "QUEUED"); const completedSyncJobs = syncJobs.filter((job) => !job.active); const selectedCoverage = seasonCoverages.find((coverage) => coverage.seasonYear === authState.season) ?? null; - const fixtureDetailCooldownSeconds = syncFixtureId.trim() ? syncCooldownSeconds(fixtureDetailCooldownKey(syncFixtureId.trim())) : 0; - async function runRequest(action: () => Promise): Promise { setError(""); setMessage(""); @@ -1211,16 +1343,36 @@ export function AdminPage({ authState }: AdminPageProps) { ) : null}
-

Admin

-

관리자 페이지

+

관리자

+

+ {activeSection === "editor" + ? "데이터 편집" + : activeSection === "sync" + ? "외부 API 동기화" + : "관리자 로그"} +

{authState.currentUser.email}
+ + + {activeSection === "editor" ? ( + <> {message ?
{message}
: null} {error ?
{error}
: null} -