From c9483d94c8c430dc1341fb0ce0bd27f36bf188fb Mon Sep 17 00:00:00 2001 From: max747 Date: Sun, 12 Jul 2026 18:35:25 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E5=A0=B1=E5=91=8A=E9=99=A4=E5=A4=96?= =?UTF-8?q?=E3=81=AE=20admin=20view=20=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/.env.example | 1 + admin/src/api/client.ts | 21 +- admin/src/components/QuestReportManager.tsx | 257 ++++++++++++++++++++ admin/src/pages/EventFormPage.tsx | 10 + admin/src/types/index.ts | 17 ++ 5 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 admin/src/components/QuestReportManager.tsx diff --git a/admin/.env.example b/admin/.env.example index 727d9dd..5f233ad 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -1,3 +1,4 @@ VITE_API_URL=https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com VITE_COGNITO_USER_POOL_ID=ap-northeast-1_xxxxxxxxx VITE_COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx +VITE_DATA_URL=https://xxxxxxxxxx.cloudfront.net diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts index 4169bd4..ac3621d 100644 --- a/admin/src/api/client.ts +++ b/admin/src/api/client.ts @@ -1,7 +1,8 @@ import { fetchAuthSession } from "aws-amplify/auth"; -import type { EventData, EventsResponse, Exclusion, HarvestQuest } from "../types"; +import type { EventData, EventsResponse, Exclusion, HarvestQuest, QuestData } from "../types"; const API_URL = import.meta.env.VITE_API_URL as string; +const DATA_URL = import.meta.env.VITE_DATA_URL as string; /** * Cognito の idToken を Bearer トークンとして含む認証ヘッダーを生成する。 @@ -92,3 +93,21 @@ export function updateExclusions(questId: string, exclusions: Exclusion[]) { export function fetchHarvestQuests() { return request("/harvest/quests"); } + +/** + * 指定クエストの集計 JSON(報告一覧)を公開データ URL から取得する。 + * viewer と同じ CloudFront 配下の公開ファイルのため、認証ヘッダーは付けない。 + * S3 + CloudFront では未作成オブジェクトに 403 が返るため、403/404 は + * 「未集計(報告データなし)」として null を返す。 + * @param eventId イベント ID(JSON パスの一部) + * @param questId クエスト ID(JSON パスの一部) + */ +export async function fetchQuestReports( + eventId: string, + questId: string, +): Promise { + const res = await fetch(`${DATA_URL}/${eventId}/${questId}.json`); + if (res.status === 403 || res.status === 404) return null; + if (!res.ok) throw new Error(`Failed to fetch quest data: ${res.status}`); + return res.json() as Promise; +} diff --git a/admin/src/components/QuestReportManager.tsx b/admin/src/components/QuestReportManager.tsx new file mode 100644 index 0000000..b0fd0ce --- /dev/null +++ b/admin/src/components/QuestReportManager.tsx @@ -0,0 +1,257 @@ +import { useState } from "react"; +import { fetchQuestReports, getExclusions, updateExclusions } from "../api/client"; +import type { Exclusion, Report } from "../types"; + +interface Props { + eventId: string; + questId: string; +} + +/** + * 1クエスト分の報告一覧を表示し、各報告の除外(無効化)を編集するアコーディオン。 + * EventFormPage のクエストカード内に配置され、自己完結で状態と保存を管理する。 + * 除外は exclusions.json(クエスト単位・全件置き換え)へ独立して保存され、 + * イベント本体の更新 submit とは分離される。 + */ +export function QuestReportManager({ eventId, questId }: Props) { + const [expanded, setExpanded] = useState(false); + const [loaded, setLoaded] = useState(false); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [savedMsg, setSavedMsg] = useState(""); + + const [reports, setReports] = useState([]); + // reportId → 除外中かどうか + const [excludedIds, setExcludedIds] = useState>(new Set()); + // reportId → 除外理由 + const [reasons, setReasons] = useState>({}); + // ロード済み reports に存在しない reportId の既存除外(保存時にマージして保持する) + const [orphanExclusions, setOrphanExclusions] = useState([]); + + const load = async () => { + setLoading(true); + setError(""); + try { + const [questData, exclusions] = await Promise.all([ + fetchQuestReports(eventId, questId), + getExclusions(questId), + ]); + const loadedReports = questData?.reports ?? []; + const reportIds = new Set(loadedReports.map((r) => r.id)); + const excluded = new Set(); + const reasonMap: Record = {}; + const orphans: Exclusion[] = []; + for (const e of exclusions) { + if (reportIds.has(e.reportId)) { + excluded.add(e.reportId); + reasonMap[e.reportId] = e.reason; + } else { + orphans.push(e); + } + } + setReports(loadedReports); + setExcludedIds(excluded); + setReasons(reasonMap); + setOrphanExclusions(orphans); + setLoaded(true); + } catch (err) { + setError(err instanceof Error ? err.message : "報告データの取得に失敗"); + } finally { + setLoading(false); + } + }; + + const toggleExpanded = () => { + const next = !expanded; + setExpanded(next); + if (next && !loaded && !loading) load(); + }; + + const toggleExcluded = (id: string) => { + setSavedMsg(""); + setExcludedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const updateReason = (id: string, value: string) => { + setSavedMsg(""); + setReasons((prev) => ({ ...prev, [id]: value })); + }; + + const handleSave = async () => { + setSaving(true); + setError(""); + setSavedMsg(""); + try { + const payload: Exclusion[] = [ + ...orphanExclusions, + ...reports + .filter((r) => excludedIds.has(r.id)) + .map((r) => ({ reportId: r.id, reason: reasons[r.id] ?? "" })), + ]; + await updateExclusions(questId, payload); + setSavedMsg("保存しました"); + } catch (err) { + setError(err instanceof Error ? err.message : "保存に失敗"); + } finally { + setSaving(false); + } + }; + + const excludedCount = excludedIds.size + orphanExclusions.length; + + return ( +
+ + + {expanded && ( +
+ {loading &&

読み込み中...

} + {error &&

{error}

} + + {loaded && !loading && reports.length === 0 && ( +

+ 報告データがありません(未集計のクエストです)。 +

+ )} + + {loaded && !loading && reports.length > 0 && ( + <> +
+ + + + + + + + + + + + + {reports.map((r) => { + const excluded = excludedIds.has(r.id); + return ( + + + + + + + + + ); + })} + +
除外報告者周回日時メモ理由
+ toggleExcluded(r.id)} + /> + + + {r.reporterName || r.reporter} + + {r.runcount}{formatTimestamp(r.timestamp)} + {r.note} + + {excluded && ( + updateReason(r.id, e.target.value)} + placeholder="除外理由" + style={{ fontSize: 13, width: "12em" }} + /> + )} +
+
+
+ + {savedMsg && {savedMsg}} +
+ + )} +
+ )} +
+ ); +} + +/** ISO 形式の日時文字列を日本時間のロケール文字列に変換する。 */ +function formatTimestamp(iso: string): string { + const d = new Date(iso); + return d.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }); +} + +const accordionButton: React.CSSProperties = { + fontSize: 12, + color: "#555", + background: "none", + border: "none", + cursor: "pointer", + padding: 0, +}; + +const panelStyle: React.CSSProperties = { + marginTop: 6, + padding: "8px 12px", + background: "#f9f9f9", + borderRadius: 4, + border: "1px solid #e0e0e0", +}; + +const th: React.CSSProperties = { + borderBottom: "1px solid #ccc", + padding: "4px 8px", + textAlign: "left", + whiteSpace: "nowrap", +}; + +const thRight: React.CSSProperties = { ...th, textAlign: "right" }; + +const td: React.CSSProperties = { + borderBottom: "1px solid #eee", + padding: "4px 8px", + whiteSpace: "nowrap", +}; + +const tdRight: React.CSSProperties = { ...td, textAlign: "right" }; +const tdCenter: React.CSSProperties = { ...td, textAlign: "center" }; + +const tdReporter: React.CSSProperties = { + ...td, + maxWidth: "12em", + overflow: "hidden", + textOverflow: "ellipsis", +}; + +const tdNote: React.CSSProperties = { + ...td, + maxWidth: "16em", + overflow: "hidden", + textOverflow: "ellipsis", +}; + +const excludedRow: React.CSSProperties = { + opacity: 0.5, + textDecoration: "line-through", +}; diff --git a/admin/src/pages/EventFormPage.tsx b/admin/src/pages/EventFormPage.tsx index a6373e4..bebe291 100644 --- a/admin/src/pages/EventFormPage.tsx +++ b/admin/src/pages/EventFormPage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { createEvent, fetchHarvestQuests, getEvents, updateEvent } from "../api/client"; +import { QuestReportManager } from "../components/QuestReportManager"; import type { EventData, HarvestQuest, Quest } from "../types"; export function EventFormPage() { @@ -537,6 +538,15 @@ export function EventFormPage() { )} + + {/* 報告管理(除外編集): 保存済みイベントかつ questId 入力済みのクエストのみ */} + {isEdit && eventId && q.questId && ( + + )} ))} diff --git a/admin/src/types/index.ts b/admin/src/types/index.ts index 33f4707..e0afcb3 100644 --- a/admin/src/types/index.ts +++ b/admin/src/types/index.ts @@ -28,6 +28,23 @@ export interface Exclusion { reason: string; } +export interface Report { + id: string; + reporter: string; + reporterName: string; + runcount: number; + timestamp: string; + note: string; + items: Record; + warnings: string[]; +} + +export interface QuestData { + quest: Quest; + lastUpdated: string; + reports: Report[]; +} + export interface HarvestQuest { id: string; name: string; From a9cb4c096485031c3933423508ccaa0dfa4e8ce7 Mon Sep 17 00:00:00 2001 From: max747 Date: Sun, 12 Jul 2026 18:42:40 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fetchQuestReports:=20VITE=5FDATA=5FURL=20?= =?UTF-8?q?=E6=9C=AA=E8=A8=AD=E5=AE=9A=E6=99=82=E3=81=AB=E6=98=8E=E7=A4=BA?= =?UTF-8?q?=E3=82=A8=E3=83=A9=E3=83=BC=E3=83=BB=E3=83=91=E3=82=B9=E3=82=92?= =?UTF-8?q?=20encodeURIComponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VITE_DATA_URL 未設定のまま実行すると "undefined/..." へ fetch して 原因が分かりづらくなるため、未設定時は明示的にエラーを投げる。 あわせて eventId/questId を URL パスに埋め込む前に encodeURIComponent する。 Co-Authored-By: Claude Opus 4.8 --- admin/src/api/client.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts index ac3621d..3dc12e8 100644 --- a/admin/src/api/client.ts +++ b/admin/src/api/client.ts @@ -106,7 +106,12 @@ export async function fetchQuestReports( eventId: string, questId: string, ): Promise { - const res = await fetch(`${DATA_URL}/${eventId}/${questId}.json`); + if (!DATA_URL) { + throw new Error("VITE_DATA_URL is not set"); + } + const res = await fetch( + `${DATA_URL}/${encodeURIComponent(eventId)}/${encodeURIComponent(questId)}.json`, + ); if (res.status === 403 || res.status === 404) return null; if (!res.ok) throw new Error(`Failed to fetch quest data: ${res.status}`); return res.json() as Promise; From a94bb2cda0c4fcb0936f9b4986a206766fb59b84 Mon Sep 17 00:00:00 2001 From: max747 Date: Sun, 12 Jul 2026 18:43:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?QuestReportManager:=20=E4=B8=80=E8=A6=A7?= =?UTF-8?q?=E5=A4=96=E3=81=AE=E9=99=A4=E5=A4=96=E8=A8=AD=E5=AE=9A(orphan)?= =?UTF-8?q?=E3=82=92=E3=83=91=E3=83=8D=E3=83=AB=E5=86=85=E3=81=AB=E6=B3=A8?= =?UTF-8?q?=E8=A8=98=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orphan 除外(現在の報告一覧に存在しない既存除外)は「X件除外中」に 含まれるが画面で確認できず混乱を招くため、パネル内に件数と 「保存時に保持される」旨を明示する注記を追加する。 Co-Authored-By: Claude Opus 4.8 --- admin/src/components/QuestReportManager.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/admin/src/components/QuestReportManager.tsx b/admin/src/components/QuestReportManager.tsx index b0fd0ce..1f90c70 100644 --- a/admin/src/components/QuestReportManager.tsx +++ b/admin/src/components/QuestReportManager.tsx @@ -119,6 +119,13 @@ export function QuestReportManager({ eventId, questId }: Props) { {loading &&

読み込み中...

} {error &&

{error}

} + {loaded && !loading && orphanExclusions.length > 0 && ( +

+ ※ 現在の報告一覧に存在しない除外設定が {orphanExclusions.length}{" "} + 件あります(過去の報告など)。一覧には表示されませんが、保存時にそのまま保持されます。 +

+ )} + {loaded && !loading && reports.length === 0 && (

報告データがありません(未集計のクエストです)。