diff --git a/e2e/TESTIDS.md b/e2e/TESTIDS.md index ed09114..ab74c62 100644 --- a/e2e/TESTIDS.md +++ b/e2e/TESTIDS.md @@ -6,21 +6,20 @@ | Pattern | Example | |---------|---------| -| `page-{name}` | `page-login`, `page-users` | -| `nav-{slug}` | `nav-users` (slug = path without `/`) | -| `{entity}-row-{id}` | `user-row-{uuid}` | -| `{entity}-detail` | `user-detail` | -| `{entity}-filter-{field}` | `event-filter-status` | -| `btn-{action}` | `btn-login`, `btn-save`, `btn-delete-confirm` | +| `page-{name}` | `page-login`, `page-users`, `page-inbox-reports` | +| `nav-{slug}` | `nav-users` | +| `row-{id}` | dense list / DataTable rows | +| `row-activate-{id}` | dense list main click target | +| `{entity}-row-{id}` | inbox-specific (`report-row-*`) | +| `btn-{action}` | `btn-login`, `btn-save-profile` | | `dialog-{name}` | `dialog-edit-user` | -| `menu-user` | avatar dropdown | -| `palette-input` | command palette | +| `filter-{name}` | `filter-users-search` | +| `menu-user` / `lang-ru` | avatar dropdown | -Helper in app: `src/lib/testid.ts` → `testid(['user-row', id])`. +Helper: `src/lib/testid.ts`. ## Rules -- kebab-case only -- Dynamic id segment = real API entity id -- Prefer roles/labels for login email/password and primary submit when i18n fixed to `ru` in mock suite -- Do not decorate every layout div — only interaction anchors +- kebab-case; dynamic id = API entity id (or banned word) +- Prefer a11y for login fields +- Do not decorate decorative wrappers diff --git a/e2e/mocks/adminApi.ts b/e2e/mocks/adminApi.ts index 714c4ed..e0ccd14 100644 --- a/e2e/mocks/adminApi.ts +++ b/e2e/mocks/adminApi.ts @@ -1,5 +1,7 @@ import type { Page, Route } from '@playwright/test'; import { createModerationSeed, type ModerationState } from './moderationSeed'; +import { createContentSeed } from './contentSeed'; +import { tryHandleContentApi } from './handleContentApi'; export type MockAdmin = { id: string; @@ -43,6 +45,7 @@ type MockOptions = { export async function installAdminApiMocks(page: Page, options: MockOptions = {}) { let currentUser: MockAdmin = { ...MOCK_ADMIN, ...options.user }; const moderation: ModerationState | null = options.moderation === false ? null : createModerationSeed(); + const content = createContentSeed(); await page.route('**/v1/admin/**', async (route) => { const req = route.request(); @@ -111,6 +114,12 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {} } if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) { + // Prefer content seed with sample node (monitoring) + if ( + await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON()) + ) { + return; + } await json(route, 200, { nodes: [] }); return; } @@ -267,26 +276,10 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {} return; } } + } - // soft stubs for linked entities in inbox - if (method === 'GET' && /\/v1\/admin\/users\/[^/]+$/.test(path)) { - await json(route, 200, { - id: 'user-1', - email: 'user1@eventhub.local', - nickname: 'User One', - role: 'user', - status: 'active', - }); - return; - } - if (method === 'GET' && /\/v1\/admin\/events\/[^/]+$/.test(path)) { - await json(route, 200, { - id: 'evt-1', - title: 'E2E Event', - status: 'active', - }); - return; - } + if (await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON())) { + return; } if (method === 'GET') { diff --git a/e2e/mocks/contentSeed.ts b/e2e/mocks/contentSeed.ts new file mode 100644 index 0000000..ad0fee5 --- /dev/null +++ b/e2e/mocks/contentSeed.ts @@ -0,0 +1,148 @@ +/** Content/system seed data for mock E2E (users, calendars, events, …). */ + +const now = '2026-07-14T12:00:00Z'; + +export function createContentSeed() { + const users = [ + { + id: 'user-e2e-1', + email: 'alice@eventhub.local', + nickname: 'Alice', + role: 'user', + status: 'active', + language: 'ru', + phone: null, + avatar_url: null, + last_login: now, + created_at: now, + updated_at: now, + }, + { + id: 'user-e2e-2', + email: 'bob@eventhub.local', + nickname: 'Bob', + role: 'user', + status: 'frozen', + language: 'en', + phone: null, + avatar_url: null, + last_login: now, + created_at: now, + updated_at: now, + }, + ]; + + const calendars = [ + { + id: 'cal-e2e-1', + title: 'E2E Calendar', + description: 'for tests', + type: 'personal', + category: 'sport', + owner_id: 'user-e2e-1', + status: 'active', + reason: null, + tags: [], + color: '#3366ff', + image_url: '', + rating_avg: 4.5, + rating_count: 2, + short_name: 'e2ecal', + confirmation: 'none', + settings: null, + created_at: now, + updated_at: now, + }, + ]; + + const events = [ + { + id: 'evt-e2e-1', + calendar_id: 'cal-e2e-1', + title: 'E2E Workout', + description: 'test event', + event_type: 'single', + start_time: '2026-01-01T10:00:00Z', + duration: 60, + recurrence: null, + master_id: null, + is_instance: false, + specialist_id: null, + location: null, + tags: [], + capacity: 10, + online_link: null, + status: 'active', + reason: null, + rating_avg: 5, + rating_count: 1, + created_at: now, + updated_at: now, + }, + ]; + + const subscriptions = [ + { + id: 'sub-e2e-1', + user_id: 'user-e2e-1', + plan: 'pro', + status: 'active', + started_at: now, + expires_at: '2027-01-01T00:00:00Z', + auto_renew: true, + created_at: now, + updated_at: now, + }, + ]; + + const bannedWords = [ + { word: 'spamword', added_by: 'admin-e2e-1', created_at: now }, + { word: 'badword', added_by: 'admin-e2e-1', created_at: now }, + ]; + + const admins = [ + { + id: 'admin-e2e-1', + email: 'e2e@eventhub.local', + nickname: 'E2E Admin', + role: 'superadmin', + language: 'ru', + phone: null, + avatar_url: null, + timezone: 'Europe/Moscow', + created_at: now, + updated_at: now, + last_login: now, + }, + { + id: 'admin-e2e-2', + email: 'mod@eventhub.local', + nickname: 'Mod Admin', + role: 'moderator', + language: 'ru', + phone: null, + avatar_url: null, + timezone: 'UTC', + created_at: now, + updated_at: now, + last_login: now, + }, + ]; + + const audit = [ + { + id: 'audit-e2e-1', + admin_id: 'admin-e2e-1', + action: 'user.update', + target_type: 'user', + target_id: 'user-e2e-1', + details: { status: 'frozen' }, + ip: '127.0.0.1', + created_at: now, + }, + ]; + + return { users, calendars, events, subscriptions, bannedWords, admins, audit }; +} + +export type ContentState = ReturnType; diff --git a/e2e/mocks/handleContentApi.ts b/e2e/mocks/handleContentApi.ts new file mode 100644 index 0000000..0a7c40d --- /dev/null +++ b/e2e/mocks/handleContentApi.ts @@ -0,0 +1,313 @@ +import type { Route } from '@playwright/test'; +import type { ContentState } from './contentSeed'; + +type JsonFn = ( + route: Route, + status: number, + body: unknown, + headers?: Record +) => Promise; + +function listSlice(items: T[], url: URL): { rows: T[]; total: number } { + const limit = Number(url.searchParams.get('limit') || 50); + const offset = Number(url.searchParams.get('offset') || 0); + const q = (url.searchParams.get('q') || '').toLowerCase(); + let rows = items; + if (q) { + rows = items.filter((item) => JSON.stringify(item).toLowerCase().includes(q)); + } + const status = url.searchParams.get('status'); + if (status && rows.length && typeof (rows[0] as { status?: string }).status === 'string') { + rows = rows.filter((item) => (item as { status?: string }).status === status); + } + const plan = url.searchParams.get('plan'); + if (plan) { + rows = rows.filter((item) => (item as { plan?: string }).plan === plan); + } + const type = url.searchParams.get('type'); + if (type) { + rows = rows.filter((item) => (item as { type?: string }).type === type); + } + return { rows: rows.slice(offset, offset + limit), total: rows.length }; +} + +/** Returns true if the request was handled. */ +export async function tryHandleContentApi( + route: Route, + method: string, + path: string, + url: URL, + content: ContentState, + json: JsonFn, + postData: () => unknown +): Promise { + // ---- users ---- + if (method === 'GET' && path.endsWith('/v1/admin/users/stats')) { + await json(route, 200, { + total_users: content.users.length, + users_by_role: {}, + users_by_status: {}, + pending_users: 0, + }); + return true; + } + if (method === 'GET' && path.endsWith('/v1/admin/users')) { + const page = listSlice(content.users, url); + await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) }); + return true; + } + { + const m = path.match(/\/v1\/admin\/users\/([^/]+)$/); + if (m) { + const id = decodeURIComponent(m[1]); + const idx = content.users.findIndex((u) => u.id === id); + if (idx < 0) { + await json(route, 404, { error: 'not_found' }); + return true; + } + if (method === 'GET') { + await json(route, 200, content.users[idx]); + return true; + } + if (method === 'PUT') { + content.users[idx] = { ...content.users[idx], ...(postData() as object) }; + await json(route, 200, content.users[idx]); + return true; + } + if (method === 'DELETE') { + content.users.splice(idx, 1); + await json(route, 200, { ok: true }); + return true; + } + } + } + + // ---- calendars ---- + if (method === 'GET' && path.endsWith('/v1/admin/calendars/stats')) { + await json(route, 200, { + total_calendars: content.calendars.length, + calendars_by_type: {}, + calendars_by_status: {}, + top_calendars_by_reviews: [], + top_calendars_by_positive_reviews: [], + top_calendars_by_negative_reviews: [], + top_calendars_by_rating: [], + }); + return true; + } + if (method === 'GET' && path.endsWith('/v1/admin/calendars')) { + const page = listSlice(content.calendars, url); + await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) }); + return true; + } + { + const m = path.match(/\/v1\/admin\/calendars\/([^/]+)$/); + if (m) { + const id = decodeURIComponent(m[1]); + const idx = content.calendars.findIndex((c) => c.id === id); + if (idx < 0) { + await json(route, 404, { error: 'not_found' }); + return true; + } + if (method === 'GET') { + await json(route, 200, content.calendars[idx]); + return true; + } + if (method === 'PUT') { + content.calendars[idx] = { ...content.calendars[idx], ...(postData() as object) }; + await json(route, 200, content.calendars[idx]); + return true; + } + if (method === 'DELETE') { + content.calendars.splice(idx, 1); + await json(route, 200, { ok: true }); + return true; + } + } + } + + // ---- events ---- + if (method === 'GET' && path.endsWith('/v1/admin/events/stats')) { + await json(route, 200, { + total_events: content.events.length, + events_by_type: {}, + events_by_status: {}, + top_events_by_rating: [], + }); + return true; + } + if (method === 'GET' && path.endsWith('/v1/admin/events')) { + const page = listSlice(content.events, url); + await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) }); + return true; + } + { + const m = path.match(/\/v1\/admin\/events\/([^/]+)$/); + if (m) { + const id = decodeURIComponent(m[1]); + const idx = content.events.findIndex((e) => e.id === id); + if (idx < 0) { + await json(route, 404, { error: 'not_found' }); + return true; + } + if (method === 'GET') { + await json(route, 200, content.events[idx]); + return true; + } + if (method === 'PUT') { + content.events[idx] = { ...content.events[idx], ...(postData() as object) }; + await json(route, 200, content.events[idx]); + return true; + } + if (method === 'DELETE') { + content.events.splice(idx, 1); + await json(route, 200, { ok: true }); + return true; + } + } + } + + // ---- subscriptions ---- + if (method === 'GET' && path.endsWith('/v1/admin/subscriptions/stats')) { + await json(route, 200, { + total_subscriptions: content.subscriptions.length, + subscriptions_by_plan: {}, + subscriptions_by_status: {}, + trial_subscriptions: 0, + ending_paid_subscriptions: [], + }); + return true; + } + if (method === 'GET' && path.endsWith('/v1/admin/subscriptions')) { + const page = listSlice(content.subscriptions, url); + await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) }); + return true; + } + { + const m = path.match(/\/v1\/admin\/subscriptions\/([^/]+)$/); + if (m) { + const id = decodeURIComponent(m[1]); + const idx = content.subscriptions.findIndex((s) => s.id === id); + if (idx < 0) { + await json(route, 404, { error: 'not_found' }); + return true; + } + if (method === 'GET') { + await json(route, 200, content.subscriptions[idx]); + return true; + } + if (method === 'PUT') { + content.subscriptions[idx] = { ...content.subscriptions[idx], ...(postData() as object) }; + await json(route, 200, content.subscriptions[idx]); + return true; + } + if (method === 'DELETE') { + content.subscriptions.splice(idx, 1); + await json(route, 200, { ok: true }); + return true; + } + } + } + + // ---- banned words ---- + if (method === 'GET' && path.endsWith('/v1/admin/banned-words')) { + const page = listSlice( + content.bannedWords.map((w) => ({ ...w, id: w.word })), + url + ); + await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) }); + return true; + } + if (method === 'POST' && path.endsWith('/v1/admin/banned-words')) { + const payload = postData() as { word?: string }; + const word = (payload.word || '').trim(); + if (word) content.bannedWords.push({ word, added_by: 'admin-e2e-1', created_at: '2026-07-14T12:00:00Z' }); + await json(route, 200, { ok: true }); + return true; + } + { + const m = path.match(/\/v1\/admin\/banned-words\/([^/]+)$/); + if (m && method === 'DELETE') { + const word = decodeURIComponent(m[1]); + content.bannedWords = content.bannedWords.filter((w) => w.word !== word); + await json(route, 200, { ok: true }); + return true; + } + } + + // ---- admins ---- + if (method === 'GET' && path.endsWith('/v1/admin/admins')) { + const page = listSlice(content.admins, url); + await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) }); + return true; + } + if (method === 'POST' && path.endsWith('/v1/admin/admins')) { + const payload = postData() as Record; + const created = { + id: `admin-new-${content.admins.length + 1}`, + email: String(payload.email || 'new@eventhub.local'), + nickname: String(payload.nickname || 'New Admin'), + role: String(payload.role || 'admin'), + language: 'ru', + phone: null, + avatar_url: null, + timezone: 'UTC', + created_at: '2026-07-14T12:00:00Z', + updated_at: '2026-07-14T12:00:00Z', + last_login: null, + }; + content.admins.push(created); + await json(route, 200, created); + return true; + } + { + const m = path.match(/\/v1\/admin\/admins\/([^/]+)$/); + if (m) { + const id = decodeURIComponent(m[1]); + const idx = content.admins.findIndex((a) => a.id === id); + if (idx < 0) { + await json(route, 404, { error: 'not_found' }); + return true; + } + if (method === 'GET') { + await json(route, 200, content.admins[idx]); + return true; + } + if (method === 'PUT') { + content.admins[idx] = { ...content.admins[idx], ...(postData() as object) }; + await json(route, 200, content.admins[idx]); + return true; + } + if (method === 'DELETE') { + content.admins.splice(idx, 1); + await json(route, 200, { ok: true }); + return true; + } + } + } + + // ---- audit ---- + if (method === 'GET' && path.endsWith('/v1/admin/audit')) { + const page = listSlice(content.audit, url); + await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) }); + return true; + } + + // ---- monitoring enrichment ---- + if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) { + await json(route, 200, { + nodes: [ + { + node: 'node-a', + cpu: 12, + memory_used_mb: 512, + memory_total_mb: 2048, + ts: '2026-07-14T12:00:00Z', + }, + ], + }); + return true; + } + + return false; +} diff --git a/e2e/tests/content.spec.ts b/e2e/tests/content.spec.ts new file mode 100644 index 0000000..4cefeb7 --- /dev/null +++ b/e2e/tests/content.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test'; +import { seedAuthenticatedSession } from '../helpers/session'; + +test.describe('content explore (mock)', () => { + test.beforeEach(async ({ page }) => { + await seedAuthenticatedSession(page); + }); + + test('users: список, поиск, detail', async ({ page }) => { + await page.goto('/users'); + await expect(page.getByTestId('page-users')).toBeVisible(); + await expect(page.getByTestId('row-user-e2e-1')).toBeVisible(); + await page.getByTestId('filter-users-search-input').fill('Alice'); + await page.getByTestId('filter-users-search').locator('button[type="submit"]').click(); + await expect(page.getByTestId('row-user-e2e-1')).toBeVisible(); + await page.goto('/users/user-e2e-1'); + await expect(page.getByTestId('page-user-detail')).toBeVisible(); + }); + + test('calendars: список и detail', async ({ page }) => { + await page.goto('/calendars'); + await expect(page.getByTestId('page-calendars')).toBeVisible(); + await expect(page.getByTestId('row-cal-e2e-1')).toBeVisible(); + await page.goto('/calendars/cal-e2e-1'); + await expect(page.getByTestId('page-calendar-detail')).toBeVisible(); + }); + + test('events: список и detail', async ({ page }) => { + await page.goto('/events'); + await expect(page.getByTestId('page-events')).toBeVisible(); + await expect(page.getByTestId('row-evt-e2e-1')).toBeVisible(); + await page.goto('/events/evt-e2e-1'); + await expect(page.getByTestId('page-event-detail')).toBeVisible(); + }); + + test('subscriptions: список', async ({ page }) => { + await page.goto('/subscriptions'); + await expect(page.getByTestId('page-subscriptions')).toBeVisible(); + await expect(page.getByTestId('row-sub-e2e-1')).toBeVisible(); + }); + + test('banned-words: список и add', async ({ page }) => { + await page.goto('/banned-words'); + await expect(page.getByTestId('page-banned-words')).toBeVisible(); + await expect(page.getByTestId('row-spamword')).toBeVisible(); + await page.getByTestId('input-banned-word').fill('newbad'); + await page.getByTestId('btn-add-banned-word').click(); + await expect(page.getByTestId('row-newbad')).toBeVisible(); + }); +}); diff --git a/e2e/tests/ift/smoke.spec.ts b/e2e/tests/ift/smoke.spec.ts index 4a7af05..ee47fe9 100644 --- a/e2e/tests/ift/smoke.spec.ts +++ b/e2e/tests/ift/smoke.spec.ts @@ -1,10 +1,20 @@ -import { test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; -/** IFT smoke project placeholder — filled in phase 6. */ +/** + * IFT smoke — заполняется секретами E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD. + * Запуск: `npm run test:e2e:ift` (без моков). + */ test.describe('IFT smoke', () => { - test.skip(!process.env.E2E_ADMIN_EMAIL, 'Set E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD for IFT'); + test.skip(!process.env.E2E_ADMIN_EMAIL || !process.env.E2E_ADMIN_PASSWORD, 'E2E_ADMIN_* secrets required'); - test('placeholder', async () => { - // Implemented in E2E phase 6 + test('login → dashboard → logout', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel(/email/i).fill(process.env.E2E_ADMIN_EMAIL!); + await page.getByLabel(/пароль|password/i).fill(process.env.E2E_ADMIN_PASSWORD!); + await page.getByTestId('btn-login').click(); + await expect(page.getByTestId('layout-shell')).toBeVisible({ timeout: 30_000 }); + await page.getByTestId('menu-user').click(); + await page.getByTestId('btn-logout').click(); + await expect(page.getByTestId('page-login')).toBeVisible(); }); }); diff --git a/e2e/tests/system.spec.ts b/e2e/tests/system.spec.ts new file mode 100644 index 0000000..1d392ce --- /dev/null +++ b/e2e/tests/system.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; +import { seedAuthenticatedSession } from '../helpers/session'; + +test.describe('system / overview (mock)', () => { + test.beforeEach(async ({ page }) => { + await seedAuthenticatedSession(page); + }); + + test('admins: список, create dialog, detail', async ({ page }) => { + await page.goto('/admins'); + await expect(page.getByTestId('page-admins')).toBeVisible(); + await expect(page.getByTestId('row-admin-e2e-2')).toBeVisible(); + await page.getByTestId('btn-create-admin').click(); + await expect(page.getByTestId('dialog-create-admin')).toBeVisible(); + await page.goto('/admins/admin-e2e-2'); + await expect(page.getByTestId('page-admin-detail')).toBeVisible(); + await expect(page.getByTestId('btn-edit-admin')).toBeVisible(); + }); + + test('audit: страница и фильтры', async ({ page }) => { + await page.goto('/audit'); + await expect(page.getByTestId('page-audit')).toBeVisible(); + await expect(page.getByTestId('filter-audit')).toBeVisible(); + await expect(page.getByTestId('row-audit-e2e-1')).toBeVisible(); + }); + + test('monitoring: страница и range', async ({ page }) => { + await page.goto('/monitoring'); + await expect(page.getByTestId('page-monitoring')).toBeVisible(); + await page.getByTestId('filter-monitoring-range-30').click(); + await expect(page.getByTestId('filter-monitoring-range-30')).toBeVisible(); + }); + + test('profile: view и edit', async ({ page }) => { + await page.goto('/profile'); + await expect(page.getByTestId('page-profile')).toBeVisible(); + await page.getByTestId('btn-edit-profile').click(); + await expect(page.getByTestId('btn-save-profile')).toBeVisible(); + await page.getByTestId('btn-cancel-profile').click(); + await expect(page.getByTestId('btn-edit-profile')).toBeVisible(); + }); + + test('dashboard остаётся доступен', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page.getByTestId('page-dashboard')).toBeVisible(); + }); +}); + +test.describe('i18n (mock)', () => { + test('переключение языка через меню', async ({ page }) => { + await seedAuthenticatedSession(page); + await page.goto('/dashboard'); + await page.getByTestId('menu-user').click(); + await page.getByTestId('lang-en').click(); + // profile language mutation returns user with language=en; menu stays open or reopens + await page.getByTestId('menu-user').click().catch(() => undefined); + await expect(page.getByTestId('lang-en')).toBeVisible(); + }); +}); diff --git a/src/components/data-table/DataTable.tsx b/src/components/data-table/DataTable.tsx index 2669b57..6d1d52a 100644 --- a/src/components/data-table/DataTable.tsx +++ b/src/components/data-table/DataTable.tsx @@ -126,15 +126,19 @@ export function DataTable({ )) ) : table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - + table.getRowModel().rows.map((row) => { + const original = row.original as { id?: string; word?: string }; + const rowKey = original.id ?? original.word; + return ( + {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} - )) + ); + }) ) : ( diff --git a/src/components/explore/DenseRowList.tsx b/src/components/explore/DenseRowList.tsx index 6035e62..00a040e 100644 --- a/src/components/explore/DenseRowList.tsx +++ b/src/components/explore/DenseRowList.tsx @@ -60,7 +60,7 @@ export function DenseRowList({ ) : (
    {items.map((item) => ( -
  • +
  • ({ > - @@ -301,7 +306,7 @@ const AdminDetailPage: React.FC = () => { - +
    ); }; diff --git a/src/pages/admins/AdminListPage.tsx b/src/pages/admins/AdminListPage.tsx index 0a68743..e9917c9 100644 --- a/src/pages/admins/AdminListPage.tsx +++ b/src/pages/admins/AdminListPage.tsx @@ -236,12 +236,13 @@ const AdminListPage: React.FC = () => { return ( <> setCreateModal(true)}> + @@ -261,7 +262,7 @@ const AdminListPage: React.FC = () => { - + {t('admins.createTitle')} diff --git a/src/pages/audit/AuditPage.tsx b/src/pages/audit/AuditPage.tsx index a7d105c..e1b7c2a 100644 --- a/src/pages/audit/AuditPage.tsx +++ b/src/pages/audit/AuditPage.tsx @@ -186,13 +186,14 @@ const AuditPage: React.FC = () => { ); return ( -
    +
    -
    +
    setAdminSearch(e.target.value)} diff --git a/src/pages/banned-words/BannedWordsPage.tsx b/src/pages/banned-words/BannedWordsPage.tsx index 530badc..4e94597 100644 --- a/src/pages/banned-words/BannedWordsPage.tsx +++ b/src/pages/banned-words/BannedWordsPage.tsx @@ -118,7 +118,7 @@ const BannedWordsPage: React.FC = () => { ); return ( -
    +
    {
    setNewWord(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAdd()} className="w-[200px]" /> -
    setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} className="w-[200px]" /> -
    diff --git a/src/pages/calendars/CalendarDetailPage.tsx b/src/pages/calendars/CalendarDetailPage.tsx index 34b4481..642f1d3 100644 --- a/src/pages/calendars/CalendarDetailPage.tsx +++ b/src/pages/calendars/CalendarDetailPage.tsx @@ -1,158 +1,158 @@ -import React from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import dayjs from 'dayjs'; -import { ArrowLeft } from 'lucide-react'; -import { useCalendar } from '@/hooks/useCalendars'; -import { formatDisplayValue } from '@/lib/utils'; -import { PageHeader } from '@/components/PageHeader'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; - -const isBadValue = (val: unknown) => - val === '-' || val === 'undefined' || val === '' || val === null || val === undefined; - -const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => { - const text = formatDisplayValue(value); - if (text === '-') return <>{emptyLabel}; - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - return
    {text}
    ; - } - return <>{text}; -}; - -const CalendarDetailPage: React.FC = () => { - const { t } = useTranslation(); - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const { data: calendar, isLoading } = useCalendar(id || ''); - - const formatDate = (dateStr: string) => { - if (isBadValue(dateStr)) return t('common.emDash'); - const d = dayjs(dateStr); - return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr; - }; - - if (isLoading) { - return ( -
    - - -
    - ); - } - - if (!calendar) return

    {t('calendars.notFound')}

    ; - - const emDash = t('common.emDash'); - const titleLabel = calendar.title || calendar.id; - - return ( - <> - navigate('/calendars')}> - - {t('common.backToList')} - - } - /> - - -
    -
    -
    {t('common.id')}
    -
    {calendar.id}
    -
    -
    -
    {t('common.title')}
    -
    {calendar.title}
    -
    -
    -
    {t('common.shortName')}
    -
    -
    -
    -
    {t('common.description')}
    -
    -
    -
    -
    {t('common.type')}
    -
    - {calendar.type} -
    -
    -
    -
    {t('common.status')}
    -
    - - {calendar.status} - -
    -
    -
    -
    {t('common.reason')}
    -
    -
    -
    -
    {t('common.category')}
    -
    -
    -
    -
    {t('common.owner')}
    -
    - {!isBadValue(calendar.owner_id) ? ( - - {calendar.owner_id} - - ) : emDash} -
    -
    -
    -
    {t('common.color')}
    -
    -
    -
    -
    {t('common.image')}
    -
    -
    -
    -
    {t('common.confirmation')}
    -
    -
    -
    -
    {t('events.tags')}
    -
    -
    -
    -
    {t('common.rating')}
    -
    {calendar.rating_avg} ({calendar.rating_count})
    -
    -
    -
    {t('common.settings')}
    -
    -
    -
    -
    {t('common.createdAt')}
    -
    {formatDate(calendar.created_at)}
    -
    -
    -
    {t('common.updatedAt')}
    -
    {formatDate(calendar.updated_at)}
    -
    -
    - -
    -
    - - ); -}; - -export default CalendarDetailPage; +import React from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import dayjs from 'dayjs'; +import { ArrowLeft } from 'lucide-react'; +import { useCalendar } from '@/hooks/useCalendars'; +import { formatDisplayValue } from '@/lib/utils'; +import { PageHeader } from '@/components/PageHeader'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +const isBadValue = (val: unknown) => + val === '-' || val === 'undefined' || val === '' || val === null || val === undefined; + +const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => { + const text = formatDisplayValue(value); + if (text === '-') return <>{emptyLabel}; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return
    {text}
    ; + } + return <>{text}; +}; + +const CalendarDetailPage: React.FC = () => { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data: calendar, isLoading } = useCalendar(id || ''); + + const formatDate = (dateStr: string) => { + if (isBadValue(dateStr)) return t('common.emDash'); + const d = dayjs(dateStr); + return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr; + }; + + if (isLoading) { + return ( +
    + + +
    + ); + } + + if (!calendar) return

    {t('calendars.notFound')}

    ; + + const emDash = t('common.emDash'); + const titleLabel = calendar.title || calendar.id; + + return ( +
    + navigate('/calendars')}> + + {t('common.backToList')} + + } + /> + + +
    +
    +
    {t('common.id')}
    +
    {calendar.id}
    +
    +
    +
    {t('common.title')}
    +
    {calendar.title}
    +
    +
    +
    {t('common.shortName')}
    +
    +
    +
    +
    {t('common.description')}
    +
    +
    +
    +
    {t('common.type')}
    +
    + {calendar.type} +
    +
    +
    +
    {t('common.status')}
    +
    + + {calendar.status} + +
    +
    +
    +
    {t('common.reason')}
    +
    +
    +
    +
    {t('common.category')}
    +
    +
    +
    +
    {t('common.owner')}
    +
    + {!isBadValue(calendar.owner_id) ? ( + + {calendar.owner_id} + + ) : emDash} +
    +
    +
    +
    {t('common.color')}
    +
    +
    +
    +
    {t('common.image')}
    +
    +
    +
    +
    {t('common.confirmation')}
    +
    +
    +
    +
    {t('events.tags')}
    +
    +
    +
    +
    {t('common.rating')}
    +
    {calendar.rating_avg} ({calendar.rating_count})
    +
    +
    +
    {t('common.settings')}
    +
    +
    +
    +
    {t('common.createdAt')}
    +
    {formatDate(calendar.created_at)}
    +
    +
    +
    {t('common.updatedAt')}
    +
    {formatDate(calendar.updated_at)}
    +
    +
    + +
    +
    +
    + ); +}; + +export default CalendarDetailPage; diff --git a/src/pages/calendars/CalendarListPage.tsx b/src/pages/calendars/CalendarListPage.tsx index d11d5cb..b37dec9 100644 --- a/src/pages/calendars/CalendarListPage.tsx +++ b/src/pages/calendars/CalendarListPage.tsx @@ -286,12 +286,14 @@ const CalendarListPage: React.FC = () => { return ( <> { const titleLabel = event.title || event.id; return ( - <> +
    { { label: String(titleLabel) }, ]} actions={ - @@ -190,7 +190,7 @@ const EventDetailPage: React.FC = () => { - +
    ); }; diff --git a/src/pages/events/EventListPage.tsx b/src/pages/events/EventListPage.tsx index 72cf0fa..ac7c4e3 100644 --- a/src/pages/events/EventListPage.tsx +++ b/src/pages/events/EventListPage.tsx @@ -376,6 +376,7 @@ const EventListPage: React.FC = () => { return ( <> {
    { const healthy = summary.online > 0 && summary.cpuPeak < 85; return ( -
    +
    @@ -303,11 +303,12 @@ const MonitoringPage: React.FC = () => { {t('monitoring.subtitle', { minutes })}

    -
    +
    {TIME_RANGE_KEYS.map((r) => (
    - @@ -309,10 +309,10 @@ const ProfilePage: React.FC = () => { )}
    - -
    diff --git a/src/pages/subscriptions/SubscriptionListPage.tsx b/src/pages/subscriptions/SubscriptionListPage.tsx index 845c42e..20e85c3 100644 --- a/src/pages/subscriptions/SubscriptionListPage.tsx +++ b/src/pages/subscriptions/SubscriptionListPage.tsx @@ -277,12 +277,13 @@ const SubscriptionListPage: React.FC = () => { return ( <> +