diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index feab994..4ac059a 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -79,6 +79,17 @@ return http.get(`/api/projects/${projectId}/reports?${query}`); } +/** Owner-free variant of listProjectReports: the project page must work + * from a share link without a session (same data, addressed by token). */ +export async function listProjectReportsByToken(token: string, params: ReportListParams = {}): Promise { + const query = new URLSearchParams(); + query.set("limit", String(params.limit ?? 50)); + query.set("offset", String(params.offset ?? 0)); + if (params.report_type) query.set("report_type", params.report_type); + if (params.report_status) query.set("report_status", params.report_status); + return http.get(`/api/projects/by-token/${encodeURIComponent(token)}/reports?${query}`); +} + export async function createReport(body: ReportCreateIn): Promise { return http.post("/api/reports", body); } diff --git a/packages/web/src/i18n/en.json b/packages/web/src/i18n/en.json index bc0931e..e7ef59a 100644 --- a/packages/web/src/i18n/en.json +++ b/packages/web/src/i18n/en.json @@ -42,6 +42,7 @@ "share": "Copy share link", "delete": "Delete", "deleteConfirm": "Delete project with all its reports?", + "deleted": "Project deleted", "renamed": "Project updated", "search": "Search projects" }, diff --git a/packages/web/src/i18n/ru.json b/packages/web/src/i18n/ru.json index b4e1067..4fe0110 100644 --- a/packages/web/src/i18n/ru.json +++ b/packages/web/src/i18n/ru.json @@ -42,6 +42,7 @@ "share": "Скопировать ссылку", "delete": "Удалить", "deleteConfirm": "Удалить проект вместе со всеми репортами?", + "deleted": "Проект удалён", "renamed": "Проект обновлён", "search": "Поиск проектов" }, diff --git a/packages/web/src/pages/ProjectPage.vue b/packages/web/src/pages/ProjectPage.vue index dcd1dbc..df42b7f 100644 --- a/packages/web/src/pages/ProjectPage.vue +++ b/packages/web/src/pages/ProjectPage.vue @@ -66,7 +66,9 @@ notFound.value = false; try { project.value = await api.getProjectByToken(token.value); - const list = await api.listProjectReports(project.value.id, { + // token-addressed, not /projects/{id}/reports — the page must render for + // visitors without a session (the owner endpoint answers 401 to them) + const list = await api.listProjectReportsByToken(token.value, { limit: perPage, offset: (page.value - 1) * perPage, report_type: filterType.value || null, diff --git a/packages/web/src/pages/ProjectsPage.vue b/packages/web/src/pages/ProjectsPage.vue index 26f0ba7..2d0fdb3 100644 --- a/packages/web/src/pages/ProjectsPage.vue +++ b/packages/web/src/pages/ProjectsPage.vue @@ -2,10 +2,11 @@ import { computed, onMounted, ref } from "vue"; import { useI18n } from "vue-i18n"; import { - GnButton, GnCard, + GnConfirmDialog, GnCopyButton, GnEmptyState, + GnIconButton, GnInput, GnModal, GnPageHeader, @@ -30,6 +31,10 @@ const newName = ref(""); const newDescription = ref(""); +const deleteTarget = ref(null); +const deleteOpen = ref(false); +const deleting = ref(false); + const filtered = computed(() => { const q = query.value.trim().toLowerCase(); if (!q) return projects.value; @@ -48,6 +53,22 @@ } }); +async function deleteProject() { + const target = deleteTarget.value; + if (!target) return; + deleting.value = true; + try { + await api.deleteProject(target.id); + projects.value = projects.value.filter((p) => p.id !== target.id); + deleteTarget.value = null; + toast.success({ title: t("projects.deleted"), text: target.name }); + } catch { + toast.error({ title: t("common.error") }); + } finally { + deleting.value = false; + } +} + async function createProject() { creating.value = true; try { @@ -106,6 +127,12 @@ :label="t('projects.share')" size="sm" /> +

{{ project.description ?? "—" }}

+ +
diff --git a/server/app/routers/projects.py b/server/app/routers/projects.py index db71a83..783b54e 100644 --- a/server/app/routers/projects.py +++ b/server/app/routers/projects.py @@ -11,6 +11,7 @@ ProjectOut, ProjectReportCounts, ProjectUpdateIn, + ReportListOut, ) router = APIRouter(prefix="/projects", tags=["projects"]) @@ -68,17 +69,14 @@ return project -@router.get("/{project_id}/reports") -async def list_project_reports( - project: Project = Depends(get_project_for_owner), - limit: int = 50, - offset: int = 0, - report_type: str | None = None, - report_status: str | None = None, - db: AsyncSession = Depends(get_session), -): - from ..schemas import ReportListOut - +async def _list_project_reports( + project: Project, + limit: int, + offset: int, + report_type: str | None, + report_status: str | None, + db: AsyncSession, +) -> ReportListOut: limit = min(max(limit, 1), 200) offset = max(offset, 0) query = select(Report).where(Report.project_id == project.id) @@ -95,6 +93,36 @@ return ReportListOut(items=result.scalars().all(), total=total_result.scalar_one(), limit=limit, offset=offset) +@router.get("/{project_id}/reports", response_model=ReportListOut) +async def list_project_reports( + project: Project = Depends(get_project_for_owner), + limit: int = 50, + offset: int = 0, + report_type: str | None = None, + report_status: str | None = None, + db: AsyncSession = Depends(get_session), +): + return await _list_project_reports(project, limit, offset, report_type, report_status, db) + + +# public counterpart of GET /{id}/reports: the project page is opened from a +# share link without a session, so the report list must be reachable by token +@router.get("/by-token/{token}/reports", response_model=ReportListOut) +async def list_project_reports_by_token( + token: str, + limit: int = 50, + offset: int = 0, + report_type: str | None = None, + report_status: str | None = None, + db: AsyncSession = Depends(get_session), +): + result = await db.execute(select(Project).where(Project.share_token == token)) + project = result.scalar_one_or_none() + if project is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Project not found") + return await _list_project_reports(project, limit, offset, report_type, report_status, db) + + @router.get("/by-token/{token}", response_model=ProjectDetailOut) async def get_project_by_token(token: str, db: AsyncSession = Depends(get_session)): result = await db.execute(select(Project).where(Project.share_token == token))