diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 00ca623e..387c924d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,7 +49,16 @@ jobs: cache-dependency-path: package-lock.json - name: Install dependencies - run: npm ci --workspace shared && npm ci --workspace server + run: npm ci + + - name: Ensure @swc/core's Linux binary for unplugin-swc + # The lockfile was generated on Windows and omits @swc/core's Linux + # optional native binary, so npm ci/install skips it on the runner. + # Install the matching version explicitly so the server's SWC transform + # (server/vitest.config.ts) can load. + run: | + SWC_VERSION=$(node -p "require('@swc/core/package.json').version") + npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION" - name: Build shared run: npm run build --workspace=shared @@ -57,12 +66,12 @@ jobs: - name: Build server (tsc -> dist) run: cd server && npm run build - - name: Typecheck (informational) - # Pre-existing type errors in the NestJS rewrite; surfaces them without - # blocking CI. Ratchet to blocking once the legacy code is cleaned up. - continue-on-error: true + - name: Typecheck run: cd server && npm run typecheck + - name: Lint + run: cd server && npm run lint:check + - name: Run tests run: cd server && npm run test:coverage @@ -93,6 +102,15 @@ jobs: - name: Build shared run: npm run build --workspace=shared + - name: Typecheck + run: cd client && npm run typecheck + + - name: Lint + run: cd client && npm run lint:check + + - name: Page pattern check + run: cd client && npm run lint:pages + - name: Run tests run: cd client && npm run test:coverage diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 00000000..11215320 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,5 @@ +# Playwright E2E (FE7) +e2e/.tmp/ +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/client/e2e/auth.setup.ts b/client/e2e/auth.setup.ts new file mode 100644 index 00000000..46a34229 --- /dev/null +++ b/client/e2e/auth.setup.ts @@ -0,0 +1,42 @@ +import { test as setup, expect } from '@playwright/test' + +// Relative to the config dir (client/), matching `storageState` in +// playwright.config.ts. Playwright runs from the client workspace root. +const stateFile = 'e2e/.tmp/state.json' + +// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The +// seeded admin is created with must_change_password=1, so the first login goes +// through the forced change-password step before reaching the dashboard. +const EMAIL = 'e2e@trek.local' +const SEED_PW = 'E2eTest12345!' +const NEW_PW = 'E2eChanged12345!' + +setup('authenticate the seeded admin (incl. forced password change)', async ({ page }) => { + await page.goto('/login') + await page.locator('input[type="email"]').fill(EMAIL) + await page.locator('input[type="password"]').fill(SEED_PW) + await page.locator('button[type="submit"]').click() + + // must_change_password=1 → the change-password step renders two password + // fields (new + confirm). Selector-agnostic of the UI language. + const pw = page.locator('input[type="password"]') + await expect(pw).toHaveCount(2) + await pw.nth(0).fill(NEW_PW) + await pw.nth(1).fill(NEW_PW) + await page.locator('button[type="submit"]').click() + + await page.waitForURL('**/dashboard', { timeout: 30_000 }) + + // Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders + // asynchronously (after the notices fetch), so wait for it before clicking. + // Dismissal is recorded server-side against this user, so clearing it here + // keeps it cleared for every authenticated flow in the run (shared test DB). + const ok = page.getByRole('button', { name: 'OK', exact: true }) + await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {}) + for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) { + await ok.click() + await page.waitForTimeout(400) + } + + await page.context().storageState({ path: stateFile }) +}) diff --git a/client/e2e/create-trip.spec.ts b/client/e2e/create-trip.spec.ts new file mode 100644 index 00000000..6c264c92 --- /dev/null +++ b/client/e2e/create-trip.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '@playwright/test' + +// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the +// trip, submit, and confirm it shows up on the dashboard. Exercises the whole +// authenticated stack — dashboard → TripFormModal → POST /api/trips → store → +// re-render — against the real backend + isolated test DB. +test('create a trip and see it on the dashboard', async ({ page }) => { + await page.goto('/dashboard') + + // The "+ New Trip" card is always rendered in the default (planned) filter. + await page.locator('.add-trip-card').click() + + // Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit + // button (the primary action lives in the footer), so click it explicitly + // rather than pressing Enter. The Create button is the slate primary button; + // Cancel is the bordered one. + const modal = page.locator('.modal-backdrop') + await expect(modal).toBeVisible() + + const title = `E2E Trip ${Date.now()}` + await modal.locator('input[type="text"]').first().fill(title) + await modal.getByRole('button', { name: 'Create New Trip' }).click() + + await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 }) +}) diff --git a/client/e2e/dashboard.spec.ts b/client/e2e/dashboard.spec.ts new file mode 100644 index 00000000..d906e34a --- /dev/null +++ b/client/e2e/dashboard.spec.ts @@ -0,0 +1,10 @@ +import { test, expect } from '@playwright/test' + +// Authenticated smoke: the stored session lands on the dashboard and the +// app chrome (navbar) renders instead of bouncing back to /login. +test('authenticated session reaches the dashboard', async ({ page }) => { + await page.goto('/dashboard') + await expect(page).toHaveURL(/\/dashboard/) + // The shared Navbar shows the TREK brand once authenticated. + await expect(page.getByRole('img', { name: 'TREK' }).first()).toBeVisible() +}) diff --git a/client/e2e/login.public.spec.ts b/client/e2e/login.public.spec.ts new file mode 100644 index 00000000..36fc67d3 --- /dev/null +++ b/client/e2e/login.public.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from '@playwright/test' + +// Infra smoke + first unauthenticated flow: the app boots, the backend is +// reachable through the Vite proxy, and the login screen renders its form. +test('login screen renders with a password field', async ({ page }) => { + await page.goto('/login') + await expect(page.locator('input[type="password"]')).toBeVisible() +}) diff --git a/client/e2e/server-launch.mjs b/client/e2e/server-launch.mjs new file mode 100644 index 00000000..c9cd1067 --- /dev/null +++ b/client/e2e/server-launch.mjs @@ -0,0 +1,43 @@ +// Boots the TREK backend for the Playwright E2E run against a fresh, isolated +// SQLite database. The DB file is deleted first so every run starts clean, then +// the server's own startup seeds a known admin from ADMIN_EMAIL/ADMIN_PASSWORD. +// +// The server is built once and launched as a SINGLE node process (not the +// watch-mode `npm run dev`, which spawns tsc -w + node --watch grandchildren +// that survive Playwright's teardown and then linger on :3001 with stale DB +// state). A single child is killed cleanly when Playwright tears the run down. +import { rmSync } from 'node:fs' +import { spawn, execSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const dbFile = path.join(here, '.tmp', 'e2e.db') +const serverDir = path.join(here, '..', '..', 'server') + +for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) { + try { rmSync(f, { force: true }) } catch {} +} + +// Build once (no watcher) — the resulting process is a single killable node. +execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' }) + +const env = { + ...process.env, + TREK_DB_FILE: dbFile, + ADMIN_EMAIL: 'e2e@trek.local', + ADMIN_PASSWORD: 'E2eTest12345!', + PORT: '3001', + NODE_ENV: 'development', +} + +const child = spawn(process.execPath, ['--require', 'tsconfig-paths/register', 'dist/index.js'], { + cwd: serverDir, + env, + stdio: 'inherit', +}) +const stop = () => { try { child.kill() } catch {} } +process.on('SIGINT', stop) +process.on('SIGTERM', stop) +process.on('exit', stop) +child.on('exit', code => process.exit(code ?? 0)) diff --git a/client/e2e/trip-planner.spec.ts b/client/e2e/trip-planner.spec.ts new file mode 100644 index 00000000..9a4b8b2c --- /dev/null +++ b/client/e2e/trip-planner.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test' + +// Open a trip into the planner: create a trip, open it from the dashboard, and +// confirm the trip planner (TripPlannerPage — the app's largest page) actually +// mounts, proving the day-plan/map shell renders rather than crashing on load. +test('open a trip and land in the planner with a map', async ({ page }) => { + await page.goto('/dashboard') + + // Create a trip to open. + await page.locator('.add-trip-card').click() + const modal = page.locator('.modal-backdrop') + await expect(modal).toBeVisible() + const title = `E2E Planner ${Date.now()}` + await modal.locator('input[type="text"]').first().fill(title) + await modal.getByRole('button', { name: 'Create New Trip' }).click() + + // Open it from the dashboard. + await page.getByText(title).first().click() + + await expect(page).toHaveURL(/\/trips\/\d+/) + // The planner shows a Leaflet map once mounted (past the splash screen). + await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 }) +}) diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs new file mode 100644 index 00000000..6e48d7e4 --- /dev/null +++ b/client/eslint.config.mjs @@ -0,0 +1,78 @@ +import js from '@eslint/js'; + +import gitignore from 'eslint-config-flat-gitignore'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; + +// Minimal stub so the existing `// eslint-disable-next-line react/no-danger` +// directive in src/i18n/TransHtml.tsx resolves without pulling in the full +// eslint-plugin-react (not a dependency here). The rule is a no-op. +const reactStub = { + rules: { + 'no-danger': { + meta: { schema: [] }, + create() { + return {}; + }, + }, + }, +}; + +export default tseslint.config( + gitignore({ strict: false }), + { + ignores: [ + 'node_modules', + 'dist', + 'coverage', + 'public', + 'test-results', + 'playwright-report', + 'e2e/**', + 'scripts/**', + '**/*.config.js', + '**/*.config.ts', + '**/*.config.mjs', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + eslintConfigPrettier, + { + files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'], + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + react: reactStub, + }, + rules: { + 'react/no-danger': 'off', + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + + // --- Severities tuned to keep CI green on a codebase that was never linted --- + // (each rule below has pre-existing violations; surfaced as warnings, not blockers) + + // rules-of-hooks has one conditional-hook violation in PlaceInspector.tsx -> warn (not error). + 'react-hooks/rules-of-hooks': 'warn', + 'react-hooks/exhaustive-deps': 'warn', + + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-unused-expressions': 'warn', + '@typescript-eslint/no-unsafe-function-type': 'warn', + '@typescript-eslint/no-this-alias': 'warn', + '@typescript-eslint/no-non-null-asserted-optional-chain': 'warn', + + // js.recommended rules with pre-existing hits. + 'no-empty': 'warn', + 'no-useless-escape': 'warn', + 'no-useless-assignment': 'warn', + 'preserve-caught-error': 'warn', + }, + }, +); diff --git a/client/package.json b/client/package.json index 3a99ea90..44c8dc55 100644 --- a/client/package.json +++ b/client/package.json @@ -8,18 +8,23 @@ "prebuild": "node scripts/generate-icons.mjs", "build": "vite build", "preview": "vite preview", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:unit": "vitest run tests/unit", "test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint .", + "lint:check": "eslint .", + "lint:pages": "node scripts/check-page-pattern.mjs", + "e2e": "playwright test", + "e2e:report": "playwright show-report", "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"", "format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"" }, "dependencies": { + "@react-pdf/renderer": "^4.5.1", "@trek/shared": "*", - "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", "dexie": "^4.4.2", "heic-to": "^1.4.2", @@ -27,11 +32,11 @@ "lucide-react": "^0.344.0", "mapbox-gl": "^3.22.0", "marked": "^18.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", "react-dropzone": "^14.4.1", - "react-leaflet": "^4.2.1", - "react-leaflet-cluster": "^2.1.0", + "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.1.3", "react-markdown": "^10.1.0", "react-router-dom": "^6.22.2", "react-window": "^2.2.7", @@ -43,33 +48,37 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/leaflet": "^1.9.8", - "@types/react": "^18.2.61", - "@types/react-dom": "^18.2.19", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.18", + "eslint": "^10.2.1", + "eslint-config-flat-gitignore": "^2.3.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", "fake-indexeddb": "^6.2.5", "jsdom": "^29.0.1", "msw": "^2.13.0", "postcss": "^8.4.35", - "sharp": "^0.33.0", - "tailwindcss": "^3.4.1", - "typescript": "^6.0.2", - "vite": "^5.1.4", - "vite-plugin-pwa": "^0.21.0", - "vitest": "^3.2.4", - "@trivago/prettier-plugin-sort-imports": "^6.0.2", "prettier": "^3.8.3", "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-tailwindcss": "^0.8.0", - "eslint": "^10.2.1", - "eslint-config-flat-gitignore": "^2.3.0", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2" + "sharp": "^0.33.0", + "tailwindcss": "^3.4.1", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^5.1.4", + "vite-plugin-pwa": "^0.21.0", + "vitest": "^3.2.4" } } diff --git a/client/playwright.config.ts b/client/playwright.config.ts new file mode 100644 index 00000000..e6868a43 --- /dev/null +++ b/client/playwright.config.ts @@ -0,0 +1,57 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * E2E harness for TREK's critical user flows (FE7). + * + * Two web servers are orchestrated: the Express/Nest backend on :3001 against an + * isolated throwaway SQLite DB (e2e/server-launch.mjs sets TREK_DB_FILE + seeds a + * known admin), and the Vite dev server on :5173 which proxies /api, /uploads, + * /ws to the backend. Tests run serially against one worker so they share the + * single seeded database deterministically. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + timeout: 45_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + // Unauthenticated flows (login, register, public share) — no stored session. + { name: 'public', testMatch: /\.public\.spec\.ts/, use: { ...devices['Desktop Chrome'] } }, + // One-time login that persists a session for the authenticated flows. + { name: 'setup', testMatch: /auth\.setup\.ts/ }, + { + name: 'app', + testMatch: /\.spec\.ts/, + testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/, + use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' }, + dependencies: ['setup'], + }, + ], + webServer: [ + { + // Always start our own backend (never reuse) so the isolated test DB is + // reset + reseeded on every run, regardless of any stray dev server. + command: 'node e2e/server-launch.mjs', + port: 3001, + reuseExistingServer: false, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'npm run dev', + port: 5173, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ], +}) diff --git a/client/scripts/check-page-pattern.mjs b/client/scripts/check-page-pattern.mjs new file mode 100644 index 00000000..5d7d789c --- /dev/null +++ b/client/scripts/check-page-pattern.mjs @@ -0,0 +1,44 @@ +// Guards the "Page = wiring container + data hook" convention (see +// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a +// co-located use() hook into JSX — it must not own state/effects itself. +// +// We scan only the default-export component body (from `export default function` +// up to the next top-level `function` declaration or EOF), so presentational +// sub-components and helper hooks living in the same file are not flagged. +// Context hooks like useTranslation/useParams are fine; the smell is stateful +// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef. +import { readdirSync, readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages') +const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef'] +const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`) + +const violations = [] +for (const file of readdirSync(pagesDir)) { + if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue + const src = readFileSync(join(pagesDir, file), 'utf8') + const lines = src.split('\n') + const start = lines.findIndex(l => /export default function/.test(l)) + if (start === -1) continue + // The page body ends at the next top-level declaration (a `function` at + // column 0) — everything after that is a sub-component or helper. + let end = lines.length + for (let i = start + 1; i < lines.length; i++) { + if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break } + } + for (let i = start; i < end; i++) { + if (bannedRe.test(lines[i])) { + violations.push(`${file}:${i + 1} ${lines[i].trim()}`) + } + } +} + +if (violations.length > 0) { + console.error('Page-pattern violations — move this state/effect logic into the page\'s use() hook:\n') + for (const v of violations) console.error(' ' + v) + console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`) + process.exit(1) +} +console.log('Page pattern OK — no state/effect logic in page containers.') diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 81d40904..d66c2503 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,7 +1,82 @@ import axios, { AxiosInstance } from 'axios' -import type { WeatherResult } from '@trek/shared' +import type { z } from 'zod' +import { + weatherResultSchema, type WeatherResult, + inAppListResultSchema, type InAppListResult, + unreadCountResultSchema, type UnreadCountResult, + channelTestResultSchema, + mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema, + mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema, + type NotificationRespondRequest, + type SettingUpsertRequest, type SettingsBulkRequest, + type JourneyCreateRequest, type JourneyAddTripRequest, + type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest, + type JourneyShareLinkRequest, + type RegisterRequest, type LoginRequest, type ForgotPasswordRequest, + type ResetPasswordRequest, type ChangePasswordRequest, + type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest, + type TripAddMemberRequest, type AssignmentReorderRequest, + type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, + type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, + type DayCreateRequest, type DayUpdateRequest, + type PlaceCreateRequest, type PlaceUpdateRequest, + type ReservationCreateRequest, type ReservationUpdateRequest, + type AccommodationCreateRequest, type AccommodationUpdateRequest, + type BudgetCreateItemRequest, type BudgetUpdateItemRequest, + type PackingCreateItemRequest, type PackingUpdateItemRequest, + type TodoCreateItemRequest, type TodoUpdateItemRequest, + type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, + type PlaceBulkDeleteRequest, + type DayNoteCreateRequest, type DayNoteUpdateRequest, + type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest, + type PackingCategoryAssigneesRequest, + type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest, + type TodoCategoryAssigneesRequest, + type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest, + type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest, + type FileUpdateRequest, type FileLinkRequest, + type CreateTagRequest, type UpdateTagRequest, + type CreateCategoryRequest, type UpdateCategoryRequest, + type PlaceImportListRequest, +} from '@trek/shared' import { getSocketId } from './websocket' import { isReachable, probeNow } from '../sync/connectivity' + +/** + * Validate a response payload against its @trek/shared Zod schema — but only in + * dev, and never throwing. A drift between the server contract and the client's + * expected shape is surfaced as a console warning during development; in + * production (and on any mismatch) the data passes through untouched, so adding + * validation can never break a working call. This is the typed-request helper + * the FE adopts per domain as each backend module lands on @trek/shared. + */ +const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV) +export function parseInDev(schema: S, data: unknown, label: string): z.infer { + if (API_DEV) { + const result = schema.safeParse(data) + if (!result.success) { + console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues) + } + } + return data as z.infer +} + +/** + * Same dev-only drift check as parseInDev, but passes the payload straight + * through with its original inferred type instead of the schema type. Use this + * for endpoints whose existing consumers rely on the loose `r.data` type — it + * adds the development contract-drift warning without retyping the public + * surface (so it can never break a consumer that worked before). + */ +function checkInDev(schema: z.ZodTypeAny, data: T, label: string): T { + if (API_DEV) { + const result = schema.safeParse(data) + if (!result.success) { + console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues) + } + } + return data +} const RATE_LIMIT_MESSAGES: Record = { en: 'Too many attempts. Please try again later.', de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.', @@ -154,12 +229,12 @@ apiClient.interceptors.response.use( ) export const authApi = { - register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data), + register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data), validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data), - login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data), - verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data), + login: (data: LoginRequest) => apiClient.post('/auth/login', data).then(r => r.data), + verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data), mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data), - mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }), + mfaEnable: (data: MfaEnableRequest) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }), mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data), me: () => apiClient.get('/auth/me').then(r => r.data), updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data), @@ -173,14 +248,14 @@ export const authApi = { updateAppSettings: (data: Record) => apiClient.put('/auth/app-settings', data).then(r => r.data), validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data), travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data), - changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data), - forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }), - resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }), + changePassword: (data: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data), + forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }), + resetPassword: (data: ResetPasswordRequest) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }), deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data), demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data), mcpTokens: { list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data), - create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data), + create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data), delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data), }, } @@ -226,32 +301,32 @@ export const oauthApi = { export const tripsApi = { list: (params?: Record) => apiClient.get('/trips', { params }).then(r => r.data), - create: (data: Record) => apiClient.post('/trips', data).then(r => r.data), + create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data), get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data), - update: (id: number | string, data: Record) => apiClient.put(`/trips/${id}`, data).then(r => r.data), + update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data), delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data), uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data), unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data), getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), - addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data), + addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data), removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), - copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), + copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data), } export const daysApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), - update: (tripId: number | string, dayId: number | string, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), + create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), + update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), } export const placesApi = { list: (tripId: number | string, params?: Record) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data), + create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data), get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data), - update: (tripId: number | string, id: number | string, data: Record) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), + update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => { @@ -270,64 +345,64 @@ export const placesApi = { return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, importGoogleList: (tripId: number | string, url: string) => - apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data), + apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data), importNaverList: (tripId: number | string, url: string) => apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data), bulkDelete: (tripId: number | string, ids: number[]) => - apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data), + apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data), } export const assignmentsApi = { list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data), - create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data), + create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data), delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data), - reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data), + reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data), move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data), update: (tripId: number | string, dayId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data), getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data), - setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data), - updateTime: (tripId: number | string, id: number, times: Record) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), + setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data), + updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), } export const packingApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), - bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), + bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data), + update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data), - reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data), + reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data), getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data), - setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), + setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data), applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data), saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data), - setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data), + setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data), listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data), - createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data), - updateBag: (tripId: number | string, bagId: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data), + createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data), + updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data), deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data), } export const todoApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data), - reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data), + reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds } satisfies TodoReorderRequest).then(r => r.data), getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data), - setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), + setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies TodoCategoryAssigneesRequest).then(r => r.data), } export const tagsApi = { list: () => apiClient.get('/tags').then(r => r.data), - create: (data: Record) => apiClient.post('/tags', data).then(r => r.data), - update: (id: number, data: Record) => apiClient.put(`/tags/${id}`, data).then(r => r.data), + create: (data: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data), + update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data), delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data), } export const categoriesApi = { list: () => apiClient.get('/categories').then(r => r.data), - create: (data: Record) => apiClient.post('/categories', data).then(r => r.data), - update: (id: number, data: Record) => apiClient.put(`/categories/${id}`, data).then(r => r.data), + create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data), + update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data), delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data), } @@ -390,7 +465,7 @@ export const addonsApi = { export const journeyApi = { list: () => apiClient.get('/journeys').then(r => r.data), - create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data), + create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data), get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data), update: (id: number, data: Record) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data), delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data), @@ -399,7 +474,7 @@ export const journeyApi = { availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data), // Trips (sync sources) - addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data), + addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId } satisfies JourneyAddTripRequest).then(r => r.data), removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data), // Entries @@ -407,7 +482,7 @@ export const journeyApi = { createEntry: (id: number, data: Record) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), updateEntry: (entryId: number, data: Record) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data), deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data), - reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data), + reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data), // Photos uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => @@ -424,7 +499,7 @@ export const journeyApi = { onUploadProgress: opts?.onUploadProgress, signal: opts?.signal, }).then(r => r.data), - addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), + addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data), @@ -446,19 +521,19 @@ export const journeyApi = { // Share getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data), - createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), + createShareLink: (id: number, perms: JourneyShareLinkRequest) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data), getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data), } export const mapsApi = { - search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), + search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')), autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) => - apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data), - details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), - placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data), - reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data), - resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data), + apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')), + details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')), + placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')), + reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')), + resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')), } export const airportsApi = { @@ -468,15 +543,15 @@ export const airportsApi = { export const budgetApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data), - setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data), - togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data), + setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data), + togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), - reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data), + reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), } export const filesApi = { @@ -484,13 +559,13 @@ export const filesApi = { upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data), toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data), restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data), permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data), emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data), - addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data), + addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data), removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data), getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data), } @@ -498,15 +573,15 @@ export const filesApi = { export const reservationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data), upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data), } export const weatherApi = { - get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data), - getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data), + get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), + getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')), } export const configApi = { @@ -516,40 +591,46 @@ export const configApi = { export const settingsApi = { get: () => apiClient.get('/settings').then(r => r.data), - set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data), - setBulk: (settings: Record) => apiClient.post('/settings/bulk', { settings }).then(r => r.data), + set: (key: string, value: unknown) => { + const body: SettingUpsertRequest = { key, value } + return apiClient.put('/settings', body).then(r => r.data) + }, + setBulk: (settings: Record) => { + const body: SettingsBulkRequest = { settings } + return apiClient.post('/settings/bulk', body).then(r => r.data) + }, } export const accommodationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data), } export const dayNotesApi = { list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data), - create: (tripId: number | string, dayId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data), - update: (tripId: number | string, dayId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data), + create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data), + update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data), delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data), } export const collabApi = { getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data), - createNote: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data), - updateNote: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data), + createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data), + updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data), deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data), uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data), getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data), - createPoll: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), - votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data), + createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), + votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data), closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data), deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data), getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data), - sendMessage: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data), + sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data), deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data), - reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data), + reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data), linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data), } @@ -590,16 +671,16 @@ export const shareApi = { export const notificationsApi = { getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), updatePreferences: (prefs: Record>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), - testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data), - testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data), - testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data), + testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')), + testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')), + testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')), } export const inAppNotificationsApi = { - list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) => - apiClient.get('/notifications/in-app', { params }).then(r => r.data), - unreadCount: () => - apiClient.get('/notifications/in-app/unread-count').then(r => r.data), + list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise => + apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')), + unreadCount: (): Promise => + apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')), markRead: (id: number) => apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data), markUnread: (id: number) => @@ -610,7 +691,7 @@ export const inAppNotificationsApi = { apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data), deleteAll: () => apiClient.delete('/notifications/in-app/all').then(r => r.data), - respond: (id: number, response: 'positive' | 'negative') => + respond: (id: number, response: NotificationRespondRequest['response']) => apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data), } diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index c2db2218..250978ba 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -158,16 +158,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, return (
{/* Header */} -
-
-

{t('admin.addons.title')}

-

+

+
+

{t('admin.addons.title')}

+

{t('admin.addons.subtitleBefore')}TREK{t('admin.addons.subtitleAfter')}

{addons.length === 0 ? ( -
+
{t('admin.addons.noAddons')}
) : ( @@ -175,9 +175,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, {/* Trip Addons */} {tripAddons.length > 0 && (
-
- - +
+ + {t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
@@ -185,14 +185,14 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && ( -
- +
+
-
{t('admin.bagTracking.title')}
-
{t('admin.bagTracking.subtitle')}
+
{t('admin.bagTracking.title')}
+
{t('admin.bagTracking.subtitle')}
- + {bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} )} {expanded && hidden > 0 && ( )}
-
+
{session.username}
@@ -164,8 +161,8 @@ export default function AdminMcpTokensPanel() { {/* MCP Tokens */}
-

{t('admin.mcpTokens.sectionTitle')}

-
+

{t('admin.mcpTokens.sectionTitle')}

+
{tokensLoading ? (
@@ -177,8 +174,8 @@ export default function AdminMcpTokensPanel() {
) : ( <> -
+
{t('admin.mcpTokens.tokenName')} {t('admin.mcpTokens.owner')} {t('admin.mcpTokens.created')} @@ -187,13 +184,12 @@ export default function AdminMcpTokensPanel() {
{tokens.map((token, i) => (
+ className={`grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3 ${i < tokens.length - 1 ? 'border-b border-edge' : ''}`}>
-

{token.name}

+

{token.name}

{token.token_prefix}...

-
+
{token.username}
@@ -217,14 +213,14 @@ export default function AdminMcpTokensPanel() { {/* Revoke OAuth session modal */} {revokeConfirmId !== null && ( -
{ if (e.target === e.currentTarget) setRevokeConfirmId(null) }}> -
-

{t('admin.oauthSessions.revokeTitle')}

-

{t('admin.oauthSessions.revokeMessage')}

+
+

{t('admin.oauthSessions.revokeTitle')}

+

{t('admin.oauthSessions.revokeMessage')}

-

+

{t('admin.audit.showing', { count: entries.length, total })}

{loading && entries.length === 0 ? ( -
{t('common.loading')}
+
{t('common.loading')}
) : entries.length === 0 ? ( -
{t('admin.audit.empty')}
+
{t('admin.audit.empty')}
) : ( -
+
- - - - - - - + + + + + + + {entries.map((e) => ( - - - - - - - + + + + + + + ))} @@ -160,8 +159,7 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R type="button" disabled={loading} onClick={() => loadMore()} - className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50" - style={{ color: 'var(--text-secondary)' }} + className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50 text-content-secondary" > {t('admin.audit.loadMore')} diff --git a/client/src/components/Admin/BackupPanel.tsx b/client/src/components/Admin/BackupPanel.tsx index 44acdf5f..6dc67c86 100644 --- a/client/src/components/Admin/BackupPanel.tsx +++ b/client/src/components/Admin/BackupPanel.tsx @@ -186,8 +186,8 @@ export default function BackupPanel() {
-

{t('backup.title')}

-

{t('backup.subtitle')}

+

{t('backup.title')}

+

{t('backup.subtitle')}

@@ -310,8 +310,8 @@ export default function BackupPanel() {
-

{t('backup.auto.title')}

-

{t('backup.auto.subtitle')}

+

{t('backup.auto.title')}

+

{t('backup.auto.subtitle')}

@@ -360,7 +360,7 @@ export default function BackupPanel() { handleAutoSettingsChange('hour', parseInt(v, 10))} + onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))} size="sm" options={HOURS.map(h => { let label: string @@ -408,7 +408,7 @@ export default function BackupPanel() { handleAutoSettingsChange('day_of_month', parseInt(v, 10))} + onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))} size="sm" options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))} /> @@ -458,7 +458,8 @@ export default function BackupPanel() { {/* Restore Warning Modal */} {restoreConfirm && (
setRestoreConfirm(null)} >
{/* Red header */}
-
- +
+
-

+

{t('backup.restoreConfirmTitle')}

-

+

{restoreConfirm.filename}

@@ -505,7 +506,8 @@ export default function BackupPanel() { @@ -130,7 +130,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement { lng: 2.3522, address: null, category_id: null, - icon: null, price: null, currency: null, image_url: null, @@ -147,14 +146,14 @@ export default function DefaultUserSettingsTab(): React.ReactElement { }], []) if (!loaded) { - return

Loading…

+ return

Loading…

} const darkMode = defaults.dark_mode return (
-

+

{t('admin.defaultSettings.description')}

@@ -225,7 +224,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement { {/* Map Tile URL */}
-
- - - - - - - - - - - - ) -} - -// ── Chip with custom tooltip ───────────────────────────────────────────────── -interface ChipWithTooltipProps { - label: string - avatarUrl: string | null - size?: number - paid?: boolean - onClick?: () => void -} - -function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) { - const [hover, setHover] = useState(false) - const [pos, setPos] = useState({ top: 0, left: 0 }) - const ref = useRef(null) - - const onEnter = () => { - if (ref.current) { - const rect = ref.current.getBoundingClientRect() - setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 }) - } - setHover(true) - } - - const borderColor = paid ? '#22c55e' : 'var(--border-primary)' - const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)' - - return ( - <> -
setHover(false)} - onClick={onClick} - style={{ - width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, - background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', - overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', - transition: 'border-color 0.15s, background 0.15s', - }}> - {avatarUrl - ? - : label?.[0]?.toUpperCase() - } -
- {hover && ReactDOM.createPortal( -
- {label} - {paid && ( - Paid - )} -
, - document.body - )} - - ) -} - -// ── Budget Member Chips (for Persons column) ──────────────────────────────── -interface BudgetMemberChipsProps { - members?: BudgetMember[] - tripMembers?: TripMember[] - onSetMembers: (memberIds: number[]) => void - onTogglePaid?: (userId: number, paid: boolean) => void - compact?: boolean - readOnly?: boolean -} - -function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) { - const chipSize = compact ? 20 : 30 - const btnSize = compact ? 18 : 28 - const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14) - const [showDropdown, setShowDropdown] = useState(false) - const [dropPos, setDropPos] = useState({ top: 0, left: 0 }) - const btnRef = useRef(null) - const dropRef = useRef(null) - - const openDropdown = useCallback(() => { - if (btnRef.current) { - const rect = btnRef.current.getBoundingClientRect() - setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 }) - } - setShowDropdown(v => !v) - }, []) - - useEffect(() => { - if (!showDropdown) return - const close = (e) => { - if (dropRef.current && dropRef.current.contains(e.target)) return - if (btnRef.current && btnRef.current.contains(e.target)) return - setShowDropdown(false) - } - document.addEventListener('mousedown', close) - return () => document.removeEventListener('mousedown', close) - }, [showDropdown]) - - const memberIds = members.map(m => m.user_id) - - const toggleMember = (userId) => { - const newIds = memberIds.includes(userId) - ? memberIds.filter(id => id !== userId) - : [...memberIds, userId] - onSetMembers(newIds) - } - - return ( -
- {members.map(m => ( - onTogglePaid(m.user_id, !m.paid) : undefined} - /> - ))} - {!readOnly && ( - - )} - {showDropdown && ReactDOM.createPortal( -
- {tripMembers.map(tm => { - const isActive = memberIds.includes(tm.id) - return ( - - ) - })} -
, - document.body - )} -
- ) -} - -// ── Per-Person Inline (inside total card) ──────────────────────────────────── -interface PerPersonInlineProps { - tripId: number - budgetItems: BudgetItem[] - currency: string - locale: string -} - -const SPLIT_COLORS = [ - { solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }, - { solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' }, - { solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' }, - { solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' }, - { solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' }, - { solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' }, -] - -export function splitColorFor(userId: number, order: number) { - return SPLIT_COLORS[order % SPLIT_COLORS.length] -} - -function colorForUserId(userId: number) { - return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length] -} - -function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) { - const color = colorForUserId(userId) - return ( -
-
- {avatarUrl ? : username?.[0]?.toUpperCase()} -
-
- ) -} - -function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType }) { - const [data, setData] = useState(null) - const fmt = (v: number) => fmtNum(v, locale, currency) - - useEffect(() => { - budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {}) - }, [tripId, budgetItems]) - - if (!data || data.length === 0) return null - - const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) })) - - return ( - <> - {grandTotal > 0 && ( -
- {people.map(p => ( -
- ))} -
- )} - -
- {people.map(p => { - const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0 - return ( -
- -
-
{p.username}
-
{percent}%
-
-
{fmt(p.total_assigned)}
-
- ) - })} -
- - ) -} - -// ── Pie Chart (pure CSS conic-gradient) ────────────────────────────────────── -interface PieChartProps { - segments: PieSegment[] - size?: number - totalLabel: string -} - -function PieChart({ segments, size = 200, totalLabel }: PieChartProps) { - if (!segments.length) return null - - const total = segments.reduce((s, x) => s + x.value, 0) - if (total === 0) return null - - let cumDeg = 0 - const stops = segments.map(seg => { - const start = cumDeg - const deg = (seg.value / total) * 360 - cumDeg += deg - return `${seg.color} ${start}deg ${start + deg}deg` - }).join(', ') - - return ( -
-
-
- - {totalLabel} -
-
- ) -} +export { splitColorFor } from './BudgetPanel.helpers' // ── Main Component ─────────────────────────────────────────────────────────── interface BudgetPanelProps { @@ -559,127 +15,21 @@ interface BudgetPanelProps { } export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) { - const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore() - const can = useCanDo() - const { t, locale } = useTranslation() - const isDark = useIsDark() - const theme = useMemo(() => widgetTheme(isDark), [isDark]) - const [newCategoryName, setNewCategoryName] = useState('') - const [editingCat, setEditingCat] = useState(null) // { name, value } - const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null) - const [settlementOpen, setSettlementOpen] = useState(false) - const currency = trip?.currency || 'EUR' - const canEdit = can('budget_edit', trip) - - const fmt = (v, cur) => fmtNum(v, locale, cur) - const hasMultipleMembers = tripMembers.length > 1 - - // Drag state for categories - const [dragCat, setDragCat] = useState(null) - const [dragOverCat, setDragOverCat] = useState(null) - // Drag state for items within a category - const [dragItem, setDragItem] = useState(null) - const [dragOverItem, setDragOverItem] = useState(null) - const [dragItemCat, setDragItemCat] = useState(null) - - // Load settlement data whenever budget items change - useEffect(() => { - if (!hasMultipleMembers) return - budgetApi.settlement(tripId).then(setSettlement).catch(() => {}) - }, [tripId, budgetItems, hasMultipleMembers]) - - const setCurrency = (cur) => { - if (tripId) updateTrip(tripId, { currency: cur }) - } - - useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId]) - - const grouped = useMemo(() => { - const map = new Map() - for (const item of (budgetItems || [])) { - const cat = item.category || 'Other' - if (!map.has(cat)) map.set(cat, []) - map.get(cat)!.push(item) - } - return map - }, [budgetItems]) - - const categoryNames = Array.from(grouped.keys()) - - // Stable color mapping: assign index-based colors once, never reassign on reorder - const colorMapRef = useRef(new Map()) - const categoryColor = useCallback((cat: string) => { - const map = colorMapRef.current - if (!map.has(cat)) { - map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length]) - } - return map.get(cat)! - }, []) - const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0) - - const pieSegments = useMemo(() => - categoryNames.map((cat, i) => ({ - name: cat, - value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0), - color: categoryColor(cat), - })).filter(s => s.value > 0) - , [grouped, categoryNames]) - - const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} } - const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} } - const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} } - const handleDeleteCategory = async (cat) => { - const items = grouped.get(cat) || [] - for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) - } - const handleRenameCategory = async (oldName, newName) => { - if (!newName.trim() || newName.trim() === oldName) return - const items = grouped.get(oldName) || [] - for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) - } - const handleAddCategory = () => { - if (!newCategoryName.trim()) return - addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }) - setNewCategoryName('') - } - - const handleExportCsv = () => { - const sep = ';' - const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s } - const d = currencyDecimals(currency) - const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : '' - - const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } - const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note'] - const rows = [header.join(sep)] - - for (const cat of categoryNames) { - for (const item of (grouped.get(cat) || [])) { - const pp = calcPP(item.total_price, item.persons) - const pd = calcPD(item.total_price, item.days) - const ppd = calcPPD(item.total_price, item.persons, item.days) - rows.push([ - esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')), - fmtPrice(item.total_price), item.persons ?? '', item.days ?? '', - fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd), - esc(item.note || ''), - ].join(sep)) - } - } - - const bom = '\uFEFF' - const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim() - a.download = `budget-${safeName}.csv` - a.click() - URL.revokeObjectURL(url) - } - - const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } - const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' } + const { + budgetItems, + setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories, + t, locale, isDark, theme, + newCategoryName, setNewCategoryName, + editingCat, setEditingCat, + settlement, settlementOpen, setSettlementOpen, + currency, canEdit, fmt, hasMultipleMembers, + dragCat, setDragCat, dragOverCat, setDragOverCat, + dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat, + setCurrency, + grouped, categoryNames, categoryColor, grandTotal, pieSegments, + handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv, + th, td, + } = useBudgetPanel(tripId, tripMembers) // ── Empty State ────────────────────────────────────────────────────────── if (!budgetItems || budgetItems.length === 0) { @@ -707,7 +57,6 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro } // ── Main Layout ────────────────────────────────────────────────────────── - const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0) return (
@@ -771,462 +120,26 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
- {categoryNames.map((cat, ci) => { - const items = grouped.get(cat) || [] - const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) - const color = categoryColor(cat) - - return ( -
{ - if (!dragCat || dragCat === cat || dragItem) return - e.preventDefault(); e.dataTransfer.dropEffect = 'move' - setDragOverCat(cat) - }} - onDragLeave={e => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null) - }} - onDrop={e => { - e.preventDefault() - if (dragCat && dragCat !== cat) { - const newOrder = [...categoryNames] - const fromIdx = newOrder.indexOf(dragCat) - const toIdx = newOrder.indexOf(cat) - newOrder.splice(fromIdx, 1) - newOrder.splice(toIdx, 0, dragCat) - reorderBudgetCategories(tripId, newOrder) - } - setDragCat(null); setDragOverCat(null) - }} - > - {dragOverCat === cat &&
} -
-
- {canEdit && ( -
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }} - onDragEnd={() => { setDragCat(null); setDragOverCat(null) }} - style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}> - -
- )} -
- {canEdit && editingCat?.name === cat ? ( - setEditingCat({ ...editingCat, value: e.target.value })} - onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }} - onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }} - style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }} - /> - ) : ( - <> - {cat} - {canEdit && ( - - )} - - )} -
-
- {fmt(subtotal, currency)} - {canEdit && ( - - )} -
-
- -
{ if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}> -
{t('admin.audit.col.time')}{t('admin.audit.col.user')}{t('admin.audit.col.action')}{t('admin.audit.col.resource')}{t('admin.audit.col.ip')}{t('admin.audit.col.details')}
{t('admin.audit.col.time')}{t('admin.audit.col.user')}{t('admin.audit.col.action')}{t('admin.audit.col.resource')}{t('admin.audit.col.ip')}{t('admin.audit.col.details')}
{fmtTime(e.created_at)}{userLabel(e)}{e.action}{e.resource || '—'}{e.ip || '—'}{fmtDetails(e.details)}
{fmtTime(e.created_at)}{userLabel(e)}{e.action}{e.resource || '—'}{e.ip || '—'}{fmtDetails(e.details)}
- setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder={t('budget.newEntry')} style={inp} /> - - setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }} - placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} /> - - setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> - - setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> - --- -
- -
-
- setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} /> - - -
- - - - - - - - - - - - - - - - {items.map(item => { - const pp = calcPP(item.total_price, item.persons) - const pd = calcPD(item.total_price, item.days) - const ppd = calcPPD(item.total_price, item.persons, item.days) - const hasMembers = item.members?.length > 0 - return ( - { - if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return } - if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) } - }} - onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }} - onDrop={e => { - if (dragItem && dragItemCat === cat && dragItem !== item.id) { - e.preventDefault(); e.stopPropagation() - const ids = items.map(i => i.id) - const fromIdx = ids.indexOf(dragItem) - const toIdx = ids.indexOf(item.id) - ids.splice(fromIdx, 1) - ids.splice(toIdx, 0, dragItem) - reorderBudgetItems(tripId, ids) - setDragItem(null); setDragOverItem(null); setDragItemCat(null) - } - }} - onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> - - - - - - - - - - - - ) - })} - {canEdit && handleAddItem(cat, data)} t={t} />} - -
{t('budget.table.name')}{t('budget.table.total')}{t('budget.table.persons')}{t('budget.table.days')}{t('budget.table.perPerson')}{t('budget.table.perDay')}{t('budget.table.perPersonDay')}{t('budget.table.date')}{t('budget.table.note')}
-
- {canEdit && ( -
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }} - onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }} - style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}> - -
- )} -
- handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} /> - {hasMultipleMembers && ( -
- setBudgetItemMembers(tripId, item.id, userIds)} - onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} - compact={false} - readOnly={!canEdit} - /> -
- )} -
-
-
- handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - - {hasMultipleMembers ? ( - setBudgetItemMembers(tripId, item.id, userIds)} - onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} - readOnly={!canEdit} - /> - ) : ( - handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - )} - - handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - {pp != null ? fmt(pp, currency) : '-'}{pd != null ? fmt(pd, currency) : '-'}{ppd != null ? fmt(ppd, currency) : '-'} - {canEdit ? ( -
- handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless /> -
- ) : ( - {item.expense_date || '—'} - )} -
handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - {canEdit && ( - - )} -
-
-
- ) - })} + {categoryNames.map(cat => ( + + ))}
-
- -
-
-
- -
-
-
{t('budget.totalBudget')}
-
-
- - {(() => { - const decimals = currencyDecimals(currency) - const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) - const sep = (0.1).toLocaleString(locale).replace(/\d/g, '') - const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, ''] - return ( -
- {integerPart} - {decimalPart && {sep}{decimalPart}} - {SYMBOLS[currency] || currency} -
- ) - })()} -
- {currency} -
- - {hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && ( - - )} - - {/* Settlement dropdown inside the total card */} - {hasMultipleMembers && settlement && settlement.flows.length > 0 && ( -
- - - {settlementOpen && ( -
- {settlement.flows.map((flow, i) => ( -
{ e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }} - onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }} - > - -
- - {fmt(flow.amount, currency)} - -
-
-
-
- -
- ))} - - {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && ( -
-
- {t('budget.netBalances')} -
-
- {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => { - const positive = b.balance > 0 - const Trend = positive ? TrendingUp : TrendingDown - return ( -
- - - {b.username} - - - - {positive ? '+' : ''}{fmt(b.balance, currency)} - -
- ) - })} -
-
- )} -
- )} -
- )} -
- - {pieSegments.length > 0 && (() => { - const decimals = currencyDecimals(currency) - const total = pieSegments.reduce((s, x) => s + x.value, 0) - const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) - const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '') - const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, ''] - const R = 80 - const CIRC = 2 * Math.PI * R - let dashOffset = 0 - return ( -
-
-
- -
-
-
{t('budget.byCategory')}
-
-
- -
- - - {pieSegments.map((seg, i) => { - const c2 = hexLighten(seg.color, 0.2) - return ( - - - - - ) - })} - - - {pieSegments.map((seg, i) => { - const segLen = total > 0 ? (seg.value / total) * CIRC : 0 - const circle = ( - - ) - dashOffset += segLen - return circle - })} - -
-
{t('budget.total')}
-
- {totalInt} - {totalDec && {decimalSep}{totalDec}} -
-
{currency}
-
-
- -
- {pieSegments.map((seg, i) => { - const pct = total > 0 ? (seg.value / total) * 100 : 0 - const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%' - const c2 = hexLighten(seg.color, 0.2) - const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color - return ( -
e.currentTarget.style.background = theme.rowHover} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'} - > -
-
-
{seg.name}
-
{fmt(seg.value, currency)}
-
- {pctLabel} -
- ) - })} -
-
- ) - })()} - -
+
) diff --git a/client/src/components/Budget/BudgetPanelAddItemRow.tsx b/client/src/components/Budget/BudgetPanelAddItemRow.tsx new file mode 100644 index 00000000..544a6650 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelAddItemRow.tsx @@ -0,0 +1,67 @@ +import { useState, useRef } from 'react' +import { Plus } from 'lucide-react' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' + +interface AddItemRowProps { + onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void + t: (key: string) => string +} + +export default function AddItemRow({ onAdd, t }: AddItemRowProps) { + const [name, setName] = useState('') + const [price, setPrice] = useState('') + const [persons, setPersons] = useState('') + const [days, setDays] = useState('') + const [note, setNote] = useState('') + const [expenseDate, setExpenseDate] = useState('') + const nameRef = useRef(null) + + const handleAdd = () => { + if (!name.trim()) return + onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null }) + setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('') + setTimeout(() => nameRef.current?.focus(), 50) + } + + const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' } + + return ( + + + setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} + placeholder={t('budget.newEntry')} style={inp} /> + + + setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} + onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }} + placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} /> + + + setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} + placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> + + + setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} + placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> + + - + - + - + +
+ +
+ + + setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} /> + + + + + + ) +} diff --git a/client/src/components/Budget/BudgetPanelCategoryTable.tsx b/client/src/components/Budget/BudgetPanelCategoryTable.tsx new file mode 100644 index 00000000..5eefec43 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelCategoryTable.tsx @@ -0,0 +1,258 @@ +import type { CSSProperties, Dispatch, SetStateAction } from 'react' +import { Trash2, Pencil, GripVertical } from 'lucide-react' +import type { BudgetItem } from '../../types' +import { currencyDecimals } from '../../utils/formatters' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' +import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers' +import InlineEditCell from './BudgetPanelInlineEditCell' +import AddItemRow from './BudgetPanelAddItemRow' +import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips' +import type { EditingCat, AddItemData } from './useBudgetPanel' + +interface BudgetCategoryTableProps { + cat: string + grouped: Map + categoryColor: (cat: string) => string + canEdit: boolean + editingCat: EditingCat | null + setEditingCat: Dispatch> + dragCat: string | null + setDragCat: Dispatch> + dragOverCat: string | null + setDragOverCat: Dispatch> + dragItem: number | null + setDragItem: Dispatch> + dragOverItem: number | null + setDragOverItem: Dispatch> + dragItemCat: string | null + setDragItemCat: Dispatch> + categoryNames: string[] + reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise + reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise + handleRenameCategory: (oldName: string, newName: string) => Promise + handleDeleteCategory: (cat: string) => Promise + handleDeleteItem: (id: number) => Promise + handleUpdateField: (id: number, field: string, value: unknown) => Promise + handleAddItem: (category: string, data: AddItemData) => Promise + tripId: number + currency: string + locale: string + t: (key: string) => string + fmt: (v: number | null | undefined, cur: string) => string + hasMultipleMembers: boolean + tripMembers: TripMember[] + setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: unknown; item: unknown }> + toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise + th: CSSProperties + td: CSSProperties +} + +export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat, + dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem, + dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems, + handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem, + tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) { + const items = grouped.get(cat) || [] + const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) + const color = categoryColor(cat) + return ( +
{ + if (!dragCat || dragCat === cat || dragItem) return + e.preventDefault(); e.dataTransfer.dropEffect = 'move' + setDragOverCat(cat) + }} + onDragLeave={e => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null) + }} + onDrop={e => { + e.preventDefault() + if (dragCat && dragCat !== cat) { + const newOrder = [...categoryNames] + const fromIdx = newOrder.indexOf(dragCat) + const toIdx = newOrder.indexOf(cat) + newOrder.splice(fromIdx, 1) + newOrder.splice(toIdx, 0, dragCat) + reorderBudgetCategories(tripId, newOrder) + } + setDragCat(null); setDragOverCat(null) + }} + > + {dragOverCat === cat &&
} +
+
+ {canEdit && ( +
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }} + onDragEnd={() => { setDragCat(null); setDragOverCat(null) }} + style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}> + +
+ )} +
+ {canEdit && editingCat?.name === cat ? ( + setEditingCat({ ...editingCat, value: e.target.value })} + onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }} + onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }} + style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }} + /> + ) : ( + <> + {cat} + {canEdit && ( + + )} + + )} +
+
+ {fmt(subtotal, currency)} + {canEdit && ( + + )} +
+
+ +
{ if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}> + + + + + + + + + + + + + + + + + {items.map(item => { + const pp = calcPP(item.total_price, item.persons) + const pd = calcPD(item.total_price, item.days) + const ppd = calcPPD(item.total_price, item.persons, item.days) + const hasMembers = (item.members?.length ?? 0) > 0 + return ( + { + if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return } + if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) } + }} + onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }} + onDrop={e => { + if (dragItem && dragItemCat === cat && dragItem !== item.id) { + e.preventDefault(); e.stopPropagation() + const ids = items.map(i => i.id) + const fromIdx = ids.indexOf(dragItem) + const toIdx = ids.indexOf(item.id) + ids.splice(fromIdx, 1) + ids.splice(toIdx, 0, dragItem) + reorderBudgetItems(tripId, ids) + setDragItem(null); setDragOverItem(null); setDragItemCat(null) + } + }} + onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> + + + + + + + + + + + + ) + })} + {canEdit && handleAddItem(cat, data)} t={t} />} + +
{t('budget.table.name')}{t('budget.table.total')}{t('budget.table.persons')}{t('budget.table.days')}{t('budget.table.perPerson')}{t('budget.table.perDay')}{t('budget.table.perPersonDay')}{t('budget.table.date')}{t('budget.table.note')}
+
+ {canEdit && ( +
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }} + onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }} + style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}> + +
+ )} +
+ handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} /> + {hasMultipleMembers && ( +
+ setBudgetItemMembers(tripId, item.id, userIds)} + onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} + compact={false} + readOnly={!canEdit} + /> +
+ )} +
+
+
+ handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + + {hasMultipleMembers ? ( + setBudgetItemMembers(tripId, item.id, userIds)} + onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} + readOnly={!canEdit} + /> + ) : ( + handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + )} + + handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + {pp != null ? fmt(pp, currency) : '-'}{pd != null ? fmt(pd, currency) : '-'}{ppd != null ? fmt(ppd, currency) : '-'} + {canEdit ? ( +
+ handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless /> +
+ ) : ( + {item.expense_date || '—'} + )} +
handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + {canEdit && ( + + )} +
+
+
+ ) +} diff --git a/client/src/components/Budget/BudgetPanelInlineEditCell.tsx b/client/src/components/Budget/BudgetPanelInlineEditCell.tsx new file mode 100644 index 00000000..c74c8bf5 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelInlineEditCell.tsx @@ -0,0 +1,71 @@ +import { useState, useEffect, useRef } from 'react' + +interface InlineEditCellProps { + value: string | number | null | undefined + onSave: (value: string | number | null) => void + type?: 'text' | 'number' + style?: React.CSSProperties + placeholder?: string + decimals?: number + locale: string + editTooltip?: string + readOnly?: boolean +} + +export default function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }: InlineEditCellProps) { + const [editing, setEditing] = useState(false) + const [editValue, setEditValue] = useState(value ?? '') + const inputRef = useRef(null) + + useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing]) + + const save = () => { + setEditing(false) + let v: string | number | null = editValue + if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p } + if (v !== value) onSave(v) + } + + const handlePaste = (e: React.ClipboardEvent) => { + if (type !== 'number') return + e.preventDefault() + let text = e.clipboardData.getData('text').trim() + // Strip everything except digits, dots, commas, minus + text = text.replace(/[^\d.,-]/g, '') + // Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal + const lastComma = text.lastIndexOf(',') + const lastDot = text.lastIndexOf('.') + const decimalPos = Math.max(lastComma, lastDot) + if (decimalPos > -1) { + const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '') + const decPart = text.substring(decimalPos + 1) + text = intPart + '.' + decPart + } else { + text = text.replace(/[.,]/g, '') + } + setEditValue(text) + } + + if (editing) { + return setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste} + onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }} + style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }} + placeholder={placeholder} /> + } + + const display = type === 'number' && value != null + ? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + : (value || '') + + return ( +
{ if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip} + style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center', + justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s', + color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }} + onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }} + onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}> + {display || placeholder || '-'} +
+ ) +} diff --git a/client/src/components/Budget/BudgetPanelMemberChips.tsx b/client/src/components/Budget/BudgetPanelMemberChips.tsx new file mode 100644 index 00000000..c5d0c190 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelMemberChips.tsx @@ -0,0 +1,179 @@ +import ReactDOM from 'react-dom' +import { useState, useEffect, useRef, useCallback } from 'react' +import { Pencil, Users, Check } from 'lucide-react' +import type { BudgetItemMember } from '../../types' + +export interface TripMember { + id: number + username: string + avatar_url?: string | null +} + +// ── Chip with custom tooltip ───────────────────────────────────────────────── +interface ChipWithTooltipProps { + label: string + avatarUrl: string | null + size?: number + paid?: boolean + onClick?: () => void +} + +export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) { + const [hover, setHover] = useState(false) + const [pos, setPos] = useState({ top: 0, left: 0 }) + const ref = useRef(null) + + const onEnter = () => { + if (ref.current) { + const rect = ref.current.getBoundingClientRect() + setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 }) + } + setHover(true) + } + + const borderColor = paid ? '#22c55e' : 'var(--border-primary)' + const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)' + + return ( + <> +
setHover(false)} + onClick={onClick} + style={{ + width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, + background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', + fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', + overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', + transition: 'border-color 0.15s, background 0.15s', + }}> + {avatarUrl + ? + : label?.[0]?.toUpperCase() + } +
+ {hover && ReactDOM.createPortal( +
+ {label} + {paid && ( + Paid + )} +
, + document.body + )} + + ) +} + +// ── Budget Member Chips (for Persons column) ──────────────────────────────── +interface BudgetMemberChipsProps { + members?: BudgetItemMember[] + tripMembers?: TripMember[] + onSetMembers: (memberIds: number[]) => void + onTogglePaid?: (userId: number, paid: boolean) => void + compact?: boolean + readOnly?: boolean +} + +export default function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) { + const chipSize = compact ? 20 : 30 + const btnSize = compact ? 18 : 28 + const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14) + const [showDropdown, setShowDropdown] = useState(false) + const [dropPos, setDropPos] = useState({ top: 0, left: 0 }) + const btnRef = useRef(null) + const dropRef = useRef(null) + + const openDropdown = useCallback(() => { + if (btnRef.current) { + const rect = btnRef.current.getBoundingClientRect() + setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 }) + } + setShowDropdown(v => !v) + }, []) + + useEffect(() => { + if (!showDropdown) return + const close = (e: MouseEvent) => { + if (dropRef.current && dropRef.current.contains(e.target as Node)) return + if (btnRef.current && btnRef.current.contains(e.target as Node)) return + setShowDropdown(false) + } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [showDropdown]) + + const memberIds = members.map(m => m.user_id) + + const toggleMember = (userId: number) => { + const newIds = memberIds.includes(userId) + ? memberIds.filter(id => id !== userId) + : [...memberIds, userId] + onSetMembers(newIds) + } + + return ( +
+ {members.map(m => ( + onTogglePaid(m.user_id, !m.paid) : undefined} + /> + ))} + {!readOnly && ( + + )} + {showDropdown && ReactDOM.createPortal( +
+ {tripMembers.map(tm => { + const isActive = memberIds.includes(tm.id) + return ( + + ) + })} +
, + document.body + )} +
+ ) +} diff --git a/client/src/components/Budget/BudgetPanelPerPersonInline.tsx b/client/src/components/Budget/BudgetPanelPerPersonInline.tsx new file mode 100644 index 00000000..5e248502 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelPerPersonInline.tsx @@ -0,0 +1,64 @@ +import { useState, useEffect } from 'react' +import { budgetApi } from '../../api/client' +import type { BudgetItem } from '../../types' +import { fmtNum, colorForUserId, widgetTheme } from './BudgetPanel.helpers' +import RingAvatar from './BudgetPanelRingAvatar' + +interface PerPersonSummaryEntry { + user_id: number + username: string + avatar_url: string | null + total_assigned: number +} + +interface PerPersonInlineProps { + tripId: number + budgetItems: BudgetItem[] + currency: string + locale: string +} + +export default function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType }) { + const [data, setData] = useState(null) + const fmt = (v: number) => fmtNum(v, locale, currency) + + useEffect(() => { + budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {}) + }, [tripId, budgetItems]) + + if (!data || data.length === 0) return null + + const people = data.map(p => ({ ...p, color: colorForUserId(p.user_id) })) + + return ( + <> + {grandTotal > 0 && ( +
+ {people.map(p => ( +
+ ))} +
+ )} + +
+ {people.map(p => { + const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0 + return ( +
+ +
+
{p.username}
+
{percent}%
+
+
{fmt(p.total_assigned)}
+
+ ) + })} +
+ + ) +} diff --git a/client/src/components/Budget/BudgetPanelPieChart.tsx b/client/src/components/Budget/BudgetPanelPieChart.tsx new file mode 100644 index 00000000..a72964a8 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelPieChart.tsx @@ -0,0 +1,53 @@ +import { Wallet } from 'lucide-react' + +interface PieSegment { + label: string + value: number + color: string +} + +// ── Pie Chart (pure CSS conic-gradient) ────────────────────────────────────── +interface PieChartProps { + segments: PieSegment[] + size?: number + totalLabel: string +} + +export default function PieChart({ segments, size = 200, totalLabel }: PieChartProps) { + if (!segments.length) return null + + const total = segments.reduce((s, x) => s + x.value, 0) + if (total === 0) return null + + let cumDeg = 0 + const stops = segments.map(seg => { + const start = cumDeg + const deg = (seg.value / total) * 360 + cumDeg += deg + return `${seg.color} ${start}deg ${start + deg}deg` + }).join(', ') + + return ( +
+
+
+ + {totalLabel} +
+
+ ) +} diff --git a/client/src/components/Budget/BudgetPanelRingAvatar.tsx b/client/src/components/Budget/BudgetPanelRingAvatar.tsx new file mode 100644 index 00000000..585bfece --- /dev/null +++ b/client/src/components/Budget/BudgetPanelRingAvatar.tsx @@ -0,0 +1,22 @@ +import { colorForUserId } from './BudgetPanel.helpers' + +export default function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) { + const color = colorForUserId(userId) + return ( +
+
+ {avatarUrl ? : username?.[0]?.toUpperCase()} +
+
+ ) +} diff --git a/client/src/components/Budget/BudgetPanelSummary.tsx b/client/src/components/Budget/BudgetPanelSummary.tsx new file mode 100644 index 00000000..693ea986 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelSummary.tsx @@ -0,0 +1,280 @@ +import type { Dispatch, SetStateAction } from 'react' +import { Wallet, Info, ChevronDown, ChevronRight, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react' +import type { BudgetItem } from '../../types' +import { currencyDecimals } from '../../utils/formatters' +import { SYMBOLS } from './BudgetPanel.constants' +import { hexLighten, widgetTheme } from './BudgetPanel.helpers' +import RingAvatar from './BudgetPanelRingAvatar' +import PerPersonInline from './BudgetPanelPerPersonInline' +import type { SettlementData, PieSegment } from './useBudgetPanel' + +interface BudgetSummaryProps { + theme: ReturnType + currency: string + locale: string + grandTotal: number + hasMultipleMembers: boolean + budgetItems: BudgetItem[] + settlement: SettlementData | null + settlementOpen: boolean + setSettlementOpen: Dispatch> + pieSegments: PieSegment[] + isDark: boolean + tripId: number + t: (key: string) => string + fmt: (v: number | null | undefined, cur: string) => string +} + +export default function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems, + settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: BudgetSummaryProps) { + return ( +
+ +
+
+
+ +
+
+
{t('budget.totalBudget')}
+
+
+ + {(() => { + const decimals = currencyDecimals(currency) + const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + const sep = (0.1).toLocaleString(locale).replace(/\d/g, '') + const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, ''] + return ( +
+ {integerPart} + {decimalPart && {sep}{decimalPart}} + {SYMBOLS[currency] || currency} +
+ ) + })()} +
+ {currency} +
+ + {hasMultipleMembers && (budgetItems || []).some(i => (i.members?.length ?? 0) > 0) && ( + + )} + + {/* Settlement dropdown inside the total card */} + {hasMultipleMembers && settlement && settlement.flows.length > 0 && ( +
+ + + {settlementOpen && ( +
+ {settlement.flows.map((flow, i) => ( +
{ e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }} + onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }} + > + +
+ + {fmt(flow.amount, currency)} + +
+
+
+
+ +
+ ))} + + {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && ( +
+
+ {t('budget.netBalances')} +
+
+ {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => { + const positive = b.balance > 0 + const Trend = positive ? TrendingUp : TrendingDown + return ( +
+ + + {b.username} + + + + {positive ? '+' : ''}{fmt(b.balance, currency)} + +
+ ) + })} +
+
+ )} +
+ )} +
+ )} +
+ + {pieSegments.length > 0 && (() => { + const decimals = currencyDecimals(currency) + const total = pieSegments.reduce((s, x) => s + x.value, 0) + const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '') + const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, ''] + const R = 80 + const CIRC = 2 * Math.PI * R + let dashOffset = 0 + return ( +
+
+
+ +
+
+
{t('budget.byCategory')}
+
+
+ +
+ + + {pieSegments.map((seg, i) => { + const c2 = hexLighten(seg.color, 0.2) + return ( + + + + + ) + })} + + + {pieSegments.map((seg, i) => { + const segLen = total > 0 ? (seg.value / total) * CIRC : 0 + const circle = ( + + ) + dashOffset += segLen + return circle + })} + +
+
{t('budget.total')}
+
+ {totalInt} + {totalDec && {decimalSep}{totalDec}} +
+
{currency}
+
+
+ +
+ {pieSegments.map((seg, i) => { + const pct = total > 0 ? (seg.value / total) * 100 : 0 + const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%' + const c2 = hexLighten(seg.color, 0.2) + const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color + return ( +
e.currentTarget.style.background = theme.rowHover} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'} + > +
+
+
{seg.name}
+
{fmt(seg.value, currency)}
+
+ {pctLabel} +
+ ) + })} +
+
+ ) + })()} + +
+ ) +} diff --git a/client/src/components/Budget/useBudgetPanel.ts b/client/src/components/Budget/useBudgetPanel.ts new file mode 100644 index 00000000..442bf037 --- /dev/null +++ b/client/src/components/Budget/useBudgetPanel.ts @@ -0,0 +1,211 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react' +import type { CSSProperties } from 'react' +import { useTripStore } from '../../store/tripStore' +import { useCanDo } from '../../store/permissionsStore' +import { useToast } from '../shared/Toast' +import { useTranslation } from '../../i18n' +import { budgetApi } from '../../api/client' +import type { BudgetItem } from '../../types' +import { currencyDecimals } from '../../utils/formatters' +import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers' +import { PIE_COLORS } from './BudgetPanel.constants' +import type { TripMember } from './BudgetPanelMemberChips' + +function useIsDark(): boolean { + const [dark, setDark] = useState(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')) + useEffect(() => { + if (typeof document === 'undefined') return + const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark'))) + mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + return () => mo.disconnect() + }, []) + return dark +} + +export interface EditingCat { + name: string + value: string +} + +interface SettlementPerson { + user_id: number + username: string + avatar_url: string | null +} + +interface SettlementFlow { + from: SettlementPerson + to: SettlementPerson + amount: number +} + +interface SettlementBalance { + user_id: number + username: string + avatar_url: string | null + balance: number +} + +export interface SettlementData { + balances: SettlementBalance[] + flows: SettlementFlow[] +} + +export interface PieSegment { + name: string + value: number + color: string +} + +export interface AddItemData { + name: string + total_price: number + persons: number | null + days: number | null + note: string | null + expense_date: string | null +} + +export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) { + const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore() + const can = useCanDo() + const toast = useToast() + const { t, locale } = useTranslation() + const isDark = useIsDark() + const theme = useMemo(() => widgetTheme(isDark), [isDark]) + const [newCategoryName, setNewCategoryName] = useState('') + const [editingCat, setEditingCat] = useState(null) // { name, value } + const [settlement, setSettlement] = useState(null) + const [settlementOpen, setSettlementOpen] = useState(false) + const currency = trip?.currency || 'EUR' + const canEdit = can('budget_edit', trip) + + const fmt = (v: number | null | undefined, cur: string) => fmtNum(v, locale, cur) + const hasMultipleMembers = tripMembers.length > 1 + + // Drag state for categories + const [dragCat, setDragCat] = useState(null) + const [dragOverCat, setDragOverCat] = useState(null) + // Drag state for items within a category + const [dragItem, setDragItem] = useState(null) + const [dragOverItem, setDragOverItem] = useState(null) + const [dragItemCat, setDragItemCat] = useState(null) + + // Load settlement data whenever budget items change + useEffect(() => { + if (!hasMultipleMembers) return + budgetApi.settlement(tripId).then(setSettlement).catch(() => {}) + }, [tripId, budgetItems, hasMultipleMembers]) + + const setCurrency = (cur: string) => { + if (tripId) updateTrip(tripId, { currency: cur }) + } + + useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId]) + + const grouped = useMemo(() => { + const map = new Map() + for (const item of (budgetItems || [])) { + const cat = item.category || 'Other' + if (!map.has(cat)) map.set(cat, []) + map.get(cat)!.push(item) + } + return map + }, [budgetItems]) + + const categoryNames = Array.from(grouped.keys()) + + // Stable color mapping: assign index-based colors once, never reassign on reorder + const colorMapRef = useRef(new Map()) + const categoryColor = useCallback((cat: string) => { + const map = colorMapRef.current + if (!map.has(cat)) { + map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length]) + } + return map.get(cat)! + }, []) + const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0) + + const pieSegments = useMemo(() => + categoryNames.map((cat, i) => ({ + name: cat, + value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0), + color: categoryColor(cat), + })).filter(s => s.value > 0) + , [grouped, categoryNames]) + + const handleAddItem = async (category: string, data: AddItemData) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } } + const handleUpdateField = async (id: number, field: string, value: unknown) => { try { await updateBudgetItem(tripId, id, { [field]: value } as Partial) } catch { toast.error(t('common.error')) } } + const handleDeleteItem = async (id: number) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } } + const handleDeleteCategory = async (cat: string) => { + const items = grouped.get(cat) || [] + try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) } + catch { toast.error(t('common.error')) } + } + const handleRenameCategory = async (oldName: string, newName: string) => { + if (!newName.trim() || newName.trim() === oldName) return + const items = grouped.get(oldName) || [] + try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) } + catch { toast.error(t('common.error')) } + } + const handleAddCategory = () => { + if (!newCategoryName.trim()) return + Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })) + .catch(() => toast.error(t('common.error'))) + setNewCategoryName('') + } + + const handleExportCsv = () => { + const sep = ';' + const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s } + const d = currencyDecimals(currency) + const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : '' + + const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } + const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note'] + const rows = [header.join(sep)] + + for (const cat of categoryNames) { + for (const item of (grouped.get(cat) || [])) { + const pp = calcPP(item.total_price, item.persons) + const pd = calcPD(item.total_price, item.days) + const ppd = calcPPD(item.total_price, item.persons, item.days) + rows.push([ + esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')), + fmtPrice(item.total_price), item.persons ?? '', item.days ?? '', + fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd), + esc(item.note || ''), + ].join(sep)) + } + } + + const bom = '' + const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim() + a.download = `budget-${safeName}.csv` + a.click() + URL.revokeObjectURL(url) + } + + const th: CSSProperties = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } + const td: CSSProperties = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' } + + return { + trip, budgetItems, + setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories, + t, locale, isDark, theme, + newCategoryName, setNewCategoryName, + editingCat, setEditingCat, + settlement, settlementOpen, setSettlementOpen, + currency, canEdit, fmt, hasMultipleMembers, + dragCat, setDragCat, dragOverCat, setDragOverCat, + dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat, + setCurrency, + grouped, categoryNames, categoryColor, grandTotal, pieSegments, + handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv, + th, td, + } +} diff --git a/client/src/components/Collab/CollabChat.constants.ts b/client/src/components/Collab/CollabChat.constants.ts new file mode 100644 index 00000000..3b8794cd --- /dev/null +++ b/client/src/components/Collab/CollabChat.constants.ts @@ -0,0 +1,10 @@ +export const EMOJI_CATEGORIES = { + 'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'], + 'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'], + 'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'], +} + +// Reaction Quick Menu (right-click) +export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉'] + +export const URL_REGEX = /https?:\/\/[^\s<>"']+/g diff --git a/client/src/components/Collab/CollabChat.helpers.ts b/client/src/components/Collab/CollabChat.helpers.ts new file mode 100644 index 00000000..84de232c --- /dev/null +++ b/client/src/components/Collab/CollabChat.helpers.ts @@ -0,0 +1,42 @@ +// ── Twemoji helper (Apple-style emojis via CDN) ── +export function emojiToCodepoint(emoji) { + const codepoints = [] + for (const c of emoji) { + const cp = c.codePointAt(0) + if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector + } + return codepoints.join('-') +} + +// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC +export function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) } + +export function formatTime(isoString, is12h) { + const d = parseUTC(isoString) + const h = d.getHours() + const mm = String(d.getMinutes()).padStart(2, '0') + if (is12h) { + const period = h >= 12 ? 'PM' : 'AM' + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h + return `${h12}:${mm} ${period}` + } + return `${String(h).padStart(2, '0')}:${mm}` +} + +export function formatDateSeparator(isoString, t) { + const d = parseUTC(isoString) + const now = new Date() + const yesterday = new Date(); yesterday.setDate(now.getDate() - 1) + + if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today' + if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday' + + return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) +} + +export function shouldShowDateSeparator(msg, prevMsg) { + if (!prevMsg) return true + const d1 = parseUTC(msg.created_at).toDateString() + const d2 = parseUTC(prevMsg.created_at).toDateString() + return d1 !== d2 +} diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index 2735029b..94b7f1b0 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -1,350 +1,10 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react' import ReactDOM from 'react-dom' -import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react' -import { collabApi } from '../../api/client' -import { useSettingsStore } from '../../store/settingsStore' -import { useCanDo } from '../../store/permissionsStore' -import { useTripStore } from '../../store/tripStore' -import { addListener, removeListener } from '../../api/websocket' -import { useTranslation } from '../../i18n' +import { ArrowUp, Reply, Smile, X } from 'lucide-react' import type { User } from '../../types' - -interface ChatReaction { - emoji: string - count: number - users: { id: number; username: string }[] -} - -interface ChatMessage { - id: number - trip_id: number - user_id: number - text: string - reply_to_id: number | null - reactions: ChatReaction[] - created_at: string - user?: { username: string; avatar_url: string | null } - reply_to?: ChatMessage | null -} - -// ── Twemoji helper (Apple-style emojis via CDN) ── -function emojiToCodepoint(emoji) { - const codepoints = [] - for (const c of emoji) { - const cp = c.codePointAt(0) - if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector - } - return codepoints.join('-') -} - -function TwemojiImg({ emoji, size = 20, style = {} }) { - const cp = emojiToCodepoint(emoji) - const [failed, setFailed] = useState(false) - - if (failed) { - return {emoji} - } - - return ( - {emoji} setFailed(true)} - /> - ) -} - -const EMOJI_CATEGORIES = { - 'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'], - 'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'], - 'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'], -} - -// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC -function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) } - -function formatTime(isoString, is12h) { - const d = parseUTC(isoString) - const h = d.getHours() - const mm = String(d.getMinutes()).padStart(2, '0') - if (is12h) { - const period = h >= 12 ? 'PM' : 'AM' - const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h - return `${h12}:${mm} ${period}` - } - return `${String(h).padStart(2, '0')}:${mm}` -} - -function formatDateSeparator(isoString, t) { - const d = parseUTC(isoString) - const now = new Date() - const yesterday = new Date(); yesterday.setDate(now.getDate() - 1) - - if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today' - if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday' - - return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) -} - -function shouldShowDateSeparator(msg, prevMsg) { - if (!prevMsg) return true - const d1 = parseUTC(msg.created_at).toDateString() - const d2 = parseUTC(prevMsg.created_at).toDateString() - return d1 !== d2 -} - -/* ── Emoji Picker ── */ -interface EmojiPickerProps { - onSelect: (emoji: string) => void - onClose: () => void - anchorRef: React.RefObject - containerRef: React.RefObject -} - -function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) { - const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0]) - const ref = useRef(null) - - const getPos = () => { - const container = containerRef?.current - const anchor = anchorRef?.current - if (container && anchor) { - const cRect = container.getBoundingClientRect() - const aRect = anchor.getBoundingClientRect() - return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 } - } - return { bottom: 80, left: 0 } - } - const pos = getPos() - - useEffect(() => { - const close = (e) => { - if (ref.current && ref.current.contains(e.target)) return - if (anchorRef?.current && anchorRef.current.contains(e.target)) return - onClose() - } - document.addEventListener('mousedown', close) - return () => document.removeEventListener('mousedown', close) - }, [onClose, anchorRef]) - - return ReactDOM.createPortal( -
- {/* Category tabs */} -
- {Object.keys(EMOJI_CATEGORIES).map(c => ( - - ))} -
- {/* Emoji grid */} -
- {EMOJI_CATEGORIES[cat].map((emoji, i) => ( - - ))} -
-
, - document.body - ) -} - -/* ── Reaction Quick Menu (right-click) ── */ -const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉'] - -interface ReactionMenuProps { - x: number - y: number - onReact: (emoji: string) => void - onClose: () => void -} - -function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) { - const ref = useRef(null) - - useEffect(() => { - const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() } - document.addEventListener('mousedown', close) - return () => document.removeEventListener('mousedown', close) - }, [onClose]) - - // Clamp to viewport - const menuWidth = 156 - const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8)) - - return ( -
- {QUICK_REACTIONS.map(emoji => ( - - ))} -
- ) -} - -/* ── Message Text with clickable URLs ── */ -interface MessageTextProps { - text: string -} - -function MessageText({ text }: MessageTextProps) { - const parts = text.split(URL_REGEX) - const urls = text.match(URL_REGEX) || [] - const result = [] - parts.forEach((part, i) => { - if (part) result.push(part) - if (urls[i]) result.push( -
- {urls[i]} - - ) - }) - return <>{result} -} - -/* ── Link Preview ── */ -const URL_REGEX = /https?:\/\/[^\s<>"']+/g -const previewCache = {} - -interface LinkPreviewProps { - url: string - tripId: number - own: boolean - onLoad: (() => void) | undefined -} - -function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) { - const [data, setData] = useState(previewCache[url] || null) - const [loading, setLoading] = useState(!previewCache[url]) - - useEffect(() => { - if (previewCache[url]) return - collabApi.linkPreview(tripId, url).then(d => { - previewCache[url] = d - setData(d) - setLoading(false) - if (d?.title || d?.description || d?.image) onLoad?.() - }).catch(() => setLoading(false)) - }, [url, tripId]) - - if (loading || !data || (!data.title && !data.description && !data.image)) return null - - const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })() - - return ( - e.currentTarget.style.opacity = '0.85'} - onMouseLeave={e => e.currentTarget.style.opacity = '1'} - > - {data.image && ( - e.target.style.display = 'none'} /> - )} -
- {domain && ( -
- {data.site_name || domain} -
- )} - {data.title && ( -
- {data.title} -
- )} - {data.description && ( -
- {data.description} -
- )} -
-
- ) -} - -/* ── Reaction Badge with NOMAD tooltip ── */ -interface ReactionBadgeProps { - reaction: ChatReaction - currentUserId: number - onReact: () => void -} - -function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) { - const [hover, setHover] = useState(false) - const [pos, setPos] = useState({ top: 0, left: 0 }) - const ref = useRef(null) - const names = reaction.users.map(u => u.username).join(', ') - - return ( - <> - - {hover && names && ReactDOM.createPortal( -
- {names} -
, - document.body - )} - - ) -} +import { useCollabChat } from './useCollabChat' +import { ChatMessages } from './CollabChatMessages' +import { EmojiPicker } from './CollabChatEmojiPicker' +import { ReactionMenu } from './CollabChatReactionMenu' /* ── Main Component ── */ interface CollabChatProps { @@ -353,173 +13,8 @@ interface CollabChatProps { } export default function CollabChat({ tripId, currentUser }: CollabChatProps) { - const { t } = useTranslation() - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const can = useCanDo() - const trip = useTripStore((s) => s.trip) - const canEdit = can('collab_edit', trip) - - const [messages, setMessages] = useState([]) - const [loading, setLoading] = useState(true) - const [hasMore, setHasMore] = useState(false) - const [loadingMore, setLoadingMore] = useState(false) - const [text, setText] = useState('') - const [replyTo, setReplyTo] = useState(null) - const [hoveredId, setHoveredId] = useState(null) - const [sending, setSending] = useState(false) - const [showEmoji, setShowEmoji] = useState(false) - const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y } - const [deletingIds, setDeletingIds] = useState(new Set()) - const deleteTimersRef = useRef[]>([]) - - useEffect(() => { - return () => { deleteTimersRef.current.forEach(clearTimeout) } - }, []) - - const containerRef = useRef(null) - const messagesRef = useRef(messages) - messagesRef.current = messages - const scrollRef = useRef(null) - const textareaRef = useRef(null) - const emojiBtnRef = useRef(null) - const isAtBottom = useRef(true) - - const scrollToBottom = useCallback((behavior = 'auto') => { - const el = scrollRef.current - if (!el) return - requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior })) - }, []) - - const checkAtBottom = useCallback(() => { - const el = scrollRef.current - if (!el) return - isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48 - }, []) - - /* ── load messages ── */ - useEffect(() => { - let cancelled = false - setLoading(true) - collabApi.getMessages(tripId).then(data => { - if (cancelled) return - const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m) - setMessages(msgs) - setHasMore(msgs.length >= 100) - setLoading(false) - setTimeout(() => scrollToBottom(), 30) - }).catch(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - }, [tripId, scrollToBottom]) - - /* ── load more ── */ - const handleLoadMore = useCallback(async () => { - if (loadingMore || messages.length === 0) return - setLoadingMore(true) - const el = scrollRef.current - const prevHeight = el ? el.scrollHeight : 0 - try { - const data = await collabApi.getMessages(tripId, messages[0]?.id) - const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m) - if (older.length === 0) { setHasMore(false) } - else { - setMessages(prev => [...older, ...prev]) - setHasMore(older.length >= 100) - requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight }) - } - } catch {} finally { setLoadingMore(false) } - }, [tripId, loadingMore, messages]) - - /* ── websocket ── */ - useEffect(() => { - const handler = (event) => { - if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) { - setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message]) - if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30) - } - if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) { - setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m)) - if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) - } - if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) { - setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m)) - } - } - addListener(handler) - return () => removeListener(handler) - }, [tripId, scrollToBottom]) - - /* ── auto-resize textarea ── */ - const handleTextChange = useCallback((e) => { - setText(e.target.value) - const ta = textareaRef.current - if (ta) { - ta.style.height = 'auto' - const h = Math.min(ta.scrollHeight, 100) - ta.style.height = h + 'px' - ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden' - } - }, []) - - /* ── send ── */ - const handleSend = useCallback(async () => { - const body = text.trim() - if (!body || sending) return - setSending(true) - try { - const payload = { text: body } - if (replyTo) payload.reply_to = replyTo.id - const data = await collabApi.sendMessage(tripId, payload) - if (data?.message) { - setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message]) - } - setText(''); setReplyTo(null); setShowEmoji(false) - if (textareaRef.current) textareaRef.current.style.height = 'auto' - isAtBottom.current = true - setTimeout(() => scrollToBottom('smooth'), 50) - } catch {} finally { setSending(false) } - }, [text, sending, replyTo, tripId, scrollToBottom]) - - const handleKeyDown = useCallback((e) => { - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } - }, [handleSend]) - - const handleDelete = useCallback(async (msgId) => { - const msg = messages.find(m => m.id === msgId) - requestAnimationFrame(() => { - setDeletingIds(prev => new Set(prev).add(msgId)) - }) - const t = setTimeout(async () => { - try { - await collabApi.deleteMessage(tripId, msgId) - setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m)) - } catch {} - setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s }) - }, 400) - deleteTimersRef.current.push(t) - }, [tripId]) - - const handleReact = useCallback(async (msgId, emoji) => { - setReactMenu(null) - try { - const data = await collabApi.reactMessage(tripId, msgId, emoji) - setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m)) - } catch {} - }, [tripId]) - - const handleEmojiSelect = useCallback((emoji) => { - setText(prev => prev + emoji) - textareaRef.current?.focus() - }, []) - - const isOwn = (msg) => String(msg.user_id) === String(currentUser.id) - - // Check if message is only emoji (1-3 emojis, no other text) - const isEmojiOnly = (text) => { - const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u - return emojiRegex.test(text.trim()) - } - - /* ── Loading ── */ + const S = useCollabChat(tripId, currentUser) + const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S if (loading) { return (
@@ -528,247 +23,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
) } - - /* ── Main ── */ return (
- {/* Messages */} - {messages.length === 0 ? ( -
- - {t('collab.chat.empty')} - {t('collab.chat.emptyDesc') || ''} -
- ) : ( -
- {hasMore && ( -
- -
- )} - - {messages.map((msg, idx) => { - const own = isOwn(msg) - const prevMsg = messages[idx - 1] - const nextMsg = messages[idx + 1] - const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id) - const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id) - const showDate = shouldShowDateSeparator(msg, prevMsg) - const showAvatar = !own && isLastInGroup - const bigEmoji = isEmojiOnly(msg.text) - const hasReply = msg.reply_text || msg.reply_to - // Deleted message placeholder - if (msg._deleted) { - return ( - - {showDate && ( -
- - {formatDateSeparator(msg.created_at, t)} - -
- )} -
- - {msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)} - -
-
- ) - } - - // Bubble border radius — iMessage style tails - const br = own - ? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px` - : `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}` - - return ( - - {/* Date separator */} - {showDate && ( -
- - {formatDateSeparator(msg.created_at, t)} - -
- )} - -
- {/* Avatar slot for others */} - {!own && ( -
- {showAvatar && ( - msg.user_avatar ? ( - - ) : ( -
- {(msg.username || '?')[0].toUpperCase()} -
- ) - )} -
- )} - -
- {/* Username for others at group start */} - {!own && isNewGroup && ( - - {msg.username} - - )} - - {/* Bubble */} -
setHoveredId(msg.id)} - onMouseLeave={() => setHoveredId(null)} - onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }} - onTouchEnd={e => { - const now = Date.now() - const lastTap = e.currentTarget.dataset.lastTap || 0 - if (now - lastTap < 300 && canEdit) { - e.preventDefault() - const touch = e.changedTouches?.[0] - if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY }) - } - e.currentTarget.dataset.lastTap = now - }} - > - {bigEmoji ? ( -
- {msg.text} -
- ) : ( -
- {/* Inline reply quote */} - {hasReply && ( -
-
- {msg.reply_username || ''} -
-
- {(msg.reply_text || '').slice(0, 80)} -
-
- )} - {hasReply ? ( -
- ) : } - {(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => ( - { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} /> - ))} -
- )} - - {/* Hover actions */} -
- - {own && canEdit && ( - - )} -
-
- - {/* Reactions — iMessage style floating badge */} - {msg.reactions?.length > 0 && ( -
-
- {msg.reactions.map(r => { - const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id)) - return ( - { if (canEdit) handleReact(msg.id, r.emoji) }} /> - ) - })} -
-
- )} - - {/* Timestamp — only on last message of group */} - {isLastInGroup && ( - - {formatTime(msg.created_at, is12h)} - - )} -
-
-
- ) - })} -
- )} - + {/* Composer */} -
+
{/* Reply preview */} {replyTo && (
void + onClose: () => void + anchorRef: React.RefObject + containerRef: React.RefObject +} + +export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) { + const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0]) + const ref = useRef(null) + + const getPos = () => { + const container = containerRef?.current + const anchor = anchorRef?.current + if (container && anchor) { + const cRect = container.getBoundingClientRect() + const aRect = anchor.getBoundingClientRect() + return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 } + } + return { bottom: 80, left: 0 } + } + const pos = getPos() + + useEffect(() => { + const close = (e) => { + if (ref.current && ref.current.contains(e.target)) return + if (anchorRef?.current && anchorRef.current.contains(e.target)) return + onClose() + } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [onClose, anchorRef]) + + return ReactDOM.createPortal( +
+ {/* Category tabs */} +
+ {Object.keys(EMOJI_CATEGORIES).map(c => ( + + ))} +
+ {/* Emoji grid */} +
+ {EMOJI_CATEGORIES[cat].map((emoji, i) => ( + + ))} +
+
, + document.body + ) +} diff --git a/client/src/components/Collab/CollabChatLinkPreview.tsx b/client/src/components/Collab/CollabChatLinkPreview.tsx new file mode 100644 index 00000000..70d282b2 --- /dev/null +++ b/client/src/components/Collab/CollabChatLinkPreview.tsx @@ -0,0 +1,65 @@ +import { useState, useEffect } from 'react' +import { collabApi } from '../../api/client' + +/* ── Link Preview ── */ +const previewCache = {} + +interface LinkPreviewProps { + url: string + tripId: number + own: boolean + onLoad: (() => void) | undefined +} + +export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) { + const [data, setData] = useState(previewCache[url] || null) + const [loading, setLoading] = useState(!previewCache[url]) + + useEffect(() => { + if (previewCache[url]) return + collabApi.linkPreview(tripId, url).then(d => { + previewCache[url] = d + setData(d) + setLoading(false) + if (d?.title || d?.description || d?.image) onLoad?.() + }).catch(() => setLoading(false)) + }, [url, tripId]) + + if (loading || !data || (!data.title && !data.description && !data.image)) return null + + const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })() + + return ( + e.currentTarget.style.opacity = '0.85'} + onMouseLeave={e => e.currentTarget.style.opacity = '1'} + > + {data.image && ( + e.currentTarget.style.display = 'none'} /> + )} +
+ {domain && ( +
+ {data.site_name || domain} +
+ )} + {data.title && ( +
+ {data.title} +
+ )} + {data.description && ( +
+ {data.description} +
+ )} +
+
+ ) +} diff --git a/client/src/components/Collab/CollabChatMessageText.tsx b/client/src/components/Collab/CollabChatMessageText.tsx new file mode 100644 index 00000000..795cf735 --- /dev/null +++ b/client/src/components/Collab/CollabChatMessageText.tsx @@ -0,0 +1,21 @@ +import { URL_REGEX } from './CollabChat.constants' + +/* ── Message Text with clickable URLs ── */ +interface MessageTextProps { + text: string +} + +export function MessageText({ text }: MessageTextProps) { + const parts = text.split(URL_REGEX) + const urls = text.match(URL_REGEX) || [] + const result = [] + parts.forEach((part, i) => { + if (part) result.push(part) + if (urls[i]) result.push( + + {urls[i]} + + ) + }) + return <>{result} +} diff --git a/client/src/components/Collab/CollabChatMessages.tsx b/client/src/components/Collab/CollabChatMessages.tsx new file mode 100644 index 00000000..75bec19d --- /dev/null +++ b/client/src/components/Collab/CollabChatMessages.tsx @@ -0,0 +1,250 @@ +import React from 'react' +import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react' +import { URL_REGEX } from './CollabChat.constants' +import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers' +import { MessageText } from './CollabChatMessageText' +import { LinkPreview } from './CollabChatLinkPreview' +import { ReactionBadge } from './CollabChatReactionBadge' + +export function ChatMessages(props: any) { + const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props + return ( + <> + {/* Messages */} + {messages.length === 0 ? ( +
+ + {t('collab.chat.empty')} + {t('collab.chat.emptyDesc') || ''} +
+ ) : ( +
+ {hasMore && ( +
+ +
+ )} + + {messages.map((msg, idx) => { + const own = isOwn(msg) + const prevMsg = messages[idx - 1] + const nextMsg = messages[idx + 1] + const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id) + const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id) + const showDate = shouldShowDateSeparator(msg, prevMsg) + const showAvatar = !own && isLastInGroup + const bigEmoji = isEmojiOnly(msg.text) + const hasReply = msg.reply_text || msg.reply_to + // Deleted message placeholder + if (msg._deleted) { + return ( + + {showDate && ( +
+ + {formatDateSeparator(msg.created_at, t)} + +
+ )} +
+ + {msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)} + +
+
+ ) + } + + // Bubble border radius — iMessage style tails + const br = own + ? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px` + : `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}` + + return ( + + {/* Date separator */} + {showDate && ( +
+ + {formatDateSeparator(msg.created_at, t)} + +
+ )} + +
+ {/* Avatar slot for others */} + {!own && ( +
+ {showAvatar && ( + msg.user_avatar ? ( + + ) : ( +
+ {(msg.username || '?')[0].toUpperCase()} +
+ ) + )} +
+ )} + +
+ {/* Username for others at group start */} + {!own && isNewGroup && ( + + {msg.username} + + )} + + {/* Bubble */} +
setHoveredId(msg.id)} + onMouseLeave={() => setHoveredId(null)} + onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }} + onTouchEnd={e => { + const now = Date.now() + const lastTap = Number(e.currentTarget.dataset.lastTap) || 0 + if (now - lastTap < 300 && canEdit) { + e.preventDefault() + const touch = e.changedTouches?.[0] + if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY }) + } + e.currentTarget.dataset.lastTap = String(now) + }} + > + {bigEmoji ? ( +
+ {msg.text} +
+ ) : ( +
+ {/* Inline reply quote */} + {hasReply && ( +
+
+ {msg.reply_username || ''} +
+
+ {(msg.reply_text || '').slice(0, 80)} +
+
+ )} + {hasReply ? ( +
+ ) : } + {(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => ( + { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} /> + ))} +
+ )} + + {/* Hover actions */} +
+ + {own && canEdit && ( + + )} +
+
+ + {/* Reactions — iMessage style floating badge */} + {msg.reactions?.length > 0 && ( +
+
+ {msg.reactions.map(r => { + const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id)) + return ( + { if (canEdit) handleReact(msg.id, r.emoji) }} /> + ) + })} +
+
+ )} + + {/* Timestamp — only on last message of group */} + {isLastInGroup && ( + + {formatTime(msg.created_at, is12h)} + + )} +
+
+
+ ) + })} +
+ )} + + + ) +} diff --git a/client/src/components/Collab/CollabChatReactionBadge.tsx b/client/src/components/Collab/CollabChatReactionBadge.tsx new file mode 100644 index 00000000..43122137 --- /dev/null +++ b/client/src/components/Collab/CollabChatReactionBadge.tsx @@ -0,0 +1,53 @@ +import { useState, useRef } from 'react' +import ReactDOM from 'react-dom' +import { TwemojiImg } from './CollabChatTwemojiImg' +import type { ChatReaction } from './CollabChat.types' + +/* ── Reaction Badge with NOMAD tooltip ── */ +interface ReactionBadgeProps { + reaction: ChatReaction + currentUserId: number + onReact: () => void +} + +export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) { + const [hover, setHover] = useState(false) + const [pos, setPos] = useState({ top: 0, left: 0 }) + const ref = useRef(null) + const names = reaction.users.map(u => u.username).join(', ') + + return ( + <> + + {hover && names && ReactDOM.createPortal( +
+ {names} +
, + document.body + )} + + ) +} diff --git a/client/src/components/Collab/CollabChatReactionMenu.tsx b/client/src/components/Collab/CollabChatReactionMenu.tsx new file mode 100644 index 00000000..57e74a1e --- /dev/null +++ b/client/src/components/Collab/CollabChatReactionMenu.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react' +import { QUICK_REACTIONS } from './CollabChat.constants' +import { TwemojiImg } from './CollabChatTwemojiImg' + +/* ── Reaction Quick Menu (right-click) ── */ +interface ReactionMenuProps { + x: number + y: number + onReact: (emoji: string) => void + onClose: () => void +} + +export function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) { + const ref = useRef(null) + + useEffect(() => { + const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [onClose]) + + // Clamp to viewport + const menuWidth = 156 + const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8)) + + return ( +
+ {QUICK_REACTIONS.map(emoji => ( + + ))} +
+ ) +} diff --git a/client/src/components/Collab/CollabChatTwemojiImg.tsx b/client/src/components/Collab/CollabChatTwemojiImg.tsx new file mode 100644 index 00000000..6e29538e --- /dev/null +++ b/client/src/components/Collab/CollabChatTwemojiImg.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' +import { emojiToCodepoint } from './CollabChat.helpers' + +export function TwemojiImg({ emoji, size = 20, style = {} }) { + const cp = emojiToCodepoint(emoji) + const [failed, setFailed] = useState(false) + + if (failed) { + return {emoji} + } + + return ( + {emoji} setFailed(true)} + /> + ) +} diff --git a/client/src/components/Collab/CollabNotes.constants.ts b/client/src/components/Collab/CollabNotes.constants.ts new file mode 100644 index 00000000..bf6e53cd --- /dev/null +++ b/client/src/components/Collab/CollabNotes.constants.ts @@ -0,0 +1,10 @@ +export const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" + +export const NOTE_COLORS = [ + { value: '#6366f1', label: 'Indigo' }, + { value: '#ef4444', label: 'Red' }, + { value: '#f59e0b', label: 'Amber' }, + { value: '#10b981', label: 'Emerald' }, + { value: '#3b82f6', label: 'Blue' }, + { value: '#8b5cf6', label: 'Violet' }, +] diff --git a/client/src/components/Collab/CollabNotes.helpers.ts b/client/src/components/Collab/CollabNotes.helpers.ts new file mode 100644 index 00000000..add4605c --- /dev/null +++ b/client/src/components/Collab/CollabNotes.helpers.ts @@ -0,0 +1,16 @@ +// Pure formatting helper for note timestamps. Falls back to translated +// relative labels for recent timestamps and a localized short date beyond a week. +export const formatTimestamp = (ts, t, locale) => { + if (!ts) return '' + const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z') + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const diffMins = Math.floor(diffMs / 60000) + if (diffMins < 1) return t('collab.chat.justNow') || 'just now' + if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago` + const diffHrs = Math.floor(diffMins / 60) + if (diffHrs < 24) return t('collab.chat.hoursAgo', { n: diffHrs }) || `${diffHrs}h ago` + const diffDays = Math.floor(diffHrs / 24) + if (diffDays < 7) return t('collab.notes.daysAgo', { n: diffDays }) || `${diffDays}d ago` + return d.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric' }) +} diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 2d6f253c..e19bce3b 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -1,904 +1,23 @@ -import ReactDOM from 'react-dom' -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import DOM from 'react-dom' +import { useState, useEffect, useCallback, useMemo } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' -import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react' +import ReactDOM from 'react-dom' +import { Plus, Pencil, X, StickyNote, Settings } from 'lucide-react' import { collabApi } from '../../api/client' -import { getAuthUrl } from '../../api/authUrl' -import { openFile } from '../../utils/fileDownload' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' import type { User } from '../../types' - -interface NoteFile { - id: number - filename: string - original_name: string - mime_type: string - url?: string -} - -interface CollabNote { - id: number - trip_id: number - title: string - content: string - category: string - website: string | null - pinned: boolean - color: string | null - username: string - avatar_url: string | null - avatar: string | null - user_id: number - created_at: string - author?: { username: string; avatar: string | null } - user?: { username: string; avatar: string | null } - files?: NoteFile[] -} - -interface NoteAuthor { - username: string - avatar?: string | null -} - -const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" - -// ── Website Thumbnail (fetches OG image) ──────────────────────────────────── -const ogCache = {} - -interface WebsiteThumbnailProps { - url: string - tripId: number - color: string -} - -function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) { - const [data, setData] = useState(ogCache[url] || null) - const [failed, setFailed] = useState(false) - - useEffect(() => { - if (ogCache[url]) { setData(ogCache[url]); return } - collabApi.linkPreview(tripId, url).then(d => { ogCache[url] = d; setData(d) }).catch(() => setFailed(true)) - }, [url, tripId]) - - const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return 'link' } })() - - return ( - { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} - onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}> - {data?.image && !failed ? ( - setFailed(true)} /> - ) : ( - <> - - - {domain} - - - )} - - ) -} - -// ── File Preview Portal ───────────────────────────────────────────────────── -interface FilePreviewPortalProps { - file: NoteFile | null - onClose: () => void -} - -function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { - const [authUrl, setAuthUrl] = useState('') - const rawUrl = file?.url || '' - useEffect(() => { - setAuthUrl('') - if (!rawUrl) return - getAuthUrl(rawUrl, 'download').then(setAuthUrl) - }, [rawUrl]) - - if (!file) return null - const isImage = file.mime_type?.startsWith('image/') - const isPdf = file.mime_type === 'application/pdf' - const isTxt = file.mime_type?.startsWith('text/') - - const openInNewTab = () => openFile(rawUrl).catch(() => {}) - - return ReactDOM.createPortal( -
- {isImage ? ( - /* Image lightbox — floating controls */ -
e.stopPropagation()}> - {authUrl - ? {file.original_name} - : - } -
- {file.original_name} -
- - -
-
-
- ) : ( - /* Document viewer — card with header */ -
e.stopPropagation()}> -
- {file.original_name} -
- - -
-
- {(isPdf || isTxt) ? ( - -

- -

-
- ) : ( -
- -
- )} -
- )} -
, - document.body - ) -} - -function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; alt?: string }) { - const [authSrc, setAuthSrc] = useState('') - useEffect(() => { - getAuthUrl(src, 'download').then(setAuthSrc) - }, [src]) - return authSrc ? {alt} : null -} - -const NOTE_COLORS = [ - { value: '#6366f1', label: 'Indigo' }, - { value: '#ef4444', label: 'Red' }, - { value: '#f59e0b', label: 'Amber' }, - { value: '#10b981', label: 'Emerald' }, - { value: '#3b82f6', label: 'Blue' }, - { value: '#8b5cf6', label: 'Violet' }, -] - -const formatTimestamp = (ts, t, locale) => { - if (!ts) return '' - const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z') - const now = new Date() - const diffMs = now - d - const diffMins = Math.floor(diffMs / 60000) - if (diffMins < 1) return t('collab.chat.justNow') || 'just now' - if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago` - const diffHrs = Math.floor(diffMins / 60) - if (diffHrs < 24) return t('collab.chat.hoursAgo', { n: diffHrs }) || `${diffHrs}h ago` - const diffDays = Math.floor(diffHrs / 24) - if (diffDays < 7) return t('collab.notes.daysAgo', { n: diffDays }) || `${diffDays}d ago` - return d.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric' }) -} - -// ── Avatar ────────────────────────────────────────────────────────────────── -interface UserAvatarProps { - user: NoteAuthor | null - size?: number -} - -function UserAvatar({ user, size = 14 }: UserAvatarProps) { - if (!user) return null - if (user.avatar) { - return ( - {user.username} - ) - } - const initials = (user.username || '?').slice(0, 1) - return ( -
- {initials} -
- ) -} - -// ── New Note Modal (portal to body) ───────────────────────────────────────── -interface NoteFormModalProps { - onClose: () => void - onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise - onDeleteFile?: (noteId: number, fileId: number) => Promise - existingCategories: string[] - categoryColors: Record - getCategoryColor: (category: string) => string - note: CollabNote | null - tripId: number - t: (key: string) => string -} - -function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) { - const can = useCanDo() - const tripObj = useTripStore((s) => s.trip) - const canUploadFiles = can('file_upload', tripObj) - const isEdit = !!note - const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean) - - const [title, setTitle] = useState(note?.title || '') - const [content, setContent] = useState(note?.content || '') - const [category, setCategory] = useState(note?.category || allCategories[0] || '') - const [website, setWebsite] = useState(note?.website || '') - const [pendingFiles, setPendingFiles] = useState([]) - const [existingAttachments, setExistingAttachments] = useState(note?.attachments || []) - const [submitting, setSubmitting] = useState(false) - const fileRef = useRef(null) - - const finalCategory = category - - const handleSubmit = async (e) => { - e.preventDefault() - if (!title.trim()) return - setSubmitting(true) - try { - await onSubmit({ - title: title.trim(), - content: content.trim(), - category: finalCategory || null, - color: getCategoryColor(finalCategory), - website: website.trim() || null, - _pendingFiles: pendingFiles, - }) - onClose() - } catch { - } finally { - setSubmitting(false) - } - } - - const handleDeleteAttachment = async (fileId) => { - if (onDeleteFile && note) { - await onDeleteFile(note.id, fileId) - setExistingAttachments(prev => prev.filter(a => a.id !== fileId)) - } - } - - const canSubmit = title.trim() && !submitting - - return ReactDOM.createPortal( -
-
e.stopPropagation()} - onPaste={e => { - if (!canUploadFiles) return - const items = e.clipboardData?.items - if (!items) return - for (const item of Array.from(items)) { - if (item.type.startsWith('image/') || item.type === 'application/pdf') { - e.preventDefault() - const file = item.getAsFile() - if (file) setPendingFiles(prev => [...prev, file]) - return - } - } - }} - onSubmit={handleSubmit} - > - {/* Modal header */} -
-

- {isEdit ? t('collab.notes.edit') : t('collab.notes.new')} -

- -
- - {/* Modal body */} -
- {/* Title */} -
-
- {t('collab.notes.title')} -
- setTitle(e.target.value)} - placeholder={t('collab.notes.titlePlaceholder')} - style={{ - width: '100%', - border: '1px solid var(--border-primary)', - borderRadius: 10, - padding: '8px 12px', - fontSize: 13, - background: 'var(--bg-input)', - color: 'var(--text-primary)', - fontFamily: 'inherit', - outline: 'none', - boxSizing: 'border-box', - }} - /> -
- - {/* Content */} -
-
- {t('collab.notes.contentPlaceholder')} -
-