diff --git a/.gitignore b/.gitignore index 8ee777b..55bedb1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ Desktop.ini # IDE / Editors .idea -.vscode/* +.vscode !.vscode/extensions.json !.vscode/settings.json *.swp diff --git a/AUDIT.md b/AUDIT.md deleted file mode 100644 index 316210d..0000000 --- a/AUDIT.md +++ /dev/null @@ -1,465 +0,0 @@ -# Comprehensive Codebase Audit Report (v5) - -**Project:** root-org โ Event Organizing Platform (SvelteKit 2 + Svelte 5 + Supabase + Tailwind v4) -**Date:** 2026-02-07 -**Auditor:** Cascade - ---- - -## Baseline Status - -| Metric | Result | -|--------|--------| -| `svelte-check` | **0 errors**, 9 warnings (a11y) | -| `vite build` | **Success** | -| `vitest` | **112 tests passed** across 9 files | -| TypeScript | `strict: true` enabled | -| Migrations | 31 SQL files (001โ031) | -| Components | 44 UI + 8 module widgets + 15 matrix + 11 message | -| API modules | 13 files in `$lib/api/` | -| Routes | 7 route groups + 5 API endpoints | - ---- - -## 1. Security - -### S-1 ยท ๐ด CRITICAL โ Real credentials committed to `.env` in git history - -**File:** `.env` - -The `.env` file contains real Supabase keys, Google API key, Google service account private key, and Matrix admin token. While `.env` is in `.gitignore`, **it was committed before the rule was added** and remains in git history. - -**Exposed secrets:** -- Supabase URL + anon key -- Google API key -- Google service account full JSON (including private key) -- Matrix admin token - -**Fix (manual โ cannot be automated):** -1. `git rm --cached .env` to untrack -2. Use `git filter-repo` to purge `.env` from all history -3. **Rotate ALL keys immediately** โ Supabase anon key, Google API key, Google service account, Matrix admin token -4. Force-push the cleaned history - -**Severity:** If repo is ever made public or shared, all backend services are fully compromised. - ---- - -### S-2 ยท ๐ด CRITICAL โ Google Calendar events endpoint lacks auth - -**File:** `src/routes/api/google-calendar/events/+server.ts` - -The `GET` handler reads `org_id` from query params and fetches calendar events. It does check auth (`safeGetSession`) and membership โ **this was fixed in a previous audit round**. However, verify the current state matches the fix. - -**Status:** Verify โ was marked fixed in v4 audit. - ---- - -### S-3 ยท ๐ก HIGH โ Auth callback open redirect - -**File:** `src/routes/auth/callback/+server.ts` - -The `next`/`redirect` query parameter is used in `redirect(303, next)` without validating it's a relative URL. Attacker can craft `?next=https://evil.com`. - -**Fix:** -```ts -function safeRedirect(target: string): string { - if (target.startsWith('/') && !target.startsWith('//')) return target; - return '/'; -} -``` - ---- - -### S-4 ยท ๐ก HIGH โ Client-side mutations bypass server authorization - -**Problem:** Settings page, event pages, and team management perform Supabase mutations directly from the browser. Security relies entirely on RLS policies being correct. The admin dashboard (newly added) correctly uses SvelteKit form actions โ this pattern should be adopted everywhere. - -**Affected areas:** -- `[orgSlug]/settings/` โ org update, delete, member management -- `[orgSlug]/events/[eventSlug]/` โ event update, delete -- `[orgSlug]/events/[eventSlug]/team/` โ member add/remove, department CRUD - -**Fix:** Migrate destructive operations to SvelteKit form actions with explicit server-side auth checks (like the admin dashboard pattern). - ---- - ---- - -### S-6 ยท ๐ก MEDIUM โ Document lock RLS race condition - -**File:** `supabase/migrations/016_document_locks.sql` - -Two competing cleanup paths for expired locks: RLS policy allows any user to delete expired locks, and `acquireLock()` in `document-locks.ts` also deletes them client-side. - -**Fix:** Consolidate to one path โ either RLS-only or a server-side cron. - ---- - -### S-7 ยท ๐ก MEDIUM โ `@inlang/paraglide-js` in both deps and devDeps - -**File:** `package.json` - -`@inlang/paraglide-js` appears in both `dependencies` and `devDependencies`. Should only be in `devDependencies` (it's a build-time tool). - ---- - -## 2. Type Safety - -### T-1 ยท ๐ก HIGH โ Supabase types are stale โ 21 `as any` casts remain - -**Files:** 12 files still use `as any`, concentrated in: -- `src/lib/matrix/sdk-types.ts` (6) โ Matrix SDK interop, acceptable -- `src/lib/api/budget.ts`, `contacts.ts`, `schedule.ts`, `sponsors.ts`, `department-dashboard.ts` (5 total) โ all use `db()` cast for tables added in migrations 026-031 -- `src/routes/admin/+page.server.ts` (1) โ admin uses `db()` for `is_platform_admin` -- `src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.server.ts` (3) -- `src/routes/[orgSlug]/events/[eventSlug]/team/+page.svelte` (1) -- `src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte` (1) -- `src/lib/utils/permissions.ts` (1) -- Test files (3) โ acceptable in tests - -**Root cause:** Types haven't been regenerated since migrations 022-031 were added. Tables like `events`, `event_members`, `event_departments`, `department_dashboards`, `budget_categories`, `sponsors`, `schedule_stages`, `department_contacts`, and column `is_platform_admin` on `profiles` are all missing from the generated types. - -**Fix:** Run `npm run db:types` to regenerate. This single command will eliminate ~15 of the 21 `as any` casts. The remaining 6 (Matrix SDK) are acceptable interop casts. - ---- - -### T-2 ยท ๐ก MEDIUM โ Manual type aliases drift from generated types - -**File:** `src/lib/supabase/types.ts:1700-1877` - -15 manually-defined interfaces at the bottom of the generated types file (`DepartmentDashboard`, `BudgetCategory`, `Sponsor`, etc.). These will become redundant after type regeneration and could drift from the actual DB schema. - -**Fix:** After regenerating types, derive these from the generated `Database` type: -```ts -export type BudgetCategory = Database['public']['Tables']['budget_categories']['Row']; -``` - ---- - -### T-3 ยท ๐ข GOOD โ TypeScript strict mode enabled - -`tsconfig.json` has `"strict": true`, `"checkJs": true`, `"forceConsistentCasingInFileNames": true`. This is best-practice configuration. - ---- - -## 3. Code Quality & Consistency - -### C-1 ยท ๐ก HIGH โ 41 raw `console.*` calls bypass structured logger - -**13 files** use `console.log/warn/error` directly instead of the project's `createLogger()` system. Concentrated in: -- `src/lib/matrix/client.ts` (10) -- `src/lib/components/matrix/MessageInput.svelte` (5) -- `src/routes/[orgSlug]/chat/+page.svelte` (5) -- `src/routes/api/matrix-provision/+server.ts` (5) -- Various matrix components (6) -- `src/lib/stores/theme.ts` (1) - -**Impact:** Loses structured context, timestamps, and the ring buffer for error reports. The logger is already imported in most non-matrix files. - -**Fix:** Replace all `console.*` with `createLogger()` calls. The Matrix integration was ported from another project and didn't adopt the logger. - ---- - -### C-2 ยท ๐ก MEDIUM โ Inconsistent error catching: `e: any` vs `e` vs `e: unknown` - -The codebase uses three different patterns: -- `catch (e: any)` โ 12 occurrences (team page, events pages, dept page) -- `catch (e)` โ 8 occurrences (tasks page, documents page, chat page) -- `catch (e: unknown)` โ 1 occurrence (chat page) - -**Best practice:** Use `catch (e: unknown)` and narrow with `e instanceof Error`. The `e: any` pattern loses type safety. - ---- - -### C-3 ยท ๐ก MEDIUM โ `$lib/stores/ui.ts` uses legacy Svelte stores alongside Svelte 5 runes - -**File:** `src/lib/stores/ui.ts` - -Uses `writable()` from `svelte/store` for `sidebarOpen`, `membersPanelOpen`, `activeModal`, `modalData`, `isLoading`, `loadingMessage`. The rest of the app uses Svelte 5 `$state()` runes. - -**Fix:** Migrate to runes-based stores using `.svelte.ts` files (like `toast.svelte.ts` already does). - ---- - -### C-4 ยท ๐ก MEDIUM โ 9 a11y warnings from svelte-check - -All in 3 files: -- `SponsorsWidget.svelte` โ color picker buttons missing labels (4 warnings) -- `admin/+page.svelte` โ form labels not associated with controls (3 warnings) -- `SponsorsWidget.svelte` โ buttons without text (2 warnings) - -**Fix:** Add `aria-label` attributes to color picker buttons; use `id`/`for` pairs on admin form labels. - ---- - -### C-5 ยท ๐ข GOOD โ Consistent API module pattern - -All 13 API modules in `$lib/api/` follow the same pattern: typed function signatures, Supabase client as first param, structured error logging via `createLogger()`, and thrown errors for callers to handle. This is clean and maintainable. - ---- - -### C-6 ยท ๐ข GOOD โ Centralized error handling - -Both server (`hooks.server.ts`) and client (`hooks.client.ts`) have `handleError` functions that generate error IDs, log structured data, and return consistent error shapes. The `+error.svelte` page shows error ID + "Copy Error Report" with recent logs dump. This is production-quality. - ---- - -## 4. Architecture - -### A-1 ยท ๐ก HIGH โ Client-side mutations should use form actions - -The admin dashboard correctly uses SvelteKit form actions for all CRUD. But other pages (settings, events, team management) perform mutations via direct `supabase.from()` calls from the browser. - -**Recommendation:** Adopt the admin dashboard pattern (form actions + `use:enhance`) for all destructive operations. This provides: -- Server-side auth verification -- Progressive enhancement (works without JS) -- Automatic `invalidateAll()` on success - ---- - -### A-2 ยท ๐ก MEDIUM โ Large page components - -Several pages are 500+ lines: -- `[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte` โ ~1100 lines (department dashboard) -- `[orgSlug]/events/[eventSlug]/team/+page.svelte` โ ~500 lines -- `[orgSlug]/chat/+page.svelte` โ ~400 lines -- `admin/+page.svelte` โ ~660 lines - -The department dashboard is the largest but is inherently complex (8 module types). The admin page is well-structured with tab separation. - -**Recommendation:** Extract reusable table/list components from admin page for potential reuse in other admin-like views. - ---- - -### A-3 ยท ๐ข GOOD โ Clean module architecture - -The project follows a clear separation: -- `$lib/api/` โ Data access layer (13 modules) -- `$lib/components/ui/` โ Design system (44 components) -- `$lib/components/modules/` โ Feature widgets (8 modules) -- `$lib/stores/` โ Global state (4 stores) -- `$lib/utils/` โ Utilities (7 files) -- `$lib/matrix/` โ Matrix chat integration (isolated) - ---- - -### A-4 ยท ๐ข GOOD โ Server-side auth pattern - -`hooks.server.ts` correctly uses `getUser()` (server-verified) before `getSession()` (client-provided). The `safeGetSession` pattern prevents session spoofing. All page server loads check auth before data fetching. - ---- - -## 5. Performance - -### P-1 ยท ๐ก MEDIUM โ Google Fonts loaded via external CSS import - -**File:** `src/routes/layout.css:1-2` - -```css -@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&...'); -@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:...'); -``` - -Two render-blocking external CSS imports. Material Symbols font is ~200KB. - -**Fix options:** -1. Self-host fonts (fastest โ eliminates external dependency) -2. Add `` to `app.html` -3. Use `font-display: swap` (already set via `&display=swap`) - ---- - -### P-2 ยท ๐ก MEDIUM โ `emojiData.ts` is 40KB - -**File:** `src/lib/utils/emojiData.ts` (40,133 bytes) - -This is a static JSON blob imported at module level. It's included in the client bundle even if the user never opens the emoji picker. - -**Fix:** Lazy-load via dynamic import: -```ts -const emojiData = await import('$lib/utils/emojiData'); -``` - ---- - -### P-3 ยท ๐ข GOOD โ Optimistic updates on kanban - -Card moves use synchronous state updates + fire-and-forget DB persist. Realtime handlers use incremental diffs instead of full reloads. `optimisticMoveIds` Set suppresses realtime echoes. This is well-implemented. - ---- - -### P-4 ยท ๐ข GOOD โ Vite config optimized for Windows - -File watcher ignores `node_modules`, `test-results`, `.svelte-kit`, and paraglide cache. Matrix crypto WASM excluded from SSR bundling. This prevents common Windows dev server performance issues. - ---- - -## 6. Testing - -### TS-1 ยท ๐ก MEDIUM โ No Svelte component tests - -112 unit tests exist but all test pure TypeScript functions (API modules, utils, markdown parsing). No Svelte component rendering tests despite `vitest-browser-svelte` being installed and configured. - -**Priority components to test:** -- `Button`, `Modal`, `Input`, `TabBar` โ core UI -- `Avatar` โ name-to-color hashing logic -- `StatusBadge` โ status-to-color mapping - ---- - -### TS-2 ยท ๐ก MEDIUM โ E2E tests don't cover new features - -Playwright tests cover: auth, org CRUD, documents, kanban, calendar. But missing: -- Events CRUD flow -- Department dashboard -- Admin dashboard -- Chat integration -- Team management - ---- - -### TS-3 ยท ๐ข GOOD โ Test infrastructure is solid - -- Vitest with browser + server projects -- `requireAssertions: true` prevents empty tests -- Playwright with auth setup, cleanup, and CI config -- 112 passing tests with 0 failures - ---- - -## 7. Dependencies - -### DEP-1 ยท ๐ก MEDIUM โ `@inlang/paraglide-js` duplicated in deps - -Listed in both `dependencies` and `devDependencies`. Should only be in `devDependencies`. - ---- - -### DEP-2 ยท ๐ก LOW โ `@types/twemoji` may be unnecessary - -`twemoji` v14 may ship its own types. Check if removing `@types/twemoji` causes any errors. - ---- - -### DEP-3 ยท ๐ข GOOD โ All dependencies actively used - -Every package in `dependencies` has corresponding imports: -- `@supabase/ssr` + `@supabase/supabase-js` โ core data layer -- `@tanstack/svelte-virtual` โ virtualized lists in chat -- `@tiptap/*` โ rich text editor -- `google-auth-library` โ calendar integration -- `highlight.js` + `marked` โ markdown rendering -- `matrix-js-sdk` โ chat -- `twemoji` โ emoji rendering - ---- - -## 8. DevOps & Infrastructure - -### DO-1 ยท ๐ข GOOD โ Docker setup - -Multi-stage Dockerfile with builder + production stages. Health check endpoint at `/health`. `docker-compose.yml` with both production and dev services. Environment variables properly externalized. - ---- - -### DO-2 ยท ๐ข GOOD โ Database migrations - -31 sequential migrations with clear naming. RLS policies on all tables. Platform admin bypass policies (migration 031). Auto-create triggers for department dashboards. - ---- - -### DO-3 ยท ๐ก MEDIUM โ No database seed file - -No `supabase/seed.sql` for development setup. New developers must manually create test data. - -**Fix:** Create a seed file with sample org, users, events, and departments. - ---- - -## 9. Accessibility - -### A11Y-1 ยท ๐ก MEDIUM โ 9 svelte-check a11y warnings - -- Color picker buttons in `SponsorsWidget.svelte` lack labels -- Admin page form labels not associated with controls -- All are fixable with `aria-label` or `id`/`for` attributes - ---- - -### A11Y-2 ยท ๐ข GOOD โ Modal accessibility - -`Modal.svelte` has `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, keyboard escape handling, and backdrop click. This follows WAI-ARIA dialog pattern. - ---- - -### A11Y-3 ยท ๐ข GOOD โ Error page - -`+error.svelte` shows status code, message, error ID, context, and a "Copy Error Report" button with recent logs. Excellent for debugging. - ---- - ---- - -## Summary Scorecard (v5) - -| Area | Score | Key Notes | -|------|-------|-----------| -| **Security** | 3/5 | S-1 (credential rotation) still critical. S-4 (server-side auth) partially addressed by admin form actions. RLS bypass for platform admins added. | -| **Type Safety** | 4/5 | `strict: true`. 21 `as any` casts remain, 15 fixable by `npm run db:types`. Matrix SDK casts are acceptable. | -| **Code Quality** | 4/5 | Consistent API pattern. 41 raw `console.*` calls in Matrix code. Inconsistent error catch typing. | -| **Architecture** | 4/5 | Clean module separation. Admin dashboard uses form actions (good pattern). Other pages still use client-side mutations. | -| **Performance** | 4/5 | Optimistic updates, incremental realtime, Windows-optimized watcher. External font loading and 40KB emoji data could improve. | -| **Testing** | 3.5/5 | 112 unit tests, Playwright E2E, CI pipeline. Missing: component tests, new feature E2E coverage. | -| **Dependencies** | 4.5/5 | All deps used. One duplicate (`paraglide-js`). | -| **DevOps** | 4/5 | Docker, health checks, 31 migrations. Missing: seed file. | -| **Accessibility** | 4/5 | Good modal/error patterns. 9 minor a11y warnings. | -| **Error Handling** | 4/5 | Structured logger, error IDs, ring buffer, toast system. Matrix code bypasses logger. | - -### Overall: 4.0 / 5.0 - ---- - -## Priority Action Items - -### Tier 1 โ Critical (do now) - -1. **S-1: Purge `.env` from git history and rotate all secrets** - - `git rm --cached .env && git commit` - - Use `git filter-repo` to purge from history - - Rotate: Supabase keys, Google API key, Google service account, Matrix admin token - -### Tier 2 โ High (this week) - -2. **T-1: Regenerate Supabase types** โ `npm run db:types` eliminates 15 `as any` casts -3. **C-1: Replace `console.*` with `createLogger()`** in Matrix integration files -4. **S-3: Fix auth callback open redirect** โ add `safeRedirect()` validator -5. **A11Y-1: Fix 9 a11y warnings** โ add `aria-label` and `id`/`for` attributes - -### Tier 3 โ Medium (this month) - -6. **S-4/A-1: Migrate mutations to form actions** โ start with settings page -7. **C-3: Migrate `$lib/stores/ui.ts` to runes** โ align with Svelte 5 patterns -8. **P-1: Self-host fonts** or add preconnect hints -9. **P-2: Lazy-load emoji data** -10. **TS-1: Add Svelte component tests** for core UI components -11. **DO-3: Create database seed file** -12. **DEP-1: Remove duplicate `paraglide-js`** from `dependencies` - -### Tier 4 โ Low (backlog) - -13. **C-2: Standardize error catching** to `catch (e: unknown)` -14. **TS-2: Expand E2E tests** to cover events, departments, admin, chat -15. **T-2: Derive manual type aliases** from generated types after regeneration -16. **S-6: Consolidate lock cleanup** to single path -17. **DEP-2: Check if `@types/twemoji` is needed** - -### Feature Backlog - -18. Permission enforcement (`hasPermission()` utility) -19. Global search across all entities -20. Keyboard shortcuts -21. Notifications system -22. Mobile responsive layout -23. Undo/redo with toast-based undo -24. Onboarding flow for new users diff --git a/docs/TECHNICAL_DESIGN.md b/docs/TECHNICAL_DESIGN.md new file mode 100644 index 0000000..9e78f29 --- /dev/null +++ b/docs/TECHNICAL_DESIGN.md @@ -0,0 +1,997 @@ +# Root โ Technical Design Document + +> Comprehensive documentation of every feature, how it works, what problems it solves, and where there is room for improvement. + +--- + +## Table of Contents + +1. [Platform Overview](#1-platform-overview) +2. [Architecture](#2-architecture) +3. [Authentication & Authorization](#3-authentication--authorization) +4. [Organizations](#4-organizations) +5. [Organization Settings](#5-organization-settings) +6. [Documents & File Browser](#6-documents--file-browser) +7. [Kanban Boards](#7-kanban-boards) +8. [Calendar](#8-calendar) +9. [Chat (Matrix)](#9-chat-matrix) +10. [Events](#10-events) +11. [Event Team & Departments](#11-event-team--departments) +12. [Department Dashboard](#12-department-dashboard) +13. [Checklists Module](#13-checklists-module) +14. [Notes Module](#14-notes-module) +15. [Schedule Module](#15-schedule-module) +16. [Contacts Module](#16-contacts-module) +17. [Budget & Finances Module](#17-budget--finances-module) +18. [Sponsors Module](#18-sponsors-module) +19. [Activity Tracking](#19-activity-tracking) +20. [Tags System](#20-tags-system) +21. [Roles & Permissions](#21-roles--permissions) +22. [Google Calendar Integration](#22-google-calendar-integration) +23. [Internationalization (i18n)](#23-internationalization-i18n) +24. [Platform Admin](#24-platform-admin) +25. [Testing Strategy](#25-testing-strategy) +26. [Database Migrations](#26-database-migrations) + +--- + +## 1. Platform Overview + +### What It Is +Root is an **event organizing platform** built for teams and organizations that plan events. It provides a unified workspace where teams can manage documents, tasks, budgets, sponsors, schedules, contacts, and communications โ all scoped to specific events and departments. + +### Problems It Solves +- **Fragmented tooling**: Event teams typically juggle Google Sheets, Slack, Trello, email, and spreadsheets. Root consolidates these into one platform. +- **No event-scoped context**: Generic project tools don't understand the concept of "events with departments." Root's data model is purpose-built for this. +- **Budget opacity**: Planned vs. actual spending is hard to track across departments. Root provides real-time financial rollups. +- **Sponsor management**: No lightweight CRM exists for event sponsorships. Root provides tier-based sponsor tracking with deliverables. + +### Tech Stack +| Layer | Technology | +|-------|-----------| +| Frontend | SvelteKit (Svelte 5), TailwindCSS | +| Backend | Supabase (PostgreSQL, Auth, Storage, Realtime) | +| Chat | Matrix protocol (Synapse server) | +| Calendar | Google Calendar API (OAuth2) | +| i18n | Paraglide v2 (en, et) | +| Icons | Material Symbols (variable font) | +| Testing | Vitest (unit), Playwright (E2E) | +| Deployment | Vercel / Netlify | + +--- + +## 2. Architecture + +### Routing Structure +``` +/ โ Landing / login +/[orgSlug]/ โ Organization overview +/[orgSlug]/documents/ โ File browser (root) +/[orgSlug]/documents/folder/[id] โ Folder view +/[orgSlug]/documents/file/[id] โ Document / kanban viewer +/[orgSlug]/calendar/ โ Calendar page +/[orgSlug]/chat/ โ Matrix chat +/[orgSlug]/events/ โ Event list +/[orgSlug]/events/[eventSlug]/ โ Event detail +/[orgSlug]/events/[eventSlug]/team/ โ Team management +/[orgSlug]/events/[eventSlug]/finances/ โ Financial overview +/[orgSlug]/events/[eventSlug]/sponsors/ โ Sponsor CRM +/[orgSlug]/events/[eventSlug]/contacts/ โ Contact directory +/[orgSlug]/events/[eventSlug]/schedule/ โ Schedule builder +/[orgSlug]/events/[eventSlug]/dept/[deptId]/ โ Department dashboard +/[orgSlug]/settings/ โ Organization settings +/[orgSlug]/account/ โ User account settings +/admin/ โ Platform admin panel +``` + +### Data Flow +1. **Layout server** (`[orgSlug]/+layout.server.ts`) loads org data, membership, permissions, members, activity, stats, and upcoming events via `select('*')` on the `organizations` table. +2. **Child pages** call `parent()` to inherit org context, then load page-specific data. +3. **Supabase client** is set via `setContext('supabase')` in the root layout and retrieved via `getContext` in child components. +4. **Realtime** subscriptions use Supabase channels for live updates (kanban cards, chat presence). + +### Key Files +| File | Purpose | +|------|---------| +| `src/routes/[orgSlug]/+layout.server.ts` | Loads org, membership, permissions, members, stats | +| `src/routes/[orgSlug]/+layout.svelte` | Sidebar navigation, user menu, org header | +| `src/lib/supabase/types.ts` | Auto-generated + manual TypeScript types | +| `src/lib/utils/currency.ts` | Shared currency formatting, timezone, date format constants | +| `src/lib/utils/permissions.ts` | Permission checking utility | +| `src/lib/stores/toast.svelte` | Toast notification store | +| `src/lib/paraglide/messages.ts` | Generated i18n message functions | + +--- + +## 3. Authentication & Authorization + +### How It Works +- **Supabase Auth** handles email/password login with session management. +- Sessions are persisted via cookies; `locals.safeGetSession()` validates on every server load. +- **Playwright tests** use a setup project that logs in once and saves `storageState` for reuse. + +### Authorization Layers +1. **Org membership**: Users must be members of an org to access it (checked in layout server). +2. **Role-based**: Each member has a role (`owner`, `admin`, `editor`, `viewer`). +3. **Permission-based**: Custom roles with granular permissions (e.g., `documents.view`, `calendar.view`, `settings.view`). +4. **Row-Level Security (RLS)**: All Supabase tables have RLS policies scoped to org membership. + +### Room for Improvement +- **Invite flow**: Currently email-based; could add link-based invites. +- **SSO**: No SAML/OIDC support yet. +- **2FA**: Not implemented. + +--- + +## 4. Organizations + +### What It Is +The top-level entity. Every user belongs to one or more organizations. All data (events, documents, members) is scoped to an org. + +### Database Schema +```sql +CREATE TABLE organizations ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + avatar_url TEXT, + theme_color TEXT DEFAULT '#00a3e0', + icon_url TEXT, + description TEXT DEFAULT '', + currency TEXT NOT NULL DEFAULT 'EUR', + date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY', + timezone TEXT NOT NULL DEFAULT 'Europe/Tallinn', + week_start_day TEXT NOT NULL DEFAULT 'monday', + default_calendar_view TEXT NOT NULL DEFAULT 'month', + default_event_color TEXT NOT NULL DEFAULT '#7986cb', + default_event_status TEXT NOT NULL DEFAULT 'planning', + default_dept_modules TEXT[] NOT NULL DEFAULT ARRAY['kanban','files','checklist'], + default_dept_layout TEXT NOT NULL DEFAULT 'split', + feature_chat BOOLEAN NOT NULL DEFAULT true, + feature_sponsors BOOLEAN NOT NULL DEFAULT true, + feature_contacts BOOLEAN NOT NULL DEFAULT true, + feature_budget BOOLEAN NOT NULL DEFAULT true, + matrix_space_id TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); +``` + +### API (`src/lib/api/organizations.ts`) +- `createOrganization(supabase, name, slug, userId)` โ creates org + adds creator as owner +- `updateOrganization(supabase, id, updates)` โ updates name, slug, avatar +- `deleteOrganization(supabase, id)` โ deletes org and cascades +- `fetchOrgMembers(supabase, orgId)` โ lists members with profiles +- `inviteMember(supabase, orgId, email, role)` โ sends invite +- `updateMemberRole(supabase, memberId, role)` โ changes role +- `removeMember(supabase, memberId)` โ removes member + +### Overview Page (`/[orgSlug]/`) +Displays: +- **Stat cards**: Events, members, documents, kanban boards +- **Upcoming events**: Next 5 events with status badges +- **Recent activity**: Last 10 activity log entries +- **Member list**: First 6 members with avatars + +--- + +## 5. Organization Settings + +### What It Is +Configurable preferences that affect the entire organization. Accessible at `/[orgSlug]/settings` under the "General" tab. + +### Sections + +#### Organization Details +- **Name** and **URL slug** โ core identity +- **Avatar** โ upload/remove via Supabase Storage +- **Description** โ free-text org description +- **Theme color** โ color picker, stored as hex + +#### Preferences +- **Currency** โ ISO 4217 code (EUR, USD, GBP, etc.). Affects all financial displays via `formatCurrency()` utility. Uses locale-aware formatting (EUR โ `de-DE` locale puts โฌ after number). +- **Date format** โ DD/MM/YYYY, MM/DD/YYYY, or YYYY-MM-DD +- **Timezone** โ grouped by region (Europe, Americas, Asia/Pacific) +- **Week start day** โ Monday or Sunday +- **Default calendar view** โ Month, Week, or Day + +#### Event Defaults +- **Default event color** โ preset palette + custom color picker +- **Default event status** โ Planning or Active +- **Default department layout** โ Single, Split, Grid, or Focus+Sidebar +- **Default department modules** โ toggle grid for: Kanban, Files, Checklist, Notes, Schedule, Contacts, Budget, Sponsors + +#### Feature Toggles +- **Chat** โ show/hide Matrix chat in sidebar +- **Budget & Finances** โ enable/disable budget module +- **Sponsors** โ enable/disable sponsor CRM +- **Contacts** โ enable/disable contact directory + +#### Danger Zone +- Delete organization (owner only) +- Leave organization (non-owners) + +### Implementation +- **Settings UI**: `src/lib/components/settings/SettingsGeneral.svelte` +- **Currency utility**: `src/lib/utils/currency.ts` โ shared `formatCurrency(amount, currency)` function with locale map +- **Feature toggles**: Wired into sidebar nav in `[orgSlug]/+layout.svelte` โ conditionally shows/hides nav items +- **Calendar default**: Passed as `initialView` prop to `Calendar.svelte` +- **Event color default**: Used in event creation form (`events/+page.svelte`) + +### Room for Improvement +- **Date format** is stored but not yet consumed by all date displays (calendar, activity feed, event cards). Need a shared `formatDate()` utility. +- **Timezone** is stored but not yet used for server-side date rendering. +- **Week start day** is stored but Calendar grid is still hardcoded to Monday. +- **Feature toggles** hide sidebar nav but don't yet block API access or hide modules within department dashboards. +- **Default dept modules/layout** are stored but not yet consumed by the department auto-create trigger. + +--- + +## 6. Documents & File Browser + +### What It Is +A hierarchical file system with folders, rich-text documents, and kanban boards. Supports org-wide and event-scoped views. + +### Problems It Solves +- Centralized document storage per organization +- Auto-created folder structure for events and departments +- In-browser document editing without external tools + +### Database +```sql +CREATE TABLE documents ( + id UUID PRIMARY KEY, + org_id UUID REFERENCES organizations(id), + parent_id UUID REFERENCES documents(id), -- folder hierarchy + type TEXT NOT NULL, -- 'folder' | 'document' | 'kanban' + title TEXT NOT NULL, + content JSONB, -- TipTap JSON for documents + position INTEGER, + event_id UUID REFERENCES events(id), -- event scoping + department_id UUID REFERENCES event_departments(id), -- dept scoping + created_by UUID, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ +); +``` + +### Routing +- `/documents` โ root file browser +- `/documents/folder/[id]` โ folder contents +- `/documents/file/[id]` โ document editor or kanban board + +### Auto-Folder System +When events/departments are created, folders are auto-created: +``` +๐ Events/ + ๐ [Event Name]/ (event_id FK) + ๐ [Department Name]/ (department_id FK) +``` + +### Key Features +- **Drag-and-drop** file/folder reordering +- **Breadcrumb** navigation +- **Document locking** โ prevents concurrent edits +- **Single-click** folder โ navigate; document โ inline editor; kanban โ full page +- **Double-click** โ open in new tab + +### API (`src/lib/api/documents.ts`) +- `fetchDocuments`, `createDocument`, `moveDocument`, `deleteDocument` +- `ensureEventsFolder`, `createEventFolder`, `createDepartmentFolder` +- `findDepartmentFolder`, `fetchFolderContents` + +### Room for Improvement +- **Real-time collaboration**: Currently single-user editing with locks. Could add CRDT-based collaborative editing. +- **Search**: No full-text search across documents. +- **Version history**: No document versioning. +- **File uploads**: Only structured documents; no raw file upload (PDF, images). + +--- + +## 7. Kanban Boards + +### What It Is +Drag-and-drop task boards with columns, cards, labels, checklists, and comments. + +### Problems It Solves +- Visual task management within the document system +- Scoped task tracking per department + +### Database +```sql +CREATE TABLE kanban_columns (id, document_id, title, position, color); +CREATE TABLE kanban_cards (id, column_id, title, description, position, due_date, assigned_to); +CREATE TABLE kanban_labels (id, document_id, name, color); +CREATE TABLE card_checklists (id, card_id, title); +CREATE TABLE card_checklist_items (id, checklist_id, text, completed, position); +CREATE TABLE task_comments (id, card_id, user_id, content, created_at); +``` + +### Key Features +- **Optimistic drag-and-drop**: Cards move instantly; DB persists fire-and-forget +- **Realtime**: Incremental handlers for INSERT/UPDATE/DELETE via Supabase channels +- **Labels**: Color-coded tags per board +- **Card detail**: Description, due date, assignee, checklists, comments +- **Drop indicators**: CSS box-shadow based (no layout shift) + +### API (`src/lib/api/kanban.ts`) +- `fetchBoard`, `createColumn`, `moveColumn`, `createCard`, `moveCard`, `updateCard` + +### Room for Improvement +- **Swimlanes**: No horizontal grouping by assignee or label. +- **Filters**: No filtering by label, assignee, or due date on the board view. +- **Card templates**: No reusable card templates. + +--- + +## 8. Calendar + +### What It Is +A month/week/day calendar view showing org events and optionally synced Google Calendar events. + +### Problems It Solves +- Unified view of all organization events +- Google Calendar sync for external event visibility + +### Implementation +- **Component**: `src/lib/components/calendar/Calendar.svelte` +- **Views**: Month (grid), Week (7-column), Day (single column with time slots) +- **Props**: `events`, `onDateClick`, `onEventClick`, `initialView` +- **Default view**: Configurable per org via `default_calendar_view` setting + +### API (`src/lib/api/calendar.ts`) +- `getMonthDays(year, month)` โ generates date grid for month view +- `isSameDay(date1, date2)` โ date comparison utility + +### Room for Improvement +- **Week start day**: Setting exists but calendar grid is hardcoded to Monday start. +- **Timezone**: Events display in browser timezone; should respect org timezone setting. +- **Recurring events**: Not supported. +- **Drag to create**: Can't drag across time slots to create events. + +--- + +## 9. Chat (Matrix) + +### What It Is +Real-time messaging powered by the Matrix protocol, using a self-hosted Synapse server. + +### Problems It Solves +- Team communication without leaving the platform +- Org-scoped chat rooms (no cross-org leakage) + +### Implementation +- Each org gets a Matrix space (`matrix_space_id` on organizations table) +- Users get Matrix credentials stored in `matrix_credentials` table +- Chat UI at `/[orgSlug]/chat/` +- Unread count badge in sidebar nav +- **Feature toggle**: Can be disabled per org via `feature_chat` setting + +### Database +```sql +CREATE TABLE matrix_credentials (user_id, matrix_user_id, access_token, device_id); +ALTER TABLE organizations ADD COLUMN matrix_space_id TEXT; +``` + +### Room for Improvement +- **Event-scoped channels**: Currently org-wide only; could auto-create channels per event/department. +- **File sharing**: No in-chat file upload. +- **Threads**: Matrix supports threads but UI doesn't expose them. +- **Push notifications**: Not implemented. + +--- + +## 10. Events + +### What It Is +The core entity of the platform. Events are projects that contain departments, budgets, schedules, sponsors, and more. + +### Problems It Solves +- Structured project management for event planning +- Status tracking (planning โ active โ completed โ archived) +- Cross-department coordination + +### Database +```sql +CREATE TABLE events ( + id UUID PRIMARY KEY, + org_id UUID REFERENCES organizations(id), + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'planning', -- planning, active, completed, archived + start_date TIMESTAMPTZ, + end_date TIMESTAMPTZ, + venue_name TEXT, + venue_address TEXT, + color TEXT, + created_by UUID, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ +); +``` + +### Key Features +- **Status pipeline**: Planning โ Active โ Completed โ Archived with tab filtering +- **Color coding**: Each event has a color (defaults to org's `default_event_color`) +- **Auto-folder creation**: Creating an event auto-creates `Events/[EventName]/` folder +- **Event detail page**: Overview with status, dates, venue, team, and navigation to sub-pages + +### API (`src/lib/api/events.ts`) +- `fetchEvents`, `fetchEventBySlug`, `createEvent`, `updateEvent`, `deleteEvent` +- `fetchEventDepartments`, `createDepartment`, `updateDepartment`, `deleteDepartment` +- `updateDepartmentPlannedBudget` + +### Room for Improvement +- **Event templates**: No way to clone an event structure. +- **Guest list / registration**: Not yet implemented (on roadmap). +- **Public event page**: No public-facing event page for attendees. + +--- + +## 11. Event Team & Departments + +### What It Is +Team management within events. Each event has departments (e.g., "Marketing", "Logistics") with assigned members. + +### Problems It Solves +- Clear ownership of event responsibilities +- Department-scoped modules (each dept gets its own dashboard) + +### Database +```sql +CREATE TABLE event_departments ( + id UUID PRIMARY KEY, + event_id UUID REFERENCES events(id), + name TEXT NOT NULL, + description TEXT, + color TEXT, + head_user_id UUID, + planned_budget NUMERIC DEFAULT 0, + created_at TIMESTAMPTZ +); + +CREATE TABLE event_members ( + id UUID PRIMARY KEY, + event_id UUID REFERENCES events(id), + user_id UUID, + department_id UUID REFERENCES event_departments(id), + role TEXT DEFAULT 'member' +); +``` + +### Key Features +- **Department creation**: Auto-creates folder + dashboard +- **Department head**: Assignable lead per department +- **Planned budget**: Per-department budget allocation +- **Member assignment**: Assign org members to specific departments + +### Room for Improvement +- **Shift scheduling**: No volunteer shift management. +- **Department templates**: Can't save/reuse department structures. + +--- + +## 12. Department Dashboard + +### What It Is +A composable, layout-configurable dashboard per department with drag-and-drop module panels. + +### Problems It Solves +- Each department needs different tools (marketing needs contacts, logistics needs schedules) +- Customizable workspace per team + +### Database +```sql +CREATE TABLE department_dashboards ( + id UUID PRIMARY KEY, + department_id UUID REFERENCES event_departments(id), + layout TEXT DEFAULT 'split' -- single, split, grid, focus_sidebar +); + +CREATE TABLE dashboard_panels ( + id UUID PRIMARY KEY, + dashboard_id UUID REFERENCES department_dashboards(id), + module TEXT NOT NULL, -- kanban, files, checklist, notes, schedule, contacts, budget, sponsors + position INTEGER, + config JSONB +); +``` + +### Layout Presets +- **Single**: One full-width panel +- **Split**: Two equal columns +- **Grid**: 2ร2 grid +- **Focus + Sidebar**: Large left panel + narrow right panel + +### Auto-Creation +A PostgreSQL trigger (`on_department_created_setup_dashboard`) auto-creates a dashboard with default panels when a department is inserted. + +### Key Features +- **Add/remove modules**: Click to add any available module +- **Expand to fullscreen**: Each panel can expand to full-screen mode +- **Layout switching**: Change layout preset from dashboard header + +### Room for Improvement +- **Drag-and-drop panel reordering**: Currently position is fixed by creation order. +- **Custom panel sizing**: All panels are equal size within a layout. +- **Default modules from org settings**: The `default_dept_modules` org setting is stored but not yet consumed by the auto-create trigger. + +--- + +## 13. Checklists Module + +### What It Is +Task checklists within department dashboards. Multiple checklists per department, each with ordered items. + +### Problems It Solves +- Simple task tracking without full kanban overhead +- Pre-event preparation checklists + +### Database +```sql +CREATE TABLE department_checklists (id, department_id, title, position); +CREATE TABLE department_checklist_items (id, checklist_id, text, completed, position); +``` + +### Key Features +- Multiple checklists per department +- Add/rename/delete checklists +- Toggle item completion with progress bar +- Drag-to-reorder items + +### Component: `src/lib/components/modules/ChecklistWidget.svelte` + +--- + +## 14. Notes Module + +### What It Is +Rich-text notes per department with auto-save. + +### Problems It Solves +- Meeting notes, decision logs, and documentation per department +- No need for external note-taking tools + +### Database +```sql +CREATE TABLE department_notes (id, department_id, title, content, position, created_at, updated_at); +``` + +### Key Features +- Note list sidebar + textarea editor +- **Auto-save** with 500ms debounce +- Create/rename/delete notes + +### Component: `src/lib/components/modules/NotesWidget.svelte` + +### Room for Improvement +- **Rich text editor**: Currently plain textarea; could use TipTap like documents. +- **Collaborative editing**: No real-time collaboration. + +--- + +## 15. Schedule Module + +### What It Is +A timeline/program builder for event schedules with stages (rooms/tracks) and time blocks. + +### Problems It Solves +- Event program planning with multiple parallel tracks +- Visual timeline view of the event day + +### Database +```sql +CREATE TABLE schedule_stages (id, department_id, name, color, position); +CREATE TABLE schedule_blocks ( + id, department_id, stage_id, title, description, + start_time TIMESTAMPTZ, end_time TIMESTAMPTZ, + speaker TEXT, color TEXT +); +``` + +### Key Features +- **Stages bar**: Color-coded tracks (e.g., "Main Stage", "Workshop Room") +- **Time blocks**: Scheduled items with start/end times, speaker, description +- **Date grouping**: Blocks grouped by date +- **Color picker**: Per-block color customization + +### Component: `src/lib/components/modules/ScheduleWidget.svelte` + +### Room for Improvement +- **Drag-and-drop timeline**: Currently list-based; could use a visual timeline grid. +- **Conflict detection**: No warning when blocks overlap on the same stage. +- **Public schedule**: No attendee-facing schedule view. + +--- + +## 16. Contacts Module + +### What It Is +A searchable vendor/contact directory per department. + +### Problems It Solves +- Centralized vendor management for event logistics +- Quick access to contact info during event planning + +### Database +```sql +CREATE TABLE department_contacts ( + id, department_id, name, email, phone, company, + role TEXT, category TEXT, notes TEXT, website TEXT +); +``` + +### Categories +Predefined categories: Venue, Catering, AV/Tech, Security, Transport, Decoration, Photography, Entertainment, Printing, Accommodation, Sponsor, Speaker, Other. + +### Key Features +- Search by name/company/email +- Filter by category +- Expandable detail rows +- CRUD modal with all contact fields + +### Component: `src/lib/components/modules/ContactsWidget.svelte` + +### Room for Improvement +- **Org-level contacts**: Currently department-scoped; could have a shared org contact book. +- **Import/export**: No CSV import/export. +- **Deduplication**: No duplicate detection. + +--- + +## 17. Budget & Finances Module + +### What It Is +Income/expense tracking with categories, planned vs. actual amounts, and cross-department financial overview. + +### Problems It Solves +- Budget planning and tracking across departments +- Planned vs. actual variance analysis +- Receipt management + +### Database +```sql +CREATE TABLE budget_categories (id, event_id, name, color); +CREATE TABLE budget_items ( + id, event_id, department_id, category_id, + description TEXT, item_type TEXT, -- 'income' | 'expense' + planned_amount NUMERIC, actual_amount NUMERIC, + notes TEXT, receipt_document_id UUID +); +``` + +### Key Features +- **Overview/Income/Expense tabs**: Filtered views +- **Category management**: Color-coded budget categories +- **Planned vs. Actual**: Side-by-side comparison with diff column +- **Receipt linking**: Attach receipt documents to budget items +- **Department rollup**: Finances page shows cross-department totals +- **Sponsor allocation**: Link sponsor funds to specific departments +- **Currency formatting**: Uses org's currency setting via shared `formatCurrency()` utility + +### Components +- `src/lib/components/modules/BudgetWidget.svelte` โ department-level widget +- `src/routes/[orgSlug]/events/[eventSlug]/finances/+page.svelte` โ cross-department overview + +### API (`src/lib/api/budget.ts`) +- `fetchEventBudgetCategories`, `createBudgetCategory`, `deleteBudgetCategory` +- `fetchEventBudgetItems`, `createBudgetItem`, `updateBudgetItem`, `deleteBudgetItem` + +### Room for Improvement +- **Budget approval workflow**: No approval process for expenses. +- **Currency conversion**: No multi-currency support within a single org. +- **Export**: No PDF/Excel export of financial reports. +- **Recurring items**: No support for recurring budget items. + +--- + +## 18. Sponsors Module + +### What It Is +A lightweight CRM for managing event sponsors with tiers, status pipeline, deliverables tracking, and fund allocation. + +### Problems It Solves +- Sponsor relationship management +- Deliverable tracking (what was promised vs. delivered) +- Fund allocation across departments + +### Database +```sql +CREATE TABLE sponsor_tiers (id, event_id, name, amount, color, position); +CREATE TABLE sponsors ( + id, event_id, department_id, tier_id, + name, contact_name, contact_email, contact_phone, website, + status TEXT, -- prospect, contacted, negotiating, confirmed, declined, active, completed + amount NUMERIC, notes TEXT +); +CREATE TABLE sponsor_deliverables (id, sponsor_id, description, completed); +CREATE TABLE sponsor_allocations (id, event_id, sponsor_id, department_id, amount, notes); +``` + +### Status Pipeline +`prospect` โ `contacted` โ `negotiating` โ `confirmed` โ `declined` โ `active` โ `completed` + +### Key Features +- **Tier management**: Gold/Silver/Bronze with amounts and colors +- **Status pipeline**: Visual status badges with color coding +- **Deliverables checklist**: Per-sponsor deliverable tracking +- **Contact info**: Name, email, phone, website per sponsor +- **Filter**: By status and tier +- **Fund allocation**: Allocate sponsor funds to specific departments +- **Currency formatting**: Uses org's currency setting + +### Components +- `src/lib/components/modules/SponsorsWidget.svelte` โ department-level widget +- `src/routes/[orgSlug]/events/[eventSlug]/sponsors/+page.svelte` โ event-level overview + +### API (`src/lib/api/sponsors.ts`) +- `fetchEventSponsors`, `createSponsor`, `updateSponsor`, `deleteSponsor` +- `fetchEventSponsorTiers`, `createSponsorTier`, `deleteSponsorTier` +- `fetchEventDeliverables`, `createDeliverable`, `toggleDeliverable`, `deleteDeliverable` + +### Room for Improvement +- **Email integration**: No automated sponsor outreach. +- **Contract management**: No contract status tracking. +- **Sponsor portal**: No external-facing sponsor dashboard. + +--- + +## 19. Activity Tracking + +### What It Is +An audit log of all significant actions within an organization. + +### Database +```sql +CREATE TABLE activity_log ( + id UUID PRIMARY KEY, + org_id UUID, user_id UUID, + action TEXT, entity_type TEXT, entity_id UUID, entity_name TEXT, + metadata JSONB, created_at TIMESTAMPTZ +); +``` + +### Key Features +- Tracks: create, update, delete actions on all entity types +- Displayed on org overview page as "Recent Activity" +- Shows user name, action, entity, and timestamp + +### API (`src/lib/api/activity.ts`) +- `logActivity(supabase, params)` โ creates activity log entry +- `fetchRecentActivity(supabase, orgId, limit)` โ fetches recent entries + +### Room for Improvement +- **Granular tracking**: Not all actions are logged (e.g., budget item edits). +- **Notifications**: No push/email notifications based on activity. +- **Filtering**: No filtering by entity type or user on the overview page. + +--- + +## 20. Tags System + +### What It Is +Organization-wide tags that can be applied to various entities for categorization. + +### Database +```sql +CREATE TABLE org_tags (id, org_id, name, color); +``` + +### Key Features +- Create/edit/delete tags with colors +- Managed in Settings โ Tags tab + +### Room for Improvement +- **Tag application**: Tags exist but aren't yet applied to events, documents, or other entities. +- **Tag filtering**: No cross-entity filtering by tag. + +--- + +## 21. Roles & Permissions + +### What It Is +A flexible role-based access control system with custom roles and granular permissions. + +### Database +```sql +CREATE TABLE org_roles ( + id UUID PRIMARY KEY, + org_id UUID, name TEXT, color TEXT, + permissions TEXT[], + is_default BOOLEAN, is_system BOOLEAN, position INTEGER +); + +-- org_members has role (text) and role_id (FK to org_roles) +``` + +### Built-in Roles +- **Owner**: Full access, can delete org +- **Admin**: Full access except org deletion +- **Editor**: Can create/edit content +- **Viewer**: Read-only access + +### Custom Roles +Admins can create custom roles with specific permissions: +- `documents.view`, `documents.edit`, `documents.delete` +- `calendar.view`, `calendar.edit` +- `settings.view`, `settings.edit` +- `members.view`, `members.invite`, `members.remove` + +### Implementation +- `src/lib/utils/permissions.ts` โ `hasPermission(userRole, userPermissions, permission)` utility +- Layout uses `canAccess()` to conditionally show nav items + +### Room for Improvement +- **Event-level roles**: Permissions are org-wide; no per-event role assignment. +- **Department-level permissions**: No per-department access control. + +--- + +## 22. Google Calendar Integration + +### What It Is +Two-way sync between Root's calendar and Google Calendar. + +### Implementation +- OAuth2 flow for connecting Google account +- Org-level Google Calendar connection (stored in org settings) +- Syncs events bidirectionally +- Subscribe URL for external calendar apps + +### API (`src/lib/api/google-calendar.ts`) +- `fetchGoogleCalendarEvents` โ fetches events from connected Google Calendar +- `getCalendarSubscribeUrl` โ generates iCal subscribe URL +- Push notifications for real-time sync + +### Room for Improvement +- **Per-user sync**: Currently org-level only. +- **Outlook/iCal**: Only Google Calendar supported. +- **Conflict resolution**: No handling of concurrent edits. + +--- + +## 23. Internationalization (i18n) + +### What It Is +Full UI translation support using Paraglide v2. + +### Supported Languages +- **English** (`en`) โ base locale +- **Estonian** (`et`) + +### Implementation +- ~248 message keys covering all UI strings +- Import pattern: `import * as m from "$lib/paraglide/messages"` +- Parameterized messages: `{m.key({ param: value })}` +- Config: `project.inlang/settings.json` +- Vite plugin: `paraglideVitePlugin` + +### Key Files +- `messages/en.json` โ English translations +- `messages/et.json` โ Estonian translations +- `src/lib/paraglide/` โ generated output + +### Room for Improvement +- **Language picker**: No in-app language switcher (uses browser locale). +- **More languages**: Only 2 languages supported. +- **RTL support**: No right-to-left language support. + +--- + +## 24. Platform Admin + +### What It Is +A super-admin panel for managing all organizations on the platform. + +### Route: `/admin/` + +### Database +```sql +-- Migration 030: platform_admin +-- Migration 031: platform_admin_rls +``` + +### Key Features +- View all organizations +- Platform-level statistics + +### Room for Improvement +- **User management**: No platform-level user management. +- **Billing**: No subscription/billing management. +- **Analytics**: No usage analytics dashboard. + +--- + +## 25. Testing Strategy + +### Unit Tests (Vitest) +- **Location**: `src/lib/api/*.test.ts` +- **Coverage**: All API modules have unit tests +- **Count**: 112+ tests across 9 files +- **Run**: `npm test` + +### E2E Tests (Playwright) +- **Location**: `tests/e2e/` +- **Key files**: `features.spec.ts`, `events.spec.ts` +- **Setup**: Auth setup project with `storageState` persistence +- **Important**: Uses `waitUntil: 'networkidle'` for Svelte 5 hydration timing + +### Key Testing Notes +- Svelte 5 uses event delegation โ handlers only work after hydration +- Serial test suites can cascade failures if early tests fail +- Database triggers (auto-create dashboard) can be flaky in test environments + +--- + +## 26. Database Migrations + +### Migration History +| # | Name | Description | +|---|------|-------------| +| 001 | initial_schema | Organizations, org_members, profiles, documents | +| 002 | card_checklists | Kanban card checklists | +| 003 | google_calendar | Google Calendar integration | +| 004 | org_google_calendar | Org-level Google Calendar | +| 005 | roles_and_invites | Custom roles, invites | +| 006 | simplify_google_calendar | Simplified Google Calendar schema | +| 007 | org_theme | Theme color, icon_url | +| 008 | kanban_enhancements | Kanban improvements | +| 009 | activity_tracking | Activity log table | +| 010 | tags_system | Org tags | +| 011 | teams_roles | Team roles | +| 012 | task_comments | Kanban card comments | +| 013 | kanban_labels | Kanban labels | +| 014 | document_enhancements | Document improvements | +| 015 | migrate_kanban_to_documents | Kanban as document type | +| 016 | document_locks | Document locking | +| 017 | avatars_storage | Avatar storage bucket | +| 018 | user_avatars_storage | User avatar storage | +| 019 | fix_org_members_profiles_fk | FK fix | +| 020 | matrix_credentials | Matrix chat credentials | +| 021 | org_matrix_space | Org Matrix space | +| 022 | events | Events table | +| 023 | event_team_management | Departments, event members | +| 024 | profile_extended_fields | Extended profile fields | +| 025 | event_tasks | Event tasks | +| 026 | department_dashboards | Dashboard, panels, checklists, notes | +| 027 | remove_default_seeds | Remove seed data | +| 028 | schedule_and_contacts | Schedule stages/blocks, contacts | +| 029 | budget_and_sponsors | Budget, sponsors, deliverables | +| 030 | platform_admin | Platform admin | +| 031 | platform_admin_rls | Platform admin RLS | +| 032 | document_event_dept_fks | Document event/dept FKs | +| 033 | files_storage | File storage | +| 034 | layout_triple | Triple layout preset | +| 035 | budget_receipts | Budget receipt linking | +| 036 | finance_enhancements | Finance improvements | +| 037 | org_settings | Organization settings expansion | + +### DB Workflow +```bash +npm run db:push # Push pending migrations +npm run db:types # Regenerate TypeScript types +npm run db:migrate # Push + regenerate in one step +``` + +--- + +## Summary of Improvement Priorities + +### High Priority +1. **Shared `formatDate()` utility** consuming org's `date_format` and `timezone` settings +2. **Wire `week_start_day`** into Calendar grid generation +3. **Wire `default_dept_modules`** into the department auto-create trigger +4. **Feature toggles** should also hide modules within department dashboards, not just sidebar nav + +### Medium Priority +5. **Event templates** โ clone event structures +6. **Guest list / registration** โ attendee management +7. **Document search** โ full-text search across documents +8. **Export** โ PDF/Excel export for budgets and sponsor reports + +### Low Priority +9. **Real-time collaboration** on documents +10. **SSO / 2FA** authentication +11. **Push notifications** for activity +12. **Public event pages** for attendees diff --git a/messages/en.json b/messages/en.json index 5e688e9..361a6ee 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8,13 +8,14 @@ "user_menu_account_settings": "Account Settings", "user_menu_switch_org": "Switch Organization", "user_menu_logout": "Log Out", - "btn_new": "+ New", + "btn_new": "New", "btn_create": "Create", "btn_cancel": "Cancel", "btn_save": "Save", "btn_delete": "Delete", "btn_edit": "Edit", "btn_close": "Close", + "btn_creating": "Creating...", "btn_upload": "Upload", "btn_remove": "Remove", "login_title": "Welcome to Root", @@ -24,7 +25,7 @@ "login_email_label": "Email", "login_email_placeholder": "you@example.com", "login_password_label": "Password", - "login_password_placeholder": "โขโขโขโขโขโขโขโข", + "login_password_placeholder": "ฤโฌยขฤโฌยขฤโฌยขฤโฌยขฤโฌยขฤโฌยขฤโฌยขฤโฌยข", "login_btn_login": "Log In", "login_btn_signup": "Sign Up", "login_or_continue": "or continue with", @@ -56,7 +57,7 @@ "files_context_move": "Move to...", "files_context_delete": "Delete", "files_context_open_tab": "Open in new tab", - "files_empty": "No files yet. Click + New to create one.", + "files_empty": "No files yet. Click New to create one.", "files_toggle_view": "Toggle view", "kanban_title": "Kanban", "kanban_create_board": "Create Board", @@ -110,6 +111,29 @@ "settings_general_leave_org": "Leave Organization", "settings_general_leave_org_desc": "Leave this organization. You will need to be re-invited to rejoin.", "settings_general_leave_btn": "Leave {orgName}", + "settings_social_title": "Social & Links", + "settings_social_desc": "Add your organization's website and social media links.", + "settings_social_website": "Website", + "settings_social_website_placeholder": "https://yourorg.com", + "settings_social_instagram": "Instagram", + "settings_social_instagram_placeholder": "https://instagram.com/yourorg", + "settings_social_facebook": "Facebook", + "settings_social_facebook_placeholder": "https://facebook.com/yourorg", + "settings_social_discord": "Discord", + "settings_social_discord_placeholder": "https://discord.gg/yourorg", + "settings_social_linkedin": "LinkedIn", + "settings_social_linkedin_placeholder": "https://linkedin.com/company/yourorg", + "settings_social_x": "X (Twitter)", + "settings_social_x_placeholder": "https://x.com/yourorg", + "settings_social_youtube": "YouTube", + "settings_social_youtube_placeholder": "https://youtube.com/@yourorg", + "settings_social_tiktok": "TikTok", + "settings_social_tiktok_placeholder": "https://tiktok.com/@yourorg", + "settings_social_fienta": "Fienta", + "settings_social_fienta_placeholder": "https://fienta.com/yourorg", + "settings_social_twitch": "Twitch", + "settings_social_twitch_placeholder": "https://twitch.tv/yourorg", + "settings_social_save": "Save Links", "settings_tags_title": "Organization Tags", "settings_tags_desc": "Manage tags that can be used across all Kanban boards.", "settings_tags_create": "Create Tag", @@ -118,7 +142,7 @@ "settings_members_title": "Team Members ({count})", "settings_members_invite": "Invite Member", "settings_members_pending": "Pending Invites", - "settings_members_invited_as": "Invited as {role} โข Expires {date}", + "settings_members_invited_as": "Invited as {role} ฤโฌยข Expires {date}", "settings_members_copy_link": "Copy Link", "settings_members_unknown": "Unknown User", "settings_members_no_name": "No name", @@ -160,7 +184,7 @@ "settings_connect_cal_desc": "Paste your Google Calendar's shareable link or calendar ID. The calendar must be set to public in Google Calendar settings.", "settings_connect_cal_how": "How to get your calendar link:", "settings_connect_cal_step1": "Open Google Calendar", - "settings_connect_cal_step2": "Click the 3 dots next to your calendar โ Settings", + "settings_connect_cal_step2": "Click the 3 dots next to your calendar ฤโ โ Settings", "settings_connect_cal_step3": "Under \"Access permissions\", check \"Make available to public\"", "settings_connect_cal_step4": "Scroll to \"Integrate calendar\" and copy the Calendar ID or Public URL", "settings_connect_cal_input_label": "Calendar URL or ID", @@ -410,5 +434,286 @@ "dept_files_open": "Open Files", "dept_files_desc": "Department files and documents", "dept_quick_add": "Quick add", - "dept_modules_label": "Modules" + "dept_modules_label": "Modules", + "dept_dashboard_collapse": "Collapse", + "dept_dashboard_move_left": "Move left", + "dept_dashboard_move_right": "Move right", + "dept_dashboard_layout_1col": "1 Column", + "dept_dashboard_layout_2col": "2 Columns", + "dept_dashboard_layout_3col": "3 Columns", + "dept_dashboard_layout_grid": "2x2 Grid", + "dept_module_kanban": "Kanban", + "dept_module_files": "Files", + "dept_module_checklist": "Checklist", + "dept_module_notes": "Notes", + "dept_module_schedule": "Schedule", + "dept_module_contacts": "Contacts", + "dept_module_budget": "Budget", + "dept_module_sponsors": "Sponsors", + "dept_module_map": "Map", + "toast_error_update_layout": "Failed to update layout", + "toast_error_add_module": "Failed to add module", + "toast_error_remove_module": "Failed to remove module", + "toast_error_reorder_modules": "Failed to reorder modules", + "toast_error_add_item": "Failed to add item", + "toast_error_update_item": "Failed to update item", + "toast_error_delete_item": "Failed to delete item", + "toast_error_create_checklist": "Failed to create checklist", + "toast_error_delete_checklist": "Failed to delete checklist", + "toast_error_rename_checklist": "Failed to rename checklist", + "toast_error_delete_contact": "Failed to delete contact", + "toast_error_create_category": "Failed to create category", + "toast_error_delete_category": "Failed to delete category", + "toast_error_create_budget_item": "Failed to create budget item", + "toast_error_update_budget_item": "Failed to update budget item", + "toast_error_delete_budget_item": "Failed to delete budget item", + "toast_error_upload_receipt": "Failed to upload receipt", + "toast_success_receipt_attached": "Receipt \"{name}\" attached", + "toast_error_create_tier": "Failed to create tier", + "toast_error_delete_tier": "Failed to delete tier", + "toast_error_create_sponsor": "Failed to create sponsor", + "toast_error_update_sponsor": "Failed to update sponsor", + "toast_error_delete_sponsor": "Failed to delete sponsor", + "toast_error_create_deliverable": "Failed to create deliverable", + "toast_error_update_deliverable": "Failed to update deliverable", + "toast_error_delete_deliverable": "Failed to delete deliverable", + "checklist_rename": "Rename", + "checklist_delete": "Delete checklist", + "checklist_add_item_placeholder": "Add item...", + "checklist_name_placeholder": "Checklist name...", + "checklist_no_items": "No checklists yet", + "checklist_add": "Add checklist", + "notes_new": "New note", + "notes_title_placeholder": "Note title...", + "notes_placeholder": "Start writing...", + "notes_no_notes": "No notes yet", + "notes_select": "Select a note", + "notes_export_document": "Export as document", + "notes_delete": "Delete note", + "notes_exported": "Exported \"{title}\" as document", + "notes_export_error": "Failed to export note", + "schedule_timeline": "Timeline", + "schedule_list": "List", + "schedule_add_block": "Add Block", + "schedule_manage_stages": "Manage Stages", + "schedule_no_blocks": "No schedule blocks yet", + "schedule_add_first": "Add your first block to start building the schedule.", + "schedule_all_day": "All day", + "schedule_no_stage": "No stage", + "schedule_add_stage_title": "Add Stage / Room", + "schedule_stage_name_placeholder": "e.g. Main Stage", + "schedule_add_stage": "Add Stage", + "schedule_existing_stages": "Existing Stages", + "schedule_no_stages": "No stages yet", + "schedule_add_block_title": "Add Schedule Block", + "schedule_edit_block_title": "Edit Schedule Block", + "schedule_block_title_label": "Title", + "schedule_block_title_placeholder": "e.g. Opening Ceremony", + "schedule_block_speaker_label": "Speaker / Host", + "schedule_block_speaker_placeholder": "e.g. John Doe", + "schedule_block_start_label": "Start Time", + "schedule_block_end_label": "End Time", + "schedule_block_stage_label": "Stage / Room", + "schedule_block_no_stage": "No stage", + "schedule_block_description_label": "Description", + "schedule_block_description_placeholder": "Optional description...", + "schedule_block_color_label": "Color", + "schedule_block_delete": "Delete Block", + "contacts_search_placeholder": "Search contacts...", + "contacts_add": "Add Contact", + "contacts_no_contacts": "No contacts yet", + "contacts_add_first": "Add your first contact to build your directory.", + "contacts_no_results": "No contacts match your search", + "contacts_category_all": "All", + "contacts_category_venue": "Venue", + "contacts_category_catering": "Catering", + "contacts_category_av": "AV / Tech", + "contacts_category_security": "Security", + "contacts_category_transport": "Transport", + "contacts_category_entertainment": "Entertainment", + "contacts_category_media": "Media", + "contacts_category_sponsor": "Sponsor", + "contacts_category_other": "Other", + "contacts_add_title": "Add Contact", + "contacts_edit_title": "Edit Contact", + "contacts_name_label": "Name", + "contacts_name_placeholder": "Contact name", + "contacts_role_label": "Role / Title", + "contacts_role_placeholder": "e.g. Event Manager", + "contacts_company_label": "Company", + "contacts_company_placeholder": "Company name", + "contacts_email_label": "Email", + "contacts_email_placeholder": "email@example.com", + "contacts_phone_label": "Phone", + "contacts_phone_placeholder": "+372 ...", + "contacts_website_label": "Website", + "contacts_website_placeholder": "https://...", + "contacts_category_label": "Category", + "contacts_notes_label": "Notes", + "contacts_notes_placeholder": "Optional notes...", + "contacts_delete_confirm": "Delete this contact?", + "budget_income": "Income", + "budget_expenses": "Expenses", + "budget_planned": "Planned: {amount}", + "budget_planned_balance": "Planned Balance", + "budget_actual_balance": "Actual Balance", + "budget_view_all": "All", + "budget_view_income": "Income", + "budget_view_expenses": "Expenses", + "budget_add_category": "Category", + "budget_add_item": "Add Item", + "budget_no_items": "No budget items yet", + "budget_col_type": "Type", + "budget_col_description": "Description", + "budget_col_category": "Category", + "budget_col_planned": "Planned", + "budget_col_actual": "Actual", + "budget_col_diff": "Diff", + "budget_col_receipt": "Receipt", + "budget_uncategorized": "Uncategorized", + "budget_total": "Total", + "budget_missing_receipt": "Missing invoice/receipt - click to upload", + "budget_missing_receipt_short": "Missing invoice/receipt", + "budget_receipt_attached": "Receipt attached", + "budget_add_item_title": "Add Budget Item", + "budget_edit_item_title": "Edit Budget Item", + "budget_description_label": "Description", + "budget_description_placeholder": "e.g. Venue rental", + "budget_type_label": "Type", + "budget_type_expense": "Expense", + "budget_type_income": "Income", + "budget_category_label": "Category", + "budget_planned_amount_label": "Planned Amount", + "budget_actual_amount_label": "Actual Amount", + "budget_notes_label": "Notes", + "budget_notes_placeholder": "Optional notes...", + "budget_add_category_title": "Add Category", + "budget_category_name_label": "Name", + "budget_category_name_placeholder": "e.g. Venue", + "budget_category_color_label": "Color", + "budget_select_color": "Select color {color}", + "budget_existing_categories": "Existing Categories", + "sponsors_search_placeholder": "Search sponsors...", + "sponsors_add_tier": "Add Tier", + "sponsors_add_sponsor": "Add Sponsor", + "sponsors_no_sponsors": "No sponsors yet", + "sponsors_add_first": "Add sponsor tiers and start tracking your sponsors.", + "sponsors_no_results": "No sponsors match your filters", + "sponsors_filter_all_statuses": "All Statuses", + "sponsors_filter_all_tiers": "All Tiers", + "sponsors_status_prospect": "Prospect", + "sponsors_status_contacted": "Contacted", + "sponsors_status_confirmed": "Confirmed", + "sponsors_status_declined": "Declined", + "sponsors_status_active": "Active", + "sponsors_contact_label": "Contact", + "sponsors_amount_label": "Amount", + "sponsors_deliverables_label": "Deliverables", + "sponsors_deliverable_placeholder": "Add deliverable...", + "sponsors_no_deliverables": "No deliverables yet", + "sponsors_notes_label": "Notes", + "sponsors_add_tier_title": "Add Sponsor Tier", + "sponsors_tier_name_label": "Tier Name", + "sponsors_tier_name_placeholder": "e.g. Gold", + "sponsors_tier_amount_label": "Minimum Amount", + "sponsors_tier_color_label": "Color", + "sponsors_existing_tiers": "Existing Tiers", + "sponsors_no_tiers": "No tiers yet", + "sponsors_add_sponsor_title": "Add Sponsor", + "sponsors_edit_sponsor_title": "Edit Sponsor", + "sponsors_name_label": "Sponsor Name", + "sponsors_name_placeholder": "Company name", + "sponsors_tier_label": "Tier", + "sponsors_no_tier": "No tier", + "sponsors_status_label": "Status", + "sponsors_sponsor_amount_label": "Amount (ฤโยฌ)", + "sponsors_sponsor_amount_placeholder": "0", + "sponsors_contact_name_label": "Contact Name", + "sponsors_contact_name_placeholder": "Contact person", + "sponsors_contact_email_label": "Contact Email", + "sponsors_contact_email_placeholder": "email@example.com", + "sponsors_contact_phone_label": "Contact Phone", + "sponsors_contact_phone_placeholder": "+372 ...", + "sponsors_website_label": "Website", + "sponsors_website_placeholder": "https://...", + "sponsors_notes_placeholder": "Optional notes...", + "sponsors_delete_confirm": "Delete this sponsor?", + "sponsors_select_color": "Select color {color}", + "files_widget_loading": "Loading files...", + "files_widget_no_folder": "Department folder not yet created", + "files_widget_empty": "No files yet", + "files_widget_full_view": "Full View", + "files_widget_create_title": "Create New", + "files_widget_type_document": "Document", + "files_widget_type_folder": "Folder", + "files_widget_type_kanban": "Kanban Board", + "files_widget_name_label": "Name", + "files_widget_doc_placeholder": "Document name", + "files_widget_folder_placeholder": "Folder name", + "files_widget_kanban_placeholder": "Board name", + "files_widget_drop_files": "Drop files to upload", + "files_widget_uploading": "Uploading {name}...", + "kanban_widget_loading": "Loading boards...", + "kanban_widget_no_folder": "Department folder not yet created", + "kanban_widget_no_boards": "No kanban boards yet", + "kanban_widget_create": "Create Board", + "kanban_widget_create_title": "Create Kanban Board", + "kanban_widget_name_label": "Board Name", + "kanban_widget_name_placeholder": "e.g. Task Tracker", + "finances_title": "Event Finances", + "finances_subtitle": "Budget overview across all departments", + "finances_total_income": "Total Income", + "finances_total_expenses": "Total Expenses", + "finances_net_balance": "Net Balance", + "finances_missing_receipts": "Missing Receipts", + "finances_items_without_receipts": "{count} items without receipts", + "finances_planned": "planned", + "finances_view_by_dept": "By Department", + "finances_view_by_category": "By Category", + "finances_filter_all": "All", + "finances_filter_income": "Income", + "finances_filter_expenses": "Expenses", + "finances_no_items": "No budget items yet", + "finances_no_items_desc": "Budget items will appear here once departments add them.", + "finances_col_description": "Description", + "finances_col_department": "Department", + "finances_col_category": "Category", + "finances_col_planned": "Planned", + "finances_col_actual": "Actual", + "finances_col_diff": "Diff", + "finances_col_receipt": "Receipt", + "finances_uncategorized": "Uncategorized", + "finances_no_department": "No Department", + "finances_group_total": "Subtotal", + "page_chat": "Chat", + "page_department": "Department", + "page_invite": "Invitation", + "map_tool_grab": "Grab", + "map_tool_select": "Select", + "map_tool_pin": "Pin", + "map_tool_polygon": "Polygon", + "map_tool_rectangle": "Rectangle", + "map_add_layer": "Add Map Layer", + "map_add_image_layer": "Add Image Map Layer", + "map_layer_name": "Layer Name", + "map_street_map": "Street Map", + "map_custom_image": "Custom Image", + "map_osm_tiles": "OpenStreetMap tiles", + "map_upload_venue": "Upload venue map", + "map_choose_image": "Choose image file", + "map_uploading": "Uploading...", + "map_image_url": "Image URL", + "map_image_desc": "Upload a venue floor plan or map image. Aspect ratio will be preserved.", + "map_objects": "Objects", + "map_no_objects": "No objects yet", + "map_export_png": "Export PNG", + "map_rename_layer": "Rename Layer", + "map_edit_pin": "Edit Pin", + "map_add_pin": "Add Pin", + "map_edit_shape": "Edit Shape", + "map_label": "Label", + "map_description": "Description", + "map_color": "Color", + "map_pen_hint_points": "{count} point(s)", + "map_pen_hint_close": "click near first point to close, or press Esc to cancel" } \ No newline at end of file diff --git a/messages/et.json b/messages/et.json index 446b020..0ad31ec 100644 --- a/messages/et.json +++ b/messages/et.json @@ -7,28 +7,28 @@ "nav_kanban": "Kanban", "user_menu_account_settings": "Konto seaded", "user_menu_switch_org": "Vaheta organisatsiooni", - "user_menu_logout": "Logi vรคlja", - "btn_new": "+ Uus", + "user_menu_logout": "Logi vฤยคlja", + "btn_new": "Uus", "btn_create": "Loo", - "btn_cancel": "Tรผhista", + "btn_cancel": "Tฤยผhista", "btn_save": "Salvesta", "btn_delete": "Kustuta", "btn_edit": "Muuda", "btn_close": "Sulge", - "btn_upload": "Laadi รผles", + "btn_upload": "Laadi ฤยผles", "btn_remove": "Eemalda", "login_title": "Tere tulemast Rooti", - "login_subtitle": "Sinu meeskonna tรถรถruum dokumentide, tahvlite ja kalendrite jaoks.", + "login_subtitle": "Sinu meeskonna tฤยถฤยถruum dokumentide, tahvlite ja kalendrite jaoks.", "login_tab_login": "Logi sisse", "login_tab_signup": "Registreeru", "login_email_label": "E-post", - "login_email_placeholder": "sina@nรคide.ee", + "login_email_placeholder": "sina@nฤยคide.ee", "login_password_label": "Parool", - "login_password_placeholder": "โขโขโขโขโขโขโขโข", + "login_password_placeholder": "ฤโฌยขฤโฌยขฤโฌยขฤโฌยขฤโฌยขฤโฌยขฤโฌยขฤโฌยข", "login_btn_login": "Logi sisse", "login_btn_signup": "Registreeru", - "login_or_continue": "vรตi jรคtka", - "login_google": "Jรคtka Google'iga", + "login_or_continue": "vฤยตi jฤยคtka", + "login_google": "Jฤยคtka Google'iga", "login_signup_prompt": "Pole kontot?", "login_login_prompt": "On juba konto?", "login_signup_success_title": "Kontrolli oma e-posti", @@ -38,9 +38,9 @@ "org_selector_create_title": "Loo organisatsioon", "org_selector_name_label": "Organisatsiooni nimi", "org_selector_name_placeholder": "Minu meeskond", - "org_selector_slug_label": "URL-i lรผhend", + "org_selector_slug_label": "URL-i lฤยผhend", "org_selector_slug_placeholder": "minu-meeskond", - "org_overview": "Organisatsiooni รผlevaade", + "org_overview": "Organisatsiooni ฤยผlevaade", "files_title": "Failid", "files_breadcrumb_home": "Avaleht", "files_create_title": "Loo uus", @@ -51,48 +51,48 @@ "files_doc_placeholder": "Dokumendi nimi", "files_folder_placeholder": "Kausta nimi", "files_kanban_placeholder": "Kanban tahvli nimi", - "files_rename_title": "Nimeta รผmber", - "files_context_rename": "Nimeta รผmber", + "files_rename_title": "Nimeta ฤยผmber", + "files_context_rename": "Nimeta ฤยผmber", "files_context_move": "Teisalda...", "files_context_delete": "Kustuta", "files_context_open_tab": "Ava uuel vahelehel", - "files_empty": "Faile pole veel. Kliki + Uus, et luua.", + "files_empty": "Faile pole veel. Kliki Uus, et luua.", "files_toggle_view": "Vaheta vaadet", "kanban_title": "Kanban", "kanban_create_board": "Loo tahvel", "kanban_board_name_label": "Tahvli nimi", "kanban_board_name_placeholder": "nt Sprint 1", "kanban_edit_board": "Muuda tahvlit", - "kanban_rename_board": "Nimeta tahvel รผmber", + "kanban_rename_board": "Nimeta tahvel ฤยผmber", "kanban_delete_board": "Kustuta tahvel", "kanban_add_column": "Lisa veerg", "kanban_column_name_label": "Veeru nimi", - "kanban_column_name_placeholder": "nt Teha, Tรถรถs, Valmis", + "kanban_column_name_placeholder": "nt Teha, Tฤยถฤยถs, Valmis", "kanban_add_card": "Lisa kaart", - "kanban_card_details": "Kaardi รผksikasjad", + "kanban_card_details": "Kaardi ฤยผksikasjad", "kanban_card_title_label": "Pealkiri", "kanban_card_title_placeholder": "Kaardi pealkiri", "kanban_card_desc_label": "Kirjeldus", - "kanban_card_desc_placeholder": "Lisa รผksikasjalikum kirjeldus...", + "kanban_card_desc_placeholder": "Lisa ฤยผksikasjalikum kirjeldus...", "kanban_tags": "Sildid", "kanban_tag_placeholder": "Sildi nimi", "kanban_tag_add": "Lisa", - "kanban_empty": "Kanban tahvleid hallatakse nรผรผd Failides", + "kanban_empty": "Kanban tahvleid hallatakse nฤยผฤยผd Failides", "kanban_go_to_files": "Mine Failidesse", - "kanban_select_board": "Vali tahvel รผlalt", + "kanban_select_board": "Vali tahvel ฤยผlalt", "calendar_title": "Kalender", "calendar_subscribe": "Telli kalender", - "calendar_refresh": "Vรคrskenda sรผndmusi", + "calendar_refresh": "Vฤยคrskenda sฤยผndmusi", "calendar_settings": "Kalendri seaded", - "calendar_create_event": "Loo sรผndmus", - "calendar_edit_event": "Muuda sรผndmust", + "calendar_create_event": "Loo sฤยผndmus", + "calendar_edit_event": "Muuda sฤยผndmust", "calendar_event_title": "Pealkiri", - "calendar_event_title_placeholder": "Sรผndmuse pealkiri", - "calendar_event_date": "Kuupรคev", + "calendar_event_title_placeholder": "Sฤยผndmuse pealkiri", + "calendar_event_date": "Kuupฤยคev", "calendar_event_time": "Kellaaeg", "calendar_event_desc": "Kirjeldus", "settings_title": "Seaded", - "settings_tab_general": "รldine", + "settings_tab_general": "ฤยldine", "settings_tab_members": "Liikmed", "settings_tab_roles": "Rollid", "settings_tab_tags": "Sildid", @@ -101,24 +101,47 @@ "settings_general_avatar": "Avatar", "settings_general_name": "Nimi", "settings_general_name_placeholder": "Organisatsiooni nimi", - "settings_general_slug": "URL-i lรผhend (sait.ee/...)", + "settings_general_slug": "URL-i lฤยผhend (sait.ee/...)", "settings_general_slug_placeholder": "minu-org", "settings_general_save": "Salvesta muudatused", "settings_general_danger_zone": "Ohutsoon", "settings_general_delete_org": "Kustuta organisatsioon", - "settings_general_delete_org_desc": "Kustuta see organisatsioon ja kรตik selle andmed jรครคdavalt.", + "settings_general_delete_org_desc": "Kustuta see organisatsioon ja kฤยตik selle andmed jฤยคฤยคdavalt.", "settings_general_leave_org": "Lahku organisatsioonist", "settings_general_leave_org_desc": "Lahku sellest organisatsioonist. Tagasi liitumiseks on vaja uut kutset.", "settings_general_leave_btn": "Lahku {orgName}", + "settings_social_title": "Sotsiaalmeedia ja lingid", + "settings_social_desc": "Lisa oma organisatsiooni veebileht ja sotsiaalmeedia lingid.", + "settings_social_website": "Veebileht", + "settings_social_website_placeholder": "https://sinuorg.ee", + "settings_social_instagram": "Instagram", + "settings_social_instagram_placeholder": "https://instagram.com/sinuorg", + "settings_social_facebook": "Facebook", + "settings_social_facebook_placeholder": "https://facebook.com/sinuorg", + "settings_social_discord": "Discord", + "settings_social_discord_placeholder": "https://discord.gg/sinuorg", + "settings_social_linkedin": "LinkedIn", + "settings_social_linkedin_placeholder": "https://linkedin.com/company/sinuorg", + "settings_social_x": "X (Twitter)", + "settings_social_x_placeholder": "https://x.com/sinuorg", + "settings_social_youtube": "YouTube", + "settings_social_youtube_placeholder": "https://youtube.com/@sinuorg", + "settings_social_tiktok": "TikTok", + "settings_social_tiktok_placeholder": "https://tiktok.com/@sinuorg", + "settings_social_fienta": "Fienta", + "settings_social_fienta_placeholder": "https://fienta.com/sinuorg", + "settings_social_twitch": "Twitch", + "settings_social_twitch_placeholder": "https://twitch.tv/sinuorg", + "settings_social_save": "Salvesta lingid", "settings_tags_title": "Organisatsiooni sildid", - "settings_tags_desc": "Halda silte, mida saab kasutada kรตigil Kanban tahvlitel.", + "settings_tags_desc": "Halda silte, mida saab kasutada kฤยตigil Kanban tahvlitel.", "settings_tags_create": "Loo silt", "settings_tags_empty": "Silte pole veel. Loo oma esimene silt Kanban kaartide korraldamiseks.", "settings_tags_name_placeholder": "Sildi nimi", "settings_members_title": "Meeskonna liikmed ({count})", "settings_members_invite": "Kutsu liige", "settings_members_pending": "Ootel kutsed", - "settings_members_invited_as": "Kutsutud kui {role} โข Aegub {date}", + "settings_members_invited_as": "Kutsutud kui {role} ฤโฌยข Aegub {date}", "settings_members_copy_link": "Kopeeri link", "settings_members_unknown": "Tundmatu kasutaja", "settings_members_no_name": "Nimi puudub", @@ -126,7 +149,7 @@ "settings_members_remove": "Eemalda organisatsioonist", "settings_invite_title": "Kutsu liige", "settings_invite_email": "E-posti aadress", - "settings_invite_email_placeholder": "kolleeg@nรคide.ee", + "settings_invite_email_placeholder": "kolleeg@nฤยคide.ee", "settings_invite_role": "Roll", "settings_invite_send": "Saada kutse", "settings_invite_role_viewer": "Vaataja - Saab sisu vaadata", @@ -135,42 +158,42 @@ "settings_invite_role_admin": "Admin - Saab hallata liikmeid ja seadeid", "settings_edit_member": "Muuda liiget", "settings_roles_title": "Rollid", - "settings_roles_desc": "Loo kohandatud rolle kindlate รตigustega.", + "settings_roles_desc": "Loo kohandatud rolle kindlate ฤยตigustega.", "settings_roles_create": "Loo roll", "settings_roles_edit": "Muuda rolli", - "settings_roles_system": "Sรผsteemne", + "settings_roles_system": "Sฤยผsteemne", "settings_roles_default": "Vaikimisi", - "settings_roles_all_perms": "Kรตik รตigused", + "settings_roles_all_perms": "Kฤยตik ฤยตigused", "settings_roles_more": "+{count} veel", "settings_roles_name_label": "Nimi", "settings_roles_name_placeholder": "nt Moderaator", - "settings_roles_color": "Vรคrv", - "settings_roles_permissions": "รigused", + "settings_roles_color": "Vฤยคrv", + "settings_roles_permissions": "ฤโขigused", "settings_integrations_google_cal": "Google'i kalender", - "settings_integrations_google_cal_desc": "Jaga Google'i kalendrit kรตigi organisatsiooni liikmetega.", - "settings_integrations_connected": "รhendatud", - "settings_integrations_disconnect": "Katkesta รผhendus", - "settings_integrations_connect": "รhenda Google'i kalender", + "settings_integrations_google_cal_desc": "Jaga Google'i kalendrit kฤยตigi organisatsiooni liikmetega.", + "settings_integrations_connected": "ฤยhendatud", + "settings_integrations_disconnect": "Katkesta ฤยผhendus", + "settings_integrations_connect": "ฤยhenda Google'i kalender", "settings_integrations_discord": "Discord", "settings_integrations_discord_desc": "Saa teavitusi oma Discordi serveris.", "settings_integrations_slack": "Slack", - "settings_integrations_slack_desc": "Saa teavitusi oma Slacki tรถรถruumis.", + "settings_integrations_slack_desc": "Saa teavitusi oma Slacki tฤยถฤยถruumis.", "settings_integrations_coming_soon": "Tulekul", - "settings_connect_cal_title": "รhenda avalik Google'i kalender", - "settings_connect_cal_desc": "Kleebi oma Google'i kalendri jagamislink vรตi kalendri ID. Kalender peab olema Google'i kalendri seadetes avalikuks seatud.", + "settings_connect_cal_title": "ฤยhenda avalik Google'i kalender", + "settings_connect_cal_desc": "Kleebi oma Google'i kalendri jagamislink vฤยตi kalendri ID. Kalender peab olema Google'i kalendri seadetes avalikuks seatud.", "settings_connect_cal_how": "Kuidas saada kalendri linki:", "settings_connect_cal_step1": "Ava Google'i kalender", - "settings_connect_cal_step2": "Kliki kalendri kรตrval 3 punkti โ Seaded", - "settings_connect_cal_step3": "\"Juurdepรครคsuรตiguste\" all mรคrgi \"Tee avalikuks\"", - "settings_connect_cal_step4": "Keri alla \"Kalendri integreerimine\" ja kopeeri kalendri ID vรตi avalik URL", - "settings_connect_cal_input_label": "Kalendri URL vรตi ID", - "settings_connect_cal_input_placeholder": "Kleebi kalendri URL vรตi ID (nt abc123@group.calendar.google.com)", - "settings_connect_cal_btn": "รhenda", + "settings_connect_cal_step2": "Kliki kalendri kฤยตrval 3 punkti ฤโ โ Seaded", + "settings_connect_cal_step3": "\"Juurdepฤยคฤยคsuฤยตiguste\" all mฤยคrgi \"Tee avalikuks\"", + "settings_connect_cal_step4": "Keri alla \"Kalendri integreerimine\" ja kopeeri kalendri ID vฤยตi avalik URL", + "settings_connect_cal_input_label": "Kalendri URL vฤยตi ID", + "settings_connect_cal_input_placeholder": "Kleebi kalendri URL vฤยตi ID (nt abc123@group.calendar.google.com)", + "settings_connect_cal_btn": "ฤยhenda", "account_title": "Konto seaded", "account_subtitle": "Halda oma isiklikku profiili ja eelistusi.", "account_profile": "Profiil", "account_photo": "Foto", - "account_sync_google": "Sรผnkrooni Google", + "account_sync_google": "Sฤยผnkrooni Google", "account_remove_photo": "Eemalda foto", "account_display_name": "Kuvatav nimi", "account_display_name_placeholder": "Sinu nimi", @@ -180,16 +203,16 @@ "account_discord": "Discord", "account_discord_placeholder": "kasutajanimi", "account_contact_info": "Kontakt ja suurused", - "account_shirt_size": "Sรคrgi suurus", + "account_shirt_size": "Sฤยคrgi suurus", "account_hoodie_size": "Pusa suurus", "account_size_placeholder": "Vali suurus", "account_save_profile": "Salvesta profiil", - "account_appearance": "Vรคlimus", + "account_appearance": "Vฤยคlimus", "account_theme": "Teema", "account_theme_dark": "Tume", "account_theme_light": "Hele (tulekul)", - "account_theme_system": "Sรผsteemne (tulekul)", - "account_accent_color": "Aktsentvรคrv", + "account_theme_system": "Sฤยผsteemne (tulekul)", + "account_accent_color": "Aktsentvฤยคrv", "account_use_org_theme": "Kasuta organisatsiooni teemat", "account_use_org_theme_desc": "Asenda oma isiklik teema organisatsiooni seadetega.", "account_language": "Keel", @@ -197,11 +220,11 @@ "account_save_preferences": "Salvesta eelistused", "account_security": "Turvalisus ja seansid", "account_password": "Parool", - "account_password_desc": "Kui logisid sisse Google'iga, haldab sinu parooli Google. Muul juhul saad parooli e-posti teel lรคhtestada.", - "account_send_reset": "Saada lรคhtestamise e-kiri", + "account_password_desc": "Kui logisid sisse Google'iga, haldab sinu parooli Google. Muul juhul saad parooli e-posti teel lฤยคhtestada.", + "account_send_reset": "Saada lฤยคhtestamise e-kiri", "account_active_sessions": "Aktiivsed seansid", - "account_sessions_desc": "Logi vรคlja kรตigist teistest seanssidest, kui kahtlustad volitamata juurdepรครคsu.", - "account_signout_others": "Logi teised seansid vรคlja", + "account_sessions_desc": "Logi vฤยคlja kฤยตigist teistest seanssidest, kui kahtlustad volitamata juurdepฤยคฤยคsu.", + "account_signout_others": "Logi teised seansid vฤยคlja", "editor_save": "Salvesta", "editor_saving": "Salvestamine...", "editor_saved": "Salvestatud", @@ -209,33 +232,33 @@ "editor_placeholder": "Alusta kirjutamist...", "editor_bold": "Paks (Ctrl+B)", "editor_italic": "Kursiiv (Ctrl+I)", - "editor_strikethrough": "Lรคbikriipsutus", - "editor_bullet_list": "Tรคpploend", + "editor_strikethrough": "Lฤยคbikriipsutus", + "editor_bullet_list": "Tฤยคpploend", "editor_numbered_list": "Nummerdatud loend", "editor_quote": "Tsitaat", "editor_code_block": "Koodiplokk", - "toast_error_delete_org": "Organisatsiooni kustutamine ebaรตnnestus.", - "toast_error_leave_org": "Organisatsioonist lahkumine ebaรตnnestus.", - "toast_error_invite": "Kutse saatmine ebaรตnnestus: {error}", - "toast_error_update_role": "Rolli uuendamine ebaรตnnestus.", - "toast_error_remove_member": "Liikme eemaldamine ebaรตnnestus.", - "toast_error_delete_role": "Rolli kustutamine ebaรตnnestus.", - "toast_error_disconnect_cal": "Kalendri lahtiรผhendamine ebaรตnnestus.", - "toast_error_reset_email": "Lรคhtestamise e-kirja saatmine ebaรตnnestus.", - "toast_success_reset_email": "Parooli lรคhtestamise e-kiri saadetud.", - "toast_success_signout_others": "Teised seansid vรคlja logitud.", - "confirm_delete_role": "Kustuta roll \"{name}\"? Selle rolliga liikmed tuleb รผmber mรครคrata.", + "toast_error_delete_org": "Organisatsiooni kustutamine ebaฤยตnnestus.", + "toast_error_leave_org": "Organisatsioonist lahkumine ebaฤยตnnestus.", + "toast_error_invite": "Kutse saatmine ebaฤยตnnestus: {error}", + "toast_error_update_role": "Rolli uuendamine ebaฤยตnnestus.", + "toast_error_remove_member": "Liikme eemaldamine ebaฤยตnnestus.", + "toast_error_delete_role": "Rolli kustutamine ebaฤยตnnestus.", + "toast_error_disconnect_cal": "Kalendri lahtiฤยผhendamine ebaฤยตnnestus.", + "toast_error_reset_email": "Lฤยคhtestamise e-kirja saatmine ebaฤยตnnestus.", + "toast_success_reset_email": "Parooli lฤยคhtestamise e-kiri saadetud.", + "toast_success_signout_others": "Teised seansid vฤยคlja logitud.", + "confirm_delete_role": "Kustuta roll \"{name}\"? Selle rolliga liikmed tuleb ฤยผmber mฤยคฤยคrata.", "confirm_leave_org": "Kas oled kindel, et soovid lahkuda {orgName}?", "confirm_delete_org": "Sisesta \"{orgName}\" kustutamise kinnitamiseks:", "confirm_remove_member": "Eemalda {name} organisatsioonist?", - "confirm_disconnect_cal": "Katkesta Google'i kalendri รผhendus?", + "confirm_disconnect_cal": "Katkesta Google'i kalendri ฤยผhendus?", "role_viewer": "Vaataja", "role_commenter": "Kommenteerija", "role_editor": "Toimetaja", "role_admin": "Admin", "role_owner": "Omanik", - "error_owner_cant_leave": "Omanikud ei saa lahkuda. Kรตigepealt anna omandiรตigus รผle vรตi kustuta organisatsioon.", - "overview_title": "Organisatsiooni รผlevaade", + "error_owner_cant_leave": "Omanikud ei saa lahkuda. Kฤยตigepealt anna omandiฤยตigus ฤยผle vฤยตi kustuta organisatsioon.", + "overview_title": "Organisatsiooni ฤยผlevaade", "overview_stat_members": "Liikmed", "overview_stat_documents": "Dokumendid", "overview_stat_folders": "Kaustad", @@ -243,11 +266,11 @@ "overview_quick_links": "Kiirlingid", "activity_title": "Viimane tegevus", "activity_empty": "Viimast tegevust pole veel.", - "activity_created": "{user} lรตi {entityType} \"{name}\"", + "activity_created": "{user} lฤยตi {entityType} \"{name}\"", "activity_updated": "{user} uuendas {entityType} \"{name}\"", "activity_deleted": "{user} kustutas {entityType} \"{name}\"", "activity_moved": "{user} teisaldas {entityType} \"{name}\"", - "activity_renamed": "{user} nimetas รผmber {entityType} \"{name}\"", + "activity_renamed": "{user} nimetas ฤยผmber {entityType} \"{name}\"", "activity_just_now": "Just praegu", "activity_minutes_ago": "{count} min tagasi", "activity_hours_ago": "{count}t tagasi", @@ -260,88 +283,88 @@ "entity_member": "liikme", "entity_role": "rolli", "entity_invite": "kutse", - "entity_event": "รผrituse", - "nav_events": "รritused", + "entity_event": "ฤยผrituse", + "nav_events": "ฤยritused", "nav_chat": "Vestlus", "chat_title": "Vestlus", - "chat_subtitle": "Meeskonna sรตnumid ja suhtlus", - "events_title": "รritused", - "events_subtitle": "Korralda ja halda oma รผritusi", - "events_new": "Uus รผritus", - "events_create": "Loo รผritus", - "events_empty_title": "รritusi pole veel", - "events_empty_desc": "Loo oma esimene รผritus alustamiseks", - "events_no_dates": "Kuupรคevad mรครคramata", - "events_tab_all": "Kรตik รผritused", + "chat_subtitle": "Meeskonna sฤยตnumid ja suhtlus", + "events_title": "ฤยritused", + "events_subtitle": "Korralda ja halda oma ฤยผritusi", + "events_new": "Uus ฤยผritus", + "events_create": "Loo ฤยผritus", + "events_empty_title": "ฤยritusi pole veel", + "events_empty_desc": "Loo oma esimene ฤยผritus alustamiseks", + "events_no_dates": "Kuupฤยคevad mฤยคฤยคramata", + "events_tab_all": "Kฤยตik ฤยผritused", "events_tab_planning": "Planeerimisel", "events_tab_active": "Aktiivne", - "events_tab_completed": "Lรตpetatud", + "events_tab_completed": "Lฤยตpetatud", "events_tab_archived": "Arhiveeritud", "events_status_planning": "Planeerimisel", "events_status_active": "Aktiivne", - "events_status_completed": "Lรตpetatud", + "events_status_completed": "Lฤยตpetatud", "events_status_archived": "Arhiveeritud", - "events_form_name": "รrituse nimi", + "events_form_name": "ฤยrituse nimi", "events_form_name_placeholder": "nt Suvekonverents 2026", "events_form_description": "Kirjeldus", - "events_form_description_placeholder": "รrituse lรผhikirjeldus...", - "events_form_start_date": "Alguskuupรคev", - "events_form_end_date": "Lรตppkuupรคev", + "events_form_description_placeholder": "ฤยrituse lฤยผhikirjeldus...", + "events_form_start_date": "Alguskuupฤยคev", + "events_form_end_date": "Lฤยตppkuupฤยคev", "events_form_venue": "Toimumiskoht", "events_form_venue_placeholder": "nt Konverentsikeskus", "events_form_venue_address_placeholder": "Toimumiskoha aadress", - "events_form_color": "Vรคrv", - "events_form_select_color": "Vali vรคrv {color}", + "events_form_color": "Vฤยคrv", + "events_form_select_color": "Vali vฤยคrv {color}", "events_creating": "Loomine...", "events_saving": "Salvestamine...", "events_deleting": "Kustutamine...", - "events_updated": "รritus uuendatud", - "events_created": "รritus \"{name}\" loodud", - "events_deleted": "รritus kustutatud", - "events_delete_title": "Kustuta รผritus?", - "events_delete_desc": "See kustutab jรครคdavalt รผrituse {name} ja kรตik selle andmed. Seda toimingut ei saa tagasi vรตtta.", - "events_delete_confirm": "Kustuta รผritus", - "events_days_ago": "{count} pรคeva tagasi", - "events_today": "Tรคna!", + "events_updated": "ฤยritus uuendatud", + "events_created": "ฤยritus \"{name}\" loodud", + "events_deleted": "ฤยritus kustutatud", + "events_delete_title": "Kustuta ฤยผritus?", + "events_delete_desc": "See kustutab jฤยคฤยคdavalt ฤยผrituse {name} ja kฤยตik selle andmed. Seda toimingut ei saa tagasi vฤยตtta.", + "events_delete_confirm": "Kustuta ฤยผritus", + "events_days_ago": "{count} pฤยคeva tagasi", + "events_today": "Tฤยคna!", "events_tomorrow": "Homme", - "events_in_days": "{count} pรคeva pรคrast", - "events_overview": "รlevaade", + "events_in_days": "{count} pฤยคeva pฤยคrast", + "events_overview": "ฤยlevaade", "events_modules": "Moodulid", - "events_details": "รrituse andmed", - "events_start_date": "Alguskuupรคev", - "events_end_date": "Lรตppkuupรคev", + "events_details": "ฤยrituse andmed", + "events_start_date": "Alguskuupฤยคev", + "events_end_date": "Lฤยตppkuupฤยคev", "events_venue": "Toimumiskoht", - "events_not_set": "Mรครคramata", - "events_all_events": "Kรตik รผritused", + "events_not_set": "Mฤยคฤยคramata", + "events_all_events": "Kฤยตik ฤยผritused", "events_team": "Meeskond", "events_team_count": "Meeskond ({count})", "events_team_manage": "Halda", - "events_team_empty": "Meeskonnaliikmeid pole veel mรครคratud", + "events_team_empty": "Meeskonnaliikmeid pole veel mฤยคฤยคratud", "events_more_members": "+{count} veel", - "events_mod_tasks": "รlesanded", - "events_mod_tasks_desc": "Halda รผlesandeid, verstaposte ja edenemist", + "events_mod_tasks": "ฤยlesanded", + "events_mod_tasks_desc": "Halda ฤยผlesandeid, verstaposte ja edenemist", "events_mod_files": "Failid", "events_mod_files_desc": "Dokumendid, lepingud ja meedia", "events_mod_schedule": "Ajakava", - "events_mod_schedule_desc": "รrituse ajakava ja programm", + "events_mod_schedule_desc": "ฤยrituse ajakava ja programm", "events_mod_budget": "Eelarve", - "events_mod_budget_desc": "Tulud, kulud ja jรคlgimine", - "events_mod_guests": "Kรผlalised", - "events_mod_guests_desc": "Kรผlaliste nimekiri ja registreerimine", + "events_mod_budget_desc": "Tulud, kulud ja jฤยคlgimine", + "events_mod_guests": "Kฤยผlalised", + "events_mod_guests_desc": "Kฤยผlaliste nimekiri ja registreerimine", "events_mod_team": "Meeskond", "events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine", "events_mod_sponsors": "Sponsorid", "events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused", "module_coming_soon": "Tulekul", - "module_coming_soon_desc": "See moodul on arendamisel ja saab peagi kรคttesaadavaks.", - "team_title": "รrituse meeskond", - "team_subtitle": "Halda meeskonnaliikmeid ja nende rolle selle รผrituse jaoks.", + "module_coming_soon_desc": "See moodul on arendamisel ja saab peagi kฤยคttesaadavaks.", + "team_title": "ฤยrituse meeskond", + "team_subtitle": "Halda meeskonnaliikmeid ja nende rolle selle ฤยผrituse jaoks.", "team_add_member": "Lisa liige", "team_role_lead": "Juht", "team_role_manager": "Haldur", "team_role_member": "Liige", - "team_empty": "Meeskonnaliikmeid pole veel mรครคratud. Lisa liikmeid oma organisatsioonist.", - "team_remove_confirm": "Eemalda {name} selle รผrituse meeskonnast?", + "team_empty": "Meeskonnaliikmeid pole veel mฤยคฤยคratud. Lisa liikmeid oma organisatsioonist.", + "team_remove_confirm": "Eemalda {name} selle ฤยผrituse meeskonnast?", "team_remove_btn": "Eemalda", "team_added": "{name} lisatud meeskonda", "team_removed": "{name} eemaldatud meeskonnast", @@ -349,66 +372,358 @@ "team_select_member": "Vali liige", "team_select_role": "Vali roll", "team_already_assigned": "Juba meeskonnas", - "team_departments": "Osakonnad", + "team_departments": "Valdkonnad", "team_roles": "Rollid", - "team_all": "Kรตik", - "team_no_department": "Mรครคramata", - "team_add_department": "Lisa osakond", + "team_all": "Kฤยตik", + "team_no_department": "Mฤยคฤยคramata", + "team_add_department": "Lisa valdkond", "team_add_role": "Lisa roll", - "team_edit_department": "Muuda osakonda", + "team_edit_department": "Muuda valdkonda", "team_edit_role": "Muuda rolli", - "team_dept_name": "Osakonna nimi", + "team_dept_name": "Valdkonna nimi", "team_role_name": "Rolli nimi", - "team_dept_created": "Osakond loodud", - "team_dept_updated": "Osakond uuendatud", - "team_dept_deleted": "Osakond kustutatud", + "team_dept_created": "Valdkond loodud", + "team_dept_updated": "Valdkond uuendatud", + "team_dept_deleted": "Valdkond kustutatud", "team_role_created": "Roll loodud", "team_role_updated": "Roll uuendatud", "team_role_deleted": "Roll kustutatud", - "team_dept_delete_confirm": "Kustuta osakond {name}? Liikmed eemaldatakse sellest.", + "team_dept_delete_confirm": "Kustuta valdkond {name}? Liikmed eemaldatakse sellest.", "team_role_delete_confirm": "Kustuta roll {name}? Liikmed kaotavad selle rolli.", - "team_view_by_dept": "Osakondade jรคrgi", + "team_view_by_dept": "Valdkondade jฤยคrgi", "team_view_list": "Nimekirja vaade", "team_member_count": "{count} liiget", - "team_assign_dept": "Mรครคra osakonnad", - "team_notes": "Mรคrkmed", - "team_notes_placeholder": "Valikulised mรคrkmed selle liikme kohta...", - "overview_subtitle": "Tere tagasi. Siin on รผlevaade toimuvast.", - "overview_stat_events": "รritused", - "overview_upcoming_events": "Tulevased รผritused", - "overview_upcoming_empty": "Tulevasi รผritusi pole. Loo รผks alustamiseks.", - "overview_view_all_events": "Vaata kรตiki รผritusi", + "team_assign_dept": "Mฤยคฤยคra valdkonnad", + "team_notes": "Mฤยคrkmed", + "team_notes_placeholder": "Valikulised mฤยคrkmed selle liikme kohta...", + "overview_subtitle": "Tere tagasi. Siin on ฤยผlevaade toimuvast.", + "overview_stat_events": "ฤยritused", + "overview_upcoming_events": "Tulevased ฤยผritused", + "overview_upcoming_empty": "Tulevasi ฤยผritusi pole. Loo ฤยผks alustamiseks.", + "overview_view_all_events": "Vaata kฤยตiki ฤยผritusi", "overview_more_members": "+{count} veel", "chat_join_title": "Liitu vestlusega", - "chat_join_description": "Vestlus pรตhineb Matrixil โ avatud standardil turvalise ja detsentraliseeritud suhtluse jaoks.", - "chat_join_consent": "Liitudes luuakse sulle Matrixi konto sinu praeguste profiiliandmete (nimi, e-post ja avatar) pรตhjal.", - "chat_join_learn_more": "Loe Matrixi kohta lรคhemalt", + "chat_join_description": "Vestlus pฤยตhineb Matrixil - avatud standardil turvalise ja detsentraliseeritud suhtluse jaoks.", + "chat_join_consent": "Liitudes luuakse sulle Matrixi konto sinu praeguste profiiliandmete (nimi, e-post ja avatar) pฤยตhjal.", + "chat_join_learn_more": "Loe Matrixi kohta lฤยคhemalt", "chat_join_button": "Liitu vestlusega", "chat_joining": "Konto seadistamine...", "chat_join_success": "Vestluskonto loodud! Tere tulemast.", - "chat_join_error": "Vestluse seadistamine ebaรตnnestus. Proovi uuesti.", - "chat_disconnect": "Katkesta vestlusรผhendus", + "chat_join_error": "Vestluse seadistamine ebaฤยตnnestus. Proovi uuesti.", + "chat_disconnect": "Katkesta vestlusฤยผhendus", "dept_dashboard_no_modules": "Mooduleid pole veel seadistatud", "dept_dashboard_add_first": "Lisa oma esimene moodul", "dept_dashboard_add_module": "Lisa moodul", - "dept_dashboard_all_added": "Kรตik moodulid on juba lisatud", + "dept_dashboard_all_added": "Kฤยตik moodulid on juba lisatud", "dept_dashboard_expand": "Laienda", "dept_dashboard_remove_module": "Eemalda moodul", "dept_dashboard_coming_soon": "Tulekul", "dept_dashboard_module_coming_soon": "Moodul tulekul", - "dept_dashboard_departments": "Osakonnad", + "dept_dashboard_departments": "Valdkonnad", "dept_checklist_no_items": "Kontrollnimekirju pole veel", "dept_checklist_add": "Lisa kontrollnimekiri", - "dept_checklist_add_item": "Lisa รผksus...", - "dept_notes_no_notes": "Mรคrkmeid pole veel", - "dept_notes_new": "Uus mรคrge", - "dept_notes_select": "Vali mรคrge", + "dept_checklist_add_item": "Lisa ฤยผksus...", + "dept_notes_no_notes": "Mฤยคrkmeid pole veel", + "dept_notes_new": "Uus mฤยคrge", + "dept_notes_select": "Vali mฤยคrge", "dept_notes_placeholder": "Alusta kirjutamist...", - "dept_notes_title_placeholder": "Mรคrkme pealkiri...", - "dept_kanban_open": "Ava รผlesannete tahvel", - "dept_kanban_desc": "Selle osakonna รผlesannete tahvel", + "dept_notes_title_placeholder": "Mฤยคrkme pealkiri...", + "dept_kanban_open": "Ava ฤยผlesannete tahvel", + "dept_kanban_desc": "Selle valdkonna ฤยผlesannete tahvel", "dept_files_open": "Ava failid", - "dept_files_desc": "Osakonna failid ja dokumendid", + "dept_files_desc": "Valdkonna failid ja dokumendid", "dept_quick_add": "Kiirvalik", - "dept_modules_label": "Moodulid" + "dept_modules_label": "Moodulid", + "dept_dashboard_collapse": "Ahenda", + "dept_dashboard_move_left": "Liiguta vasakule", + "dept_dashboard_move_right": "Liiguta paremale", + "dept_dashboard_layout_1col": "1 veerg", + "dept_dashboard_layout_2col": "2 veergu", + "dept_dashboard_layout_3col": "3 veergu", + "dept_dashboard_layout_grid": "2ฤโ2 ruudustik", + "dept_module_kanban": "Kanban", + "dept_module_files": "Failid", + "dept_module_checklist": "Kontrollnimekiri", + "dept_module_notes": "Mฤยคrkmed", + "dept_module_schedule": "Ajakava", + "dept_module_contacts": "Kontaktid", + "dept_module_budget": "Eelarve", + "dept_module_sponsors": "Sponsorid", + "dept_module_map": "Kaart", + "toast_error_update_layout": "Paigutuse uuendamine ebaฤยตnnestus", + "toast_error_add_module": "Mooduli lisamine ebaฤยตnnestus", + "toast_error_remove_module": "Mooduli eemaldamine ebaฤยตnnestus", + "toast_error_reorder_modules": "Moodulite ฤยผmberjฤยคrjestamine ebaฤยตnnestus", + "toast_error_add_item": "ฤยksuse lisamine ebaฤยตnnestus", + "toast_error_update_item": "ฤยksuse uuendamine ebaฤยตnnestus", + "toast_error_delete_item": "ฤยksuse kustutamine ebaฤยตnnestus", + "toast_error_create_checklist": "Kontrollnimekirja loomine ebaฤยตnnestus", + "toast_error_delete_checklist": "Kontrollnimekirja kustutamine ebaฤยตnnestus", + "toast_error_rename_checklist": "Kontrollnimekirja ฤยผmbernimetamine ebaฤยตnnestus", + "toast_error_create_note": "Mฤยคrkme loomine ebaฤยตnnestus", + "toast_error_update_note": "Mฤยคrkme uuendamine ebaฤยตnnestus", + "toast_error_delete_note": "Mฤยคrkme kustutamine ebaฤยตnnestus", + "toast_error_create_stage": "Lava loomine ebaฤยตnnestus", + "toast_error_delete_stage": "Lava kustutamine ebaฤยตnnestus", + "toast_error_create_block": "Ajakavaploki loomine ebaฤยตnnestus", + "toast_error_update_block": "Ajakavaploki uuendamine ebaฤยตnnestus", + "toast_error_delete_block": "Ajakavaploki kustutamine ebaฤยตnnestus", + "toast_error_create_contact": "Kontakti loomine ebaฤยตnnestus", + "toast_error_update_contact": "Kontakti uuendamine ebaฤยตnnestus", + "toast_error_delete_contact": "Kontakti kustutamine ebaฤยตnnestus", + "toast_error_create_category": "Kategooria loomine ebaฤยตnnestus", + "toast_error_delete_category": "Kategooria kustutamine ebaฤยตnnestus", + "toast_error_create_budget_item": "Eelarveฤยผksuse loomine ebaฤยตnnestus", + "toast_error_update_budget_item": "Eelarveฤยผksuse uuendamine ebaฤยตnnestus", + "toast_error_delete_budget_item": "Eelarveฤยผksuse kustutamine ebaฤยตnnestus", + "toast_error_upload_receipt": "Kviitungi ฤยผleslaadimine ebaฤยตnnestus", + "toast_success_receipt_attached": "Kviitung \"{name}\" lisatud", + "toast_error_create_tier": "Taseme loomine ebaฤยตnnestus", + "toast_error_delete_tier": "Taseme kustutamine ebaฤยตnnestus", + "toast_error_create_sponsor": "Sponsori loomine ebaฤยตnnestus", + "toast_error_update_sponsor": "Sponsori uuendamine ebaฤยตnnestus", + "toast_error_delete_sponsor": "Sponsori kustutamine ebaฤยตnnestus", + "toast_error_create_deliverable": "Kohustuse loomine ebaฤยตnnestus", + "toast_error_update_deliverable": "Kohustuse uuendamine ebaฤยตnnestus", + "toast_error_delete_deliverable": "Kohustuse kustutamine ebaฤยตnnestus", + "checklist_rename": "Nimeta ฤยผmber", + "checklist_delete": "Kustuta nimekiri", + "checklist_add_item_placeholder": "Lisa ฤยผksus...", + "checklist_name_placeholder": "Nimekirja nimi...", + "checklist_no_items": "Kontrollnimekirju pole veel", + "checklist_add": "Lisa kontrollnimekiri", + "notes_new": "Uus mฤยคrge", + "notes_title_placeholder": "Mฤยคrkme pealkiri...", + "notes_placeholder": "Alusta kirjutamist...", + "notes_no_notes": "Mฤยคrkmeid pole veel", + "notes_select": "Vali mฤยคrge", + "notes_export_document": "Ekspordi dokumendina", + "notes_delete": "Kustuta mฤยคrge", + "notes_exported": "Eksporditud \"{title}\" dokumendina", + "notes_export_error": "Mฤยคrkme eksportimine ebaฤยตnnestus", + "schedule_timeline": "Ajajoon", + "schedule_list": "Nimekiri", + "schedule_add_block": "Lisa plokk", + "schedule_manage_stages": "Halda lavasid", + "schedule_no_blocks": "Ajakavaplokke pole veel", + "schedule_add_first": "Lisa oma esimene plokk ajakava koostamise alustamiseks.", + "schedule_all_day": "Terve pฤยคev", + "schedule_no_stage": "Lava puudub", + "schedule_add_stage_title": "Lisa lava / ruum", + "schedule_stage_name_placeholder": "nt Peamine lava", + "schedule_add_stage": "Lisa lava", + "schedule_existing_stages": "Olemasolevad lavad", + "schedule_no_stages": "Lavasid pole veel", + "schedule_add_block_title": "Lisa ajakavaplokk", + "schedule_edit_block_title": "Muuda ajakavaplokki", + "schedule_block_title_label": "Pealkiri", + "schedule_block_title_placeholder": "nt Avamine", + "schedule_block_speaker_label": "Esineja / Juht", + "schedule_block_speaker_placeholder": "nt Jaan Tamm", + "schedule_block_start_label": "Algusaeg", + "schedule_block_end_label": "Lฤยตpuaeg", + "schedule_block_stage_label": "Lava / Ruum", + "schedule_block_no_stage": "Lava puudub", + "schedule_block_description_label": "Kirjeldus", + "schedule_block_description_placeholder": "Valikuline kirjeldus...", + "schedule_block_color_label": "Vฤยคrv", + "schedule_block_delete": "Kustuta plokk", + "contacts_search_placeholder": "Otsi kontakte...", + "contacts_add": "Lisa kontakt", + "contacts_no_contacts": "Kontakte pole veel", + "contacts_add_first": "Lisa oma esimene kontakt kataloogi loomiseks.", + "contacts_no_results": "Otsingutulemusi ei leitud", + "contacts_category_all": "Kฤยตik", + "contacts_category_venue": "Toimumiskoht", + "contacts_category_catering": "Toitlustus", + "contacts_category_av": "AV / Tehnika", + "contacts_category_security": "Turvalisus", + "contacts_category_transport": "Transport", + "contacts_category_entertainment": "Meelelahutus", + "contacts_category_media": "Meedia", + "contacts_category_sponsor": "Sponsor", + "contacts_category_other": "Muu", + "contacts_add_title": "Lisa kontakt", + "contacts_edit_title": "Muuda kontakti", + "contacts_name_label": "Nimi", + "contacts_name_placeholder": "Kontakti nimi", + "contacts_role_label": "Roll / Ametinimetus", + "contacts_role_placeholder": "nt ฤยrituse juht", + "contacts_company_label": "Ettevฤยตte", + "contacts_company_placeholder": "Ettevฤยตtte nimi", + "contacts_email_label": "E-post", + "contacts_email_placeholder": "email@nฤยคide.ee", + "contacts_phone_label": "Telefon", + "contacts_phone_placeholder": "+372 ...", + "contacts_website_label": "Veebileht", + "contacts_website_placeholder": "https://...", + "contacts_category_label": "Kategooria", + "contacts_notes_label": "Mฤยคrkmed", + "contacts_notes_placeholder": "Valikulised mฤยคrkmed...", + "contacts_delete_confirm": "Kustuta see kontakt?", + "budget_income": "Tulud", + "budget_expenses": "Kulud", + "budget_planned": "Planeeritud: {amount}", + "budget_planned_balance": "Planeeritud saldo", + "budget_actual_balance": "Tegelik saldo", + "budget_view_all": "Kฤยตik", + "budget_view_income": "Tulud", + "budget_view_expenses": "Kulud", + "budget_add_category": "Kategooria", + "budget_add_item": "Lisa ฤยผksus", + "budget_no_items": "Eelarveฤยผksusi pole veel", + "budget_col_type": "Tฤยผฤยผp", + "budget_col_description": "Kirjeldus", + "budget_col_category": "Kategooria", + "budget_col_planned": "Planeeritud", + "budget_col_actual": "Tegelik", + "budget_col_diff": "Vahe", + "budget_col_receipt": "Kviitung", + "budget_uncategorized": "Kategoriseerimata", + "budget_total": "Kokku", + "budget_missing_receipt": "Arve/kviitung puudub - kliki ฤยผleslaadimiseks", + "budget_missing_receipt_short": "Arve/kviitung puudub", + "budget_receipt_attached": "Kviitung lisatud", + "budget_add_item_title": "Lisa eelarveฤยผksus", + "budget_edit_item_title": "Muuda eelarveฤยผksust", + "budget_description_label": "Kirjeldus", + "budget_description_placeholder": "nt Ruumi rent", + "budget_type_label": "Tฤยผฤยผp", + "budget_type_expense": "Kulu", + "budget_type_income": "Tulu", + "budget_category_label": "Kategooria", + "budget_planned_amount_label": "Planeeritud summa", + "budget_actual_amount_label": "Tegelik summa", + "budget_notes_label": "Mฤยคrkmed", + "budget_notes_placeholder": "Valikulised mฤยคrkmed...", + "budget_add_category_title": "Lisa kategooria", + "budget_category_name_label": "Nimi", + "budget_category_name_placeholder": "nt Toimumiskoht", + "budget_category_color_label": "Vฤยคrv", + "budget_select_color": "Vali vฤยคrv {color}", + "budget_existing_categories": "Olemasolevad kategooriad", + "sponsors_search_placeholder": "Otsi sponsoreid...", + "sponsors_add_tier": "Lisa tase", + "sponsors_add_sponsor": "Lisa sponsor", + "sponsors_no_sponsors": "Sponsoreid pole veel", + "sponsors_add_first": "Lisa sponsoritasemed ja alusta sponsorite jฤยคlgimist.", + "sponsors_no_results": "Filtritele vastavaid sponsoreid ei leitud", + "sponsors_filter_all_statuses": "Kฤยตik staatused", + "sponsors_filter_all_tiers": "Kฤยตik tasemed", + "sponsors_status_prospect": "Potentsiaalne", + "sponsors_status_contacted": "Kontakteeritud", + "sponsors_status_confirmed": "Kinnitatud", + "sponsors_status_declined": "Keeldunud", + "sponsors_status_active": "Aktiivne", + "sponsors_contact_label": "Kontakt", + "sponsors_amount_label": "Summa", + "sponsors_deliverables_label": "Kohustused", + "sponsors_deliverable_placeholder": "Lisa kohustus...", + "sponsors_no_deliverables": "Kohustusi pole veel", + "sponsors_notes_label": "Mฤยคrkmed", + "sponsors_add_tier_title": "Lisa sponsoritase", + "sponsors_tier_name_label": "Taseme nimi", + "sponsors_tier_name_placeholder": "nt Kuld", + "sponsors_tier_amount_label": "Minimaalne summa", + "sponsors_tier_color_label": "Vฤยคrv", + "sponsors_existing_tiers": "Olemasolevad tasemed", + "sponsors_no_tiers": "Tasemeid pole veel", + "sponsors_add_sponsor_title": "Lisa sponsor", + "sponsors_edit_sponsor_title": "Muuda sponsorit", + "sponsors_name_label": "Sponsori nimi", + "sponsors_name_placeholder": "Ettevฤยตtte nimi", + "sponsors_tier_label": "Tase", + "sponsors_no_tier": "Tase puudub", + "sponsors_status_label": "Staatus", + "sponsors_sponsor_amount_label": "Summa (ฤโยฌ)", + "sponsors_sponsor_amount_placeholder": "0", + "sponsors_contact_name_label": "Kontaktisiku nimi", + "sponsors_contact_name_placeholder": "Kontaktisik", + "sponsors_contact_email_label": "Kontakti e-post", + "sponsors_contact_email_placeholder": "email@nฤยคide.ee", + "sponsors_contact_phone_label": "Kontakti telefon", + "sponsors_contact_phone_placeholder": "+372 ...", + "sponsors_website_label": "Veebileht", + "sponsors_website_placeholder": "https://...", + "sponsors_notes_placeholder": "Valikulised mฤยคrkmed...", + "sponsors_delete_confirm": "Kustuta see sponsor?", + "sponsors_select_color": "Vali vฤยคrv {color}", + "files_widget_loading": "Failide laadimine...", + "files_widget_no_folder": "Valdkonna kausta pole veel loodud", + "files_widget_empty": "Faile pole veel", + "files_widget_full_view": "Tฤยคisvaade", + "files_widget_create_title": "Loo uus", + "files_widget_type_document": "Dokument", + "files_widget_type_folder": "Kaust", + "files_widget_type_kanban": "Kanban tahvel", + "files_widget_name_label": "Nimi", + "files_widget_doc_placeholder": "Dokumendi nimi", + "files_widget_folder_placeholder": "Kausta nimi", + "files_widget_kanban_placeholder": "Tahvli nimi", + "files_widget_drop_files": "Lohista failid ฤยผleslaadimiseks", + "files_widget_uploading": "ฤยleslaadimine {name}...", + "kanban_widget_loading": "Tahvlite laadimine...", + "kanban_widget_no_folder": "Valdkonna kausta pole veel loodud", + "kanban_widget_no_boards": "Kanban tahvleid pole veel", + "kanban_widget_create": "Loo tahvel", + "kanban_widget_create_title": "Loo Kanban tahvel", + "kanban_widget_name_label": "Tahvli nimi", + "kanban_widget_name_placeholder": "nt ฤยlesannete jฤยคlgija", + "finances_title": "ฤยrituse rahandus", + "finances_subtitle": "Eelarve ฤยผlevaade kฤยตigi valdkondade lฤยตikes", + "finances_total_income": "Tulud kokku", + "finances_total_expenses": "Kulud kokku", + "finances_net_balance": "Netosaldo", + "finances_missing_receipts": "Puuduvad kviitungid", + "finances_items_without_receipts": "{count} ฤยผksust ilma kviitungita", + "finances_planned": "planeeritud", + "finances_view_by_dept": "Valdkondade jฤยคrgi", + "finances_view_by_category": "Kategooriate jฤยคrgi", + "finances_filter_all": "Kฤยตik", + "finances_filter_income": "Tulud", + "finances_filter_expenses": "Kulud", + "finances_no_items": "Eelarveฤยผksusi pole veel", + "finances_no_items_desc": "Eelarveฤยผksused ilmuvad siia, kui valdkonnad neid lisavad.", + "finances_col_description": "Kirjeldus", + "finances_col_department": "Valdkond", + "finances_col_category": "Kategooria", + "finances_col_planned": "Planeeritud", + "finances_col_actual": "Tegelik", + "finances_col_diff": "Vahe", + "finances_col_receipt": "Kviitung", + "finances_uncategorized": "Kategoriseerimata", + "finances_no_department": "Valdkond puudub", + "finances_group_total": "Vahesumma", + "btn_creating": "Loomine...", + "page_chat": "Vestlus", + "page_department": "Valdkond", + "page_invite": "Kutse", + "map_tool_grab": "Liiguta", + "map_tool_select": "Vali", + "map_tool_pin": "Marker", + "map_tool_polygon": "Hulknurk", + "map_tool_rectangle": "Ristkรผlik", + "map_add_layer": "Lisa kaardikiht", + "map_add_image_layer": "Lisa pildikaart", + "map_layer_name": "Kihi nimi", + "map_street_map": "Tรคnavakaart", + "map_custom_image": "Kohandatud pilt", + "map_osm_tiles": "OpenStreetMap kaardikihid", + "map_upload_venue": "Laadi รผles koha kaart", + "map_choose_image": "Vali pildifail", + "map_uploading": "รleslaadimine...", + "map_image_url": "Pildi URL", + "map_image_desc": "Laadi รผles koha plaan vรตi kaardipilt. Kuvasuhe sรคilitatakse.", + "map_objects": "Objektid", + "map_no_objects": "Objekte pole veel", + "map_export_png": "Ekspordi PNG", + "map_rename_layer": "Nimeta kiht รผmber", + "map_edit_pin": "Muuda markerit", + "map_add_pin": "Lisa marker", + "map_edit_shape": "Muuda kujundit", + "map_label": "Silt", + "map_description": "Kirjeldus", + "map_color": "Vรคrv", + "map_pen_hint_points": "{count} punkt(i)", + "map_pen_hint_close": "kliki esimese punkti lรคhedal sulgemiseks vรตi vajuta Esc tรผhistamiseks" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 36a5ad8..423dae6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "root-org", "version": "0.0.1", "dependencies": { - "@inlang/paraglide-js": "^2.10.0", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.94.0", "@tanstack/svelte-virtual": "^3.13.18", @@ -16,8 +15,10 @@ "@tiptap/extension-placeholder": "^3.19.0", "@tiptap/pm": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", + "dom-to-image-more": "^3.7.2", "google-auth-library": "^10.5.0", "highlight.js": "^11.11.1", + "leaflet": "^1.9.4", "marked": "^17.0.1", "matrix-js-sdk": "^40.2.0-rc.0", "twemoji": "^14.0.2" @@ -31,9 +32,11 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", + "@types/leaflet": "^1.9.21", "@types/marked": "^5.0.2", "@types/twemoji": "^13.1.1", "@vitest/browser-playwright": "^4.0.18", + "@vitest/coverage-v8": "^4.0.18", "playwright": "^1.58.0", "supabase": "^2.76.1", "svelte": "^5.48.2", @@ -45,6 +48,42 @@ "vitest-browser-svelte": "^2.0.2" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -54,6 +93,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -2154,6 +2217,23 @@ "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -2230,6 +2310,7 @@ "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/mocker": "4.0.18", "@vitest/utils": "4.0.18", @@ -2272,6 +2353,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -2477,6 +2589,28 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2809,6 +2943,12 @@ "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "license": "MIT" }, + "node_modules/dom-to-image-more": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/dom-to-image-more/-/dom-to-image-more-3.7.2.tgz", + "integrity": "sha512-uQf+pHv6eQhgfI8t2bFuinV0KsPyT8TZgCLwcSU8uBVgN9v6leb0mMpvp6HQAlAcplP3NCcGjxbdqef6pTzvmw==", + "license": "MIT" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3191,6 +3331,16 @@ "node": ">=18" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3213,6 +3363,13 @@ "node": ">=12.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -3315,6 +3472,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -3347,6 +3543,13 @@ "dev": true, "license": "MIT" }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -3432,6 +3635,12 @@ "node": ">=14.0.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -3742,6 +3951,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-it": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", @@ -4537,6 +4774,19 @@ "sdp-verify": "checker.js" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", @@ -4751,6 +5001,19 @@ "npm": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", diff --git a/package.json b/package.json index 772c634..1de4ea1 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", + "@types/leaflet": "^1.9.21", "@types/marked": "^5.0.2", "@types/twemoji": "^13.1.1", "@vitest/browser-playwright": "^4.0.18", + "@vitest/coverage-v8": "^4.0.18", "playwright": "^1.58.0", "supabase": "^2.76.1", "svelte": "^5.48.2", @@ -46,10 +48,12 @@ "@tiptap/extension-placeholder": "^3.19.0", "@tiptap/pm": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", + "dom-to-image-more": "^3.7.2", "google-auth-library": "^10.5.0", "highlight.js": "^11.11.1", + "leaflet": "^1.9.4", "marked": "^17.0.1", "matrix-js-sdk": "^40.2.0-rc.0", "twemoji": "^14.0.2" } -} \ No newline at end of file +} diff --git a/src/lib/api/activity.test.ts b/src/lib/api/activity.test.ts new file mode 100644 index 0000000..2af46b9 --- /dev/null +++ b/src/lib/api/activity.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from 'vitest'; +import { logActivity } from './activity'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'insert']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('logActivity', () => { + it('inserts activity log entry', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect( + logActivity(sb, { + orgId: 'org1', + userId: 'user1', + action: 'create', + entityType: 'document', + entityId: 'doc1', + entityName: 'Test Doc', + }) + ).resolves.toBeUndefined(); + expect(sb.from).toHaveBeenCalledWith('activity_log'); + }); + + it('does not throw on error (fire-and-forget)', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + // logActivity should warn but not throw + await expect( + logActivity(sb, { + orgId: 'org1', + userId: 'user1', + action: 'delete', + entityType: 'folder', + }) + ).resolves.toBeUndefined(); + }); + + it('passes metadata as JSON', async () => { + const sb = mockSupabase({ data: null, error: null }); + await logActivity(sb, { + orgId: 'org1', + userId: 'user1', + action: 'move', + entityType: 'kanban_card', + metadata: { from: 'col1', to: 'col2' }, + }); + expect(sb.from).toHaveBeenCalledWith('activity_log'); + }); +}); diff --git a/src/lib/api/activity.ts b/src/lib/api/activity.ts index 0f46ac2..c3f9a90 100644 --- a/src/lib/api/activity.ts +++ b/src/lib/api/activity.ts @@ -32,7 +32,7 @@ export async function logActivity( }); if (error) { - // Activity logging should never block the main action โ just warn + // Activity logging should never block the main action - just warn log.warn('Failed to log activity', { error: { message: error.message } }); } } diff --git a/src/lib/api/budget.test.ts b/src/lib/api/budget.test.ts new file mode 100644 index 0000000..e0588f0 --- /dev/null +++ b/src/lib/api/budget.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchBudgetCategories, + createBudgetCategory, + updateBudgetCategory, + deleteBudgetCategory, + fetchEventBudgetCategories, + fetchEventBudgetItems, + fetchBudgetItems, + createBudgetItem, + updateBudgetItem, + deleteBudgetItem, +} from './budget'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single', 'in']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Budget Categories โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchBudgetCategories', () => { + it('returns categories for a department', async () => { + const cats = [{ id: 'c1', name: 'Travel', department_id: 'd1' }]; + const sb = mockSupabase({ data: cats, error: null }); + const result = await fetchBudgetCategories(sb, 'd1'); + expect(result).toEqual(cats); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchBudgetCategories(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createBudgetCategory', () => { + it('creates a category with default color', async () => { + const cat = { id: 'c1', name: 'Food', department_id: 'd1', color: '#6366f1' }; + const sb = mockSupabase({ data: cat, error: null }); + const result = await createBudgetCategory(sb, 'd1', 'Food'); + expect(result).toEqual(cat); + }); + + it('creates a category with custom color', async () => { + const cat = { id: 'c2', name: 'AV', department_id: 'd1', color: '#ff0000' }; + const sb = mockSupabase({ data: cat, error: null }); + const result = await createBudgetCategory(sb, 'd1', 'AV', '#ff0000'); + expect(result).toEqual(cat); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'dup' } }); + await expect(createBudgetCategory(sb, 'd1', 'X')).rejects.toEqual({ message: 'dup' }); + }); +}); + +describe('updateBudgetCategory', () => { + it('updates and returns the category', async () => { + const cat = { id: 'c1', name: 'Updated' }; + const sb = mockSupabase({ data: cat, error: null }); + const result = await updateBudgetCategory(sb, 'c1', { name: 'Updated' }); + expect(result).toEqual(cat); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'nope' } }); + await expect(updateBudgetCategory(sb, 'c1', { name: 'X' })).rejects.toEqual({ message: 'nope' }); + }); +}); + +describe('deleteBudgetCategory', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteBudgetCategory(sb, 'c1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'del fail' } }); + await expect(deleteBudgetCategory(sb, 'c1')).rejects.toEqual({ message: 'del fail' }); + }); +}); + +describe('fetchEventBudgetCategories', () => { + it('returns categories stripped of join data', async () => { + const raw = [{ id: 'c1', name: 'Cat', event_departments: { event_id: 'e1' } }]; + const sb = mockSupabase({ data: raw, error: null }); + const result = await fetchEventBudgetCategories(sb, 'e1'); + expect(result).toEqual([{ id: 'c1', name: 'Cat' }]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEventBudgetCategories(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('fetchEventBudgetItems', () => { + it('returns items stripped of join data', async () => { + const raw = [{ id: 'i1', description: 'Item', event_departments: { event_id: 'e1' } }]; + const sb = mockSupabase({ data: raw, error: null }); + const result = await fetchEventBudgetItems(sb, 'e1'); + expect(result).toEqual([{ id: 'i1', description: 'Item' }]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEventBudgetItems(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Budget Items โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchBudgetItems', () => { + it('returns items for a department', async () => { + const items = [{ id: 'i1', description: 'Mic', department_id: 'd1' }]; + const sb = mockSupabase({ data: items, error: null }); + const result = await fetchBudgetItems(sb, 'd1'); + expect(result).toEqual(items); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchBudgetItems(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createBudgetItem', () => { + it('creates an expense item with defaults', async () => { + const item = { id: 'i1', description: 'Mic', item_type: 'expense', planned_amount: 0, actual_amount: 0 }; + const sb = mockSupabase({ data: item, error: null }); + const result = await createBudgetItem(sb, 'd1', { description: 'Mic', item_type: 'expense' }); + expect(result).toEqual(item); + }); + + it('creates an income item with amounts', async () => { + const item = { id: 'i2', description: 'Ticket', item_type: 'income', planned_amount: 100, actual_amount: 50 }; + const sb = mockSupabase({ data: item, error: null }); + const result = await createBudgetItem(sb, 'd1', { + description: 'Ticket', + item_type: 'income', + planned_amount: 100, + actual_amount: 50, + }); + expect(result).toEqual(item); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createBudgetItem(sb, 'd1', { description: 'X', item_type: 'expense' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateBudgetItem', () => { + it('updates and returns the item', async () => { + const item = { id: 'i1', description: 'Updated' }; + const sb = mockSupabase({ data: item, error: null }); + const result = await updateBudgetItem(sb, 'i1', { description: 'Updated' }); + expect(result).toEqual(item); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateBudgetItem(sb, 'i1', { description: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteBudgetItem', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteBudgetItem(sb, 'i1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteBudgetItem(sb, 'i1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/budget.ts b/src/lib/api/budget.ts index b1897a9..3fe1777 100644 --- a/src/lib/api/budget.ts +++ b/src/lib/api/budget.ts @@ -87,6 +87,52 @@ export async function deleteBudgetCategory( } } +/** + * Fetch all budget categories for all departments in an event. + */ +export async function fetchEventBudgetCategories( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('budget_categories') + .select('*, event_departments!inner(event_id)') + .eq('event_departments.event_id', eventId) + .order('sort_order'); + + if (error) { + log.error('fetchEventBudgetCategories failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { event_departments, ...cat } = d; + return cat; + }) as BudgetCategory[]; +} + +/** + * Fetch all budget items for all departments in an event. + */ +export async function fetchEventBudgetItems( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('budget_items') + .select('*, event_departments!inner(event_id)') + .eq('event_departments.event_id', eventId) + .order('sort_order'); + + if (error) { + log.error('fetchEventBudgetItems failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { event_departments, ...item } = d; + return item; + }) as BudgetItem[]; +} + // ============================================================ // Budget Items // ============================================================ @@ -144,7 +190,7 @@ export async function createBudgetItem( export async function updateBudgetItem( supabase: SupabaseClient, itemId: string, - params: Partial> + params: Partial> ): Promise { const { data, error } = await db(supabase) .from('budget_items') diff --git a/src/lib/api/calendar.test.ts b/src/lib/api/calendar.test.ts index 30f2b83..3985ab0 100644 --- a/src/lib/api/calendar.test.ts +++ b/src/lib/api/calendar.test.ts @@ -1,5 +1,90 @@ -import { describe, it, expect } from 'vitest'; -import { getMonthDays, isSameDay, formatTime } from './calendar'; +import { describe, it, expect, vi } from 'vitest'; +import { getMonthDays, isSameDay, formatTime, fetchEvents, createEvent, updateEvent, deleteEvent } from './calendar'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'gte', 'lte', 'order', 'single', 'channel', 'on', 'subscribe']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Calendar CRUD โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('calendar fetchEvents', () => { + it('returns events for date range', async () => { + const events = [{ id: 'ce1', title: 'Meeting', org_id: 'o1' }]; + const sb = mockSupabase({ data: events, error: null }); + const result = await fetchEvents(sb, 'o1', new Date('2024-01-01'), new Date('2024-01-31')); + expect(result).toEqual(events); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchEvents(sb, 'o1', new Date(), new Date())).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEvents(sb, 'o1', new Date(), new Date())).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('calendar createEvent', () => { + it('creates and returns event', async () => { + const event = { id: 'ce1', title: 'New Meeting' }; + const sb = mockSupabase({ data: event, error: null }); + const result = await createEvent(sb, 'o1', { + title: 'New Meeting', + start_time: '2024-01-15T10:00:00Z', + end_time: '2024-01-15T11:00:00Z', + }, 'user1'); + expect(result).toEqual(event); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createEvent(sb, 'o1', { + title: 'X', + start_time: '2024-01-15T10:00:00Z', + end_time: '2024-01-15T11:00:00Z', + }, 'user1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('calendar updateEvent', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateEvent(sb, 'ce1', { title: 'Updated' })).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateEvent(sb, 'ce1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('calendar deleteEvent', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteEvent(sb, 'ce1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteEvent(sb, 'ce1')).rejects.toEqual({ message: 'fail' }); + }); +}); describe('getMonthDays', () => { it('returns exactly 42 days (6 weeks grid)', () => { diff --git a/src/lib/api/contacts.test.ts b/src/lib/api/contacts.test.ts new file mode 100644 index 0000000..a1834bc --- /dev/null +++ b/src/lib/api/contacts.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchContacts, + createContact, + updateContact, + deleteContact, + CONTACT_CATEGORIES, + CATEGORY_LABELS, + CATEGORY_ICONS, +} from './contacts'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('contacts constants', () => { + it('CONTACT_CATEGORIES has expected entries', () => { + expect(CONTACT_CATEGORIES).toContain('general'); + expect(CONTACT_CATEGORIES).toContain('vendor'); + expect(CONTACT_CATEGORIES).toContain('speaker'); + expect(CONTACT_CATEGORIES).toContain('media'); + expect(CONTACT_CATEGORIES.length).toBe(10); + }); + + it('CATEGORY_LABELS has a label for every category', () => { + for (const cat of CONTACT_CATEGORIES) { + expect(CATEGORY_LABELS[cat]).toBeDefined(); + expect(typeof CATEGORY_LABELS[cat]).toBe('string'); + } + }); + + it('CATEGORY_ICONS has an icon for every category', () => { + for (const cat of CONTACT_CATEGORIES) { + expect(CATEGORY_ICONS[cat]).toBeDefined(); + } + }); +}); + +// โโ CRUD โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchContacts', () => { + it('returns contacts for a department', async () => { + const contacts = [{ id: 'ct1', name: 'Alice', department_id: 'd1' }]; + const sb = mockSupabase({ data: contacts, error: null }); + const result = await fetchContacts(sb, 'd1'); + expect(result).toEqual(contacts); + }); + + it('returns empty array when data is null', async () => { + const sb = mockSupabase({ data: null, error: null }); + const result = await fetchContacts(sb, 'd1'); + expect(result).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchContacts(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createContact', () => { + it('creates a contact with minimal params', async () => { + const contact = { id: 'ct1', name: 'Bob', department_id: 'd1', category: 'general' }; + const sb = mockSupabase({ data: contact, error: null }); + const result = await createContact(sb, 'd1', { name: 'Bob' }); + expect(result).toEqual(contact); + }); + + it('creates a contact with all params', async () => { + const contact = { id: 'ct2', name: 'Eve', department_id: 'd1', category: 'vendor', email: 'eve@test.com' }; + const sb = mockSupabase({ data: contact, error: null }); + const result = await createContact(sb, 'd1', { + name: 'Eve', + category: 'vendor', + email: 'eve@test.com', + phone: '+1234', + company: 'Acme', + role: 'Manager', + website: 'https://acme.com', + notes: 'VIP', + }, 'user1'); + expect(result).toEqual(contact); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createContact(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateContact', () => { + it('updates and returns the contact', async () => { + const contact = { id: 'ct1', name: 'Updated' }; + const sb = mockSupabase({ data: contact, error: null }); + const result = await updateContact(sb, 'ct1', { name: 'Updated' }); + expect(result).toEqual(contact); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateContact(sb, 'ct1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteContact', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteContact(sb, 'ct1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteContact(sb, 'ct1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/department-dashboard.test.ts b/src/lib/api/department-dashboard.test.ts new file mode 100644 index 0000000..dfd8135 --- /dev/null +++ b/src/lib/api/department-dashboard.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchDashboard, + updateDashboardLayout, + addPanel, + updatePanel, + removePanel, + fetchChecklists, + createChecklist, + deleteChecklist, + renameChecklist, + addChecklistItem, + updateChecklistItem, + deleteChecklistItem, + toggleChecklistItem, + fetchNotes, + createNote, + updateNote, + deleteNote, +} from './department-dashboard'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Dashboard โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchDashboard', () => { + it('returns dashboard with sorted panels', async () => { + const dash = { + id: 'd1', + department_id: 'dept1', + panels: [ + { id: 'p2', position: 1 }, + { id: 'p1', position: 0 }, + ], + }; + const sb = mockSupabase({ data: dash, error: null }); + const result = await fetchDashboard(sb, 'dept1'); + expect(result).not.toBeNull(); + expect(result!.panels[0].id).toBe('p1'); + expect(result!.panels[1].id).toBe('p2'); + }); + + it('returns null when not found (PGRST116)', async () => { + const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } }); + const result = await fetchDashboard(sb, 'dept1'); + expect(result).toBeNull(); + }); + + it('throws on other errors', async () => { + const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } }); + await expect(fetchDashboard(sb, 'dept1')).rejects.toEqual({ code: '42000', message: 'fail' }); + }); +}); + +describe('updateDashboardLayout', () => { + it('updates and returns dashboard', async () => { + const dash = { id: 'd1', layout: 'grid' }; + const sb = mockSupabase({ data: dash, error: null }); + expect(await updateDashboardLayout(sb, 'd1', 'grid' as any)).toEqual(dash); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateDashboardLayout(sb, 'd1', 'grid' as any)).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Panels โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('addPanel', () => { + it('adds and returns panel', async () => { + const panel = { id: 'p1', dashboard_id: 'd1', module: 'checklist', position: 0 }; + const sb = mockSupabase({ data: panel, error: null }); + expect(await addPanel(sb, 'd1', 'checklist' as any, 0)).toEqual(panel); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(addPanel(sb, 'd1', 'checklist' as any, 0)).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updatePanel', () => { + it('updates and returns panel', async () => { + const panel = { id: 'p1', width: 'full' }; + const sb = mockSupabase({ data: panel, error: null }); + expect(await updatePanel(sb, 'p1', { width: 'full' } as any)).toEqual(panel); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updatePanel(sb, 'p1', { width: 'full' } as any)).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('removePanel', () => { + it('removes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(removePanel(sb, 'p1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(removePanel(sb, 'p1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Checklists โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchChecklists', () => { + it('returns checklists with items grouped', async () => { + const checklists = [{ id: 'cl1', department_id: 'dept1', title: 'Pre-event' }]; + const items = [{ id: 'i1', checklist_id: 'cl1', content: 'Book venue', is_completed: false }]; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + if (callIdx === 1) return resolve({ data: checklists, error: null }); + return resolve({ data: items, error: null }); + }; + const sb = { from: vi.fn(() => chain) } as any; + + const result = await fetchChecklists(sb, 'dept1'); + expect(result).toHaveLength(1); + expect(result[0].items).toHaveLength(1); + expect(result[0].items[0].content).toBe('Book venue'); + }); + + it('returns empty array when no checklists', async () => { + const sb = mockSupabase({ data: [], error: null }); + const result = await fetchChecklists(sb, 'dept1'); + expect(result).toEqual([]); + }); + + it('throws on error', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(fetchChecklists(sb, 'dept1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createChecklist', () => { + it('creates and returns checklist', async () => { + const cl = { id: 'cl1', title: 'Setup', department_id: 'dept1' }; + const sb = mockSupabase({ data: cl, error: null }); + expect(await createChecklist(sb, 'dept1', 'Setup', 'user1')).toEqual(cl); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createChecklist(sb, 'dept1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteChecklist', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteChecklist(sb, 'cl1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteChecklist(sb, 'cl1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('renameChecklist', () => { + it('renames and returns checklist', async () => { + const cl = { id: 'cl1', title: 'Renamed' }; + const sb = mockSupabase({ data: cl, error: null }); + expect(await renameChecklist(sb, 'cl1', 'Renamed')).toEqual(cl); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(renameChecklist(sb, 'cl1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Checklist Items โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('addChecklistItem', () => { + it('adds and returns item', async () => { + const item = { id: 'i1', checklist_id: 'cl1', content: 'Task', sort_order: 0 }; + const sb = mockSupabase({ data: item, error: null }); + expect(await addChecklistItem(sb, 'cl1', 'Task')).toEqual(item); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(addChecklistItem(sb, 'cl1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateChecklistItem', () => { + it('updates and returns item', async () => { + const item = { id: 'i1', content: 'Updated' }; + const sb = mockSupabase({ data: item, error: null }); + expect(await updateChecklistItem(sb, 'i1', { content: 'Updated' })).toEqual(item); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateChecklistItem(sb, 'i1', { content: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteChecklistItem', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteChecklistItem(sb, 'i1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteChecklistItem(sb, 'i1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('toggleChecklistItem', () => { + it('toggles completion via updateChecklistItem', async () => { + const item = { id: 'i1', is_completed: true }; + const sb = mockSupabase({ data: item, error: null }); + expect(await toggleChecklistItem(sb, 'i1', true)).toEqual(item); + }); +}); + +// โโ Notes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchNotes', () => { + it('returns notes for a department', async () => { + const notes = [{ id: 'n1', title: 'Meeting Notes', department_id: 'dept1' }]; + const sb = mockSupabase({ data: notes, error: null }); + expect(await fetchNotes(sb, 'dept1')).toEqual(notes); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchNotes(sb, 'dept1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchNotes(sb, 'dept1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createNote', () => { + it('creates and returns note', async () => { + const note = { id: 'n1', title: 'New Note', department_id: 'dept1' }; + const sb = mockSupabase({ data: note, error: null }); + expect(await createNote(sb, 'dept1', 'New Note', 'user1')).toEqual(note); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createNote(sb, 'dept1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateNote', () => { + it('updates and returns note', async () => { + const note = { id: 'n1', title: 'Updated' }; + const sb = mockSupabase({ data: note, error: null }); + expect(await updateNote(sb, 'n1', { title: 'Updated' })).toEqual(note); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateNote(sb, 'n1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteNote', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteNote(sb, 'n1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteNote(sb, 'n1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/document-locks.test.ts b/src/lib/api/document-locks.test.ts new file mode 100644 index 0000000..2b7dfbb --- /dev/null +++ b/src/lib/api/document-locks.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + getLockInfo, + acquireLock, + heartbeatLock, + releaseLock, + startHeartbeat, +} from './document-locks'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'gt', 'lt', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ getLockInfo โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('getLockInfo', () => { + it('returns unlocked when no lock exists', async () => { + const sb = mockSupabase({ data: null, error: null }); + const info = await getLockInfo(sb, 'doc1', 'user1'); + expect(info).toEqual({ + isLocked: false, + lockedBy: null, + lockedByName: null, + isOwnLock: false, + }); + }); + + it('returns locked with own lock', async () => { + // First call returns lock, second returns profile + const lockData = { id: 'l1', document_id: 'doc1', user_id: 'user1', locked_at: new Date().toISOString(), last_heartbeat: new Date().toISOString() }; + const profileData = { full_name: 'Alice', email: 'alice@test.com' }; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'gt', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callCount = 0; + chain.single = vi.fn(() => { + callCount++; + if (callCount === 1) return Promise.resolve({ data: lockData, error: null }); + return Promise.resolve({ data: profileData, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const info = await getLockInfo(sb, 'doc1', 'user1'); + expect(info.isLocked).toBe(true); + expect(info.isOwnLock).toBe(true); + expect(info.lockedByName).toBe('Alice'); + }); + + it('returns locked with other user lock', async () => { + const lockData = { id: 'l1', document_id: 'doc1', user_id: 'user2', locked_at: new Date().toISOString(), last_heartbeat: new Date().toISOString() }; + const profileData = { full_name: 'Bob', email: 'bob@test.com' }; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'gt', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callCount = 0; + chain.single = vi.fn(() => { + callCount++; + if (callCount === 1) return Promise.resolve({ data: lockData, error: null }); + return Promise.resolve({ data: profileData, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const info = await getLockInfo(sb, 'doc1', 'user1'); + expect(info.isLocked).toBe(true); + expect(info.isOwnLock).toBe(false); + expect(info.lockedByName).toBe('Bob'); + }); +}); + +// โโ acquireLock โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('acquireLock', () => { + it('returns true when lock acquired', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await acquireLock(sb, 'doc1', 'user1')).toBe(true); + }); + + it('returns false on unique constraint violation', async () => { + const sb = mockSupabase({ data: null, error: { code: '23505', message: 'unique' } }); + expect(await acquireLock(sb, 'doc1', 'user1')).toBe(false); + }); + + it('returns false on other errors', async () => { + const sb = mockSupabase({ data: null, error: { code: '42000', message: 'other' } }); + expect(await acquireLock(sb, 'doc1', 'user1')).toBe(false); + }); +}); + +// โโ heartbeatLock โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('heartbeatLock', () => { + it('returns true on success', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await heartbeatLock(sb, 'doc1', 'user1')).toBe(true); + }); + + it('returns false on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + expect(await heartbeatLock(sb, 'doc1', 'user1')).toBe(false); + }); +}); + +// โโ releaseLock โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('releaseLock', () => { + it('releases without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(releaseLock(sb, 'doc1', 'user1')).resolves.toBeUndefined(); + }); + + it('does not throw on error (just logs)', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(releaseLock(sb, 'doc1', 'user1')).resolves.toBeUndefined(); + }); +}); + +// โโ startHeartbeat โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('startHeartbeat', () => { + it('returns a cleanup function', () => { + const sb = mockSupabase({ data: null, error: null }); + const cleanup = startHeartbeat(sb, 'doc1', 'user1'); + expect(typeof cleanup).toBe('function'); + cleanup(); // should not throw + }); +}); diff --git a/src/lib/api/document-locks.ts b/src/lib/api/document-locks.ts index 5c78b7c..25a202a 100644 --- a/src/lib/api/document-locks.ts +++ b/src/lib/api/document-locks.ts @@ -36,7 +36,7 @@ export async function getLockInfo( return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false }; } - // Fetch profile separately โ document_locks.user_id FK points to auth.users, not profiles + // Fetch profile separately - document_locks.user_id FK points to auth.users, not profiles let lockedByName = 'Someone'; if (lock.user_id) { const { data: profile } = await supabase @@ -87,7 +87,7 @@ export async function acquireLock( if (error) { if (error.code === '23505') { - // Unique constraint violation โ someone else holds the lock + // Unique constraint violation - someone else holds the lock log.debug('Lock already held', { data: { documentId } }); return false; } diff --git a/src/lib/api/documents.test.ts b/src/lib/api/documents.test.ts index 49ec815..5a2084e 100644 --- a/src/lib/api/documents.test.ts +++ b/src/lib/api/documents.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments } from './documents'; +import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments, fetchFolderContents, findDepartmentFolder, findFinanceFolder, ensureEventsFolder, createEventFolder, createDepartmentFolder, ensureFinanceFolder, ensureFinanceDeptFolder, uploadFile, deleteFileFromStorage, subscribeToDocuments, getFileMetadata, formatFileSize } from './documents'; // Lightweight Supabase mock builder function mockSupabase(response: { data?: unknown; error?: unknown }) { @@ -108,7 +108,7 @@ describe('deleteDocument', () => { describe('fetchDocuments', () => { it('returns documents array on success', async () => { const docs = [fakeDoc]; - // fetchDocuments calls .from().select().eq().order().order() โ need deeper chain + // fetchDocuments calls .from().select().eq().order().order() - need deeper chain const orderFn2 = vi.fn().mockResolvedValue({ data: docs, error: null }); const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 }); const eqFn = vi.fn().mockReturnValue({ order: orderFn1 }); @@ -130,3 +130,538 @@ describe('fetchDocuments', () => { await expect(fetchDocuments(sb, 'org-1')).rejects.toEqual({ message: 'fetch failed' }); }); }); + +// โโ updateDocument โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('updateDocument', () => { + it('updates and returns document', async () => { + const updated = { ...fakeDoc, name: 'Renamed' }; + const sb = mockSupabaseSuccess(updated); + const result = await updateDocument(sb, 'doc-1', { name: 'Renamed' }); + expect(result.name).toBe('Renamed'); + }); + + it('throws on error', async () => { + const sb = mockSupabaseError('update failed'); + await expect(updateDocument(sb, 'doc-1', { name: 'X' })) + .rejects.toEqual({ message: 'update failed', code: 'ERROR' }); + }); +}); + +// โโ moveDocument โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('moveDocument', () => { + it('moves document to new parent', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(moveDocument(sb, 'doc-1', 'folder-2')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'move failed', code: 'ERROR' } }); + await expect(moveDocument(sb, 'doc-1', 'folder-2')) + .rejects.toEqual({ message: 'move failed', code: 'ERROR' }); + }); +}); + +// โโ fetchFolderContents โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchFolderContents', () => { + it('returns folder contents', async () => { + const docs = [fakeDoc]; + const orderFn2 = vi.fn().mockResolvedValue({ data: docs, error: null }); + const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 }); + const eqFn = vi.fn().mockReturnValue({ order: orderFn1 }); + const selectFn = vi.fn().mockReturnValue({ eq: eqFn }); + const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any; + + const result = await fetchFolderContents(sb, 'folder-1'); + expect(result).toEqual(docs); + }); + + it('returns empty array when null', async () => { + const orderFn2 = vi.fn().mockResolvedValue({ data: null, error: null }); + const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 }); + const eqFn = vi.fn().mockReturnValue({ order: orderFn1 }); + const selectFn = vi.fn().mockReturnValue({ eq: eqFn }); + const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any; + + expect(await fetchFolderContents(sb, 'folder-1')).toEqual([]); + }); + + it('throws on error', async () => { + const orderFn2 = vi.fn().mockResolvedValue({ data: null, error: { message: 'fail' } }); + const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 }); + const eqFn = vi.fn().mockReturnValue({ order: orderFn1 }); + const selectFn = vi.fn().mockReturnValue({ eq: eqFn }); + const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any; + + await expect(fetchFolderContents(sb, 'folder-1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ findDepartmentFolder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('findDepartmentFolder', () => { + it('returns folder when found', async () => { + const folder = { id: 'f1', name: 'Dept', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: folder, error: null })); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await findDepartmentFolder(sb, 'dept-1'); + expect(result).toEqual(folder); + }); + + it('returns null when not found', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: null, error: null })); + const sb = { from: vi.fn(() => chain) } as any; + + expect(await findDepartmentFolder(sb, 'dept-1')).toBeNull(); + }); +}); + +// โโ Pure utility functions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('getFileMetadata', () => { + it('returns metadata for file type with storage_path', () => { + const doc = { type: 'file', content: { storage_path: '/path/to/file', mime_type: 'image/png', size: 1024 } } as any; + const result = getFileMetadata(doc); + expect(result).not.toBeNull(); + expect(result!.storage_path).toBe('/path/to/file'); + }); + + it('returns null for non-file type', () => { + const doc = { type: 'folder', content: null } as any; + expect(getFileMetadata(doc)).toBeNull(); + }); + + it('returns null when content is null', () => { + const doc = { type: 'file', content: null } as any; + expect(getFileMetadata(doc)).toBeNull(); + }); + + it('returns null when no storage_path', () => { + const doc = { type: 'file', content: { mime_type: 'text/plain' } } as any; + expect(getFileMetadata(doc)).toBeNull(); + }); +}); + +describe('formatFileSize', () => { + it('formats bytes', () => { + expect(formatFileSize(500)).toBe('500 B'); + }); + + it('formats kilobytes', () => { + expect(formatFileSize(2048)).toBe('2.0 KB'); + }); + + it('formats megabytes', () => { + expect(formatFileSize(5 * 1024 * 1024)).toBe('5.0 MB'); + }); + + it('formats gigabytes', () => { + expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe('2.0 GB'); + }); +}); + +// โโ findFinanceFolder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('findFinanceFolder', () => { + it('returns finance folder when event folder and finance folder exist', async () => { + const eventFolder = { id: 'ef1' }; + const financeFolder = { id: 'ff1', name: 'Finance', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.single = vi.fn(() => { + callIdx++; + if (callIdx === 1) return Promise.resolve({ data: eventFolder, error: null }); + return Promise.resolve({ data: financeFolder, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await findFinanceFolder(sb, 'event-1'); + expect(result).toEqual(financeFolder); + }); + + it('returns null when event folder not found', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: null, error: null })); + const sb = { from: vi.fn(() => chain) } as any; + + expect(await findFinanceFolder(sb, 'event-1')).toBeNull(); + }); + + it('returns null when finance folder not found inside event folder', async () => { + const eventFolder = { id: 'ef1' }; + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.single = vi.fn(() => { + callIdx++; + if (callIdx === 1) return Promise.resolve({ data: eventFolder, error: null }); + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + expect(await findFinanceFolder(sb, 'event-1')).toBeNull(); + }); +}); + +// โโ deleteFileFromStorage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('deleteFileFromStorage', () => { + it('deletes file from storage', async () => { + const removeFn = vi.fn().mockResolvedValue({ error: null }); + const sb = { storage: { from: vi.fn().mockReturnValue({ remove: removeFn }) } } as any; + await expect(deleteFileFromStorage(sb, 'org/root/file.txt')).resolves.toBeUndefined(); + expect(removeFn).toHaveBeenCalledWith(['org/root/file.txt']); + }); + + it('throws on storage error', async () => { + const removeFn = vi.fn().mockResolvedValue({ error: { message: 'storage fail' } }); + const sb = { storage: { from: vi.fn().mockReturnValue({ remove: removeFn }) } } as any; + await expect(deleteFileFromStorage(sb, 'org/root/file.txt')).rejects.toEqual({ message: 'storage fail' }); + }); +}); + +// โโ ensureEventsFolder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('ensureEventsFolder', () => { + it('returns existing Events folder if found', async () => { + const folder = { id: 'ef1', name: 'Events', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: folder, error: null })); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureEventsFolder(sb, 'org-1', 'user-1'); + expect(result).toEqual(folder); + }); + + it('creates Events folder when not found', async () => { + const newFolder = { id: 'ef2', name: 'Events', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: null, error: null }); // not found + return Promise.resolve({ data: newFolder, error: null }); // created + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureEventsFolder(sb, 'org-1', 'user-1'); + expect(result).toEqual(newFolder); + }); +}); + +// โโ uploadFile โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('uploadFile', () => { + it('uploads file and creates document row', async () => { + const doc = { id: 'doc-1', name: 'photo.png', type: 'file' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: doc, error: null })); + + const sb = { + from: vi.fn(() => chain), + storage: { + from: vi.fn().mockReturnValue({ + upload: vi.fn().mockResolvedValue({ error: null }), + getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://example.com/photo.png' } }), + remove: vi.fn().mockResolvedValue({ error: null }), + }), + }, + } as any; + + const file = new File(['data'], 'photo.png', { type: 'image/png' }); + const result = await uploadFile(sb, 'org-1', 'folder-1', 'user-1', file); + expect(result).toEqual(doc); + }); + + it('throws on upload error', async () => { + const sb = { + from: vi.fn(), + storage: { + from: vi.fn().mockReturnValue({ + upload: vi.fn().mockResolvedValue({ error: { message: 'upload fail' } }), + }), + }, + } as any; + + const file = new File(['data'], 'test.txt', { type: 'text/plain' }); + await expect(uploadFile(sb, 'org-1', null, 'user-1', file)).rejects.toEqual({ message: 'upload fail' }); + }); + + it('cleans up storage on DB insert error', async () => { + const removeFn = vi.fn().mockResolvedValue({ error: null }); + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: null, error: { message: 'db fail' } })); + + const sb = { + from: vi.fn(() => chain), + storage: { + from: vi.fn().mockReturnValue({ + upload: vi.fn().mockResolvedValue({ error: null }), + getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://example.com/f.txt' } }), + remove: removeFn, + }), + }, + } as any; + + const file = new File(['data'], 'test.txt', { type: 'text/plain' }); + await expect(uploadFile(sb, 'org-1', null, 'user-1', file)).rejects.toEqual({ message: 'db fail' }); + expect(removeFn).toHaveBeenCalled(); + }); +}); + +// โโ createEventFolder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('createEventFolder', () => { + it('returns existing event folder if found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const eventFolder = { id: 'evf1', name: 'Conf', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); // ensureEventsFolder + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); // existing event folder + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createEventFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf'); + expect(result).toEqual(eventFolder); + }); + + it('creates event folder when not found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const newFolder = { id: 'evf2', name: 'Conf', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); // ensureEventsFolder + if (singleIdx === 2) return Promise.resolve({ data: null, error: null }); // not found + return Promise.resolve({ data: newFolder, error: null }); // created + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createEventFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf'); + expect(result).toEqual(newFolder); + }); +}); + +// โโ createDepartmentFolder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('createDepartmentFolder', () => { + it('returns existing dept folder if found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const eventFolder = { id: 'evf1', name: 'Conf' }; + const deptFolder = { id: 'df1', name: 'Logistics', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); // ensureEventsFolder + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); // createEventFolder existing + if (singleIdx === 3) return Promise.resolve({ data: deptFolder, error: null }); // existing dept folder + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createDepartmentFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Logistics'); + expect(result).toEqual(deptFolder); + }); + + it('creates dept folder when not found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const eventFolder = { id: 'evf1', name: 'Conf' }; + const newDeptFolder = { id: 'df2', name: 'Marketing', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); + if (singleIdx === 3) return Promise.resolve({ data: null, error: null }); // not found + return Promise.resolve({ data: newDeptFolder, error: null }); // created + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createDepartmentFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing'); + expect(result).toEqual(newDeptFolder); + }); +}); + +// โโ ensureFinanceFolder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('ensureFinanceFolder', () => { + it('returns existing Finance folder if found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const eventFolder = { id: 'evf1', name: 'Conf' }; + const financeFolder = { id: 'ff1', name: 'Finance', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); + if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null }); // existing + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureFinanceFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf'); + expect(result).toEqual(financeFolder); + }); + + it('creates Finance folder when not found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const eventFolder = { id: 'evf1', name: 'Conf' }; + const newFinance = { id: 'ff2', name: 'Finance', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); + if (singleIdx === 3) return Promise.resolve({ data: null, error: null }); // not found + return Promise.resolve({ data: newFinance, error: null }); // created + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureFinanceFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf'); + expect(result).toEqual(newFinance); + }); +}); + +// โโ ensureFinanceDeptFolder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('ensureFinanceDeptFolder', () => { + it('returns existing finance dept folder if found', async () => { + const eventsFolder = { id: 'ef1' }; + const eventFolder = { id: 'evf1' }; + const financeFolder = { id: 'ff1' }; + const deptFolder = { id: 'fdf1', name: 'Marketing', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); + if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null }); + if (singleIdx === 4) return Promise.resolve({ data: deptFolder, error: null }); // existing + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureFinanceDeptFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing'); + expect(result).toEqual(deptFolder); + }); + + it('creates finance dept folder when not found', async () => { + const eventsFolder = { id: 'ef1' }; + const eventFolder = { id: 'evf1' }; + const financeFolder = { id: 'ff1' }; + const newDeptFolder = { id: 'fdf2', name: 'Marketing', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); + if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null }); + if (singleIdx === 4) return Promise.resolve({ data: null, error: null }); // not found + return Promise.resolve({ data: newDeptFolder, error: null }); // created + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureFinanceDeptFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing'); + expect(result).toEqual(newDeptFolder); + }); +}); + +// โโ subscribeToDocuments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('subscribeToDocuments', () => { + it('sets up realtime subscription', () => { + const channel: any = {}; + channel.on = vi.fn(() => channel); + channel.subscribe = vi.fn(() => channel); + const sb = { channel: vi.fn(() => channel) } as any; + + const result = subscribeToDocuments(sb, 'org-1', vi.fn(), vi.fn(), vi.fn()); + expect(sb.channel).toHaveBeenCalledWith('documents:org-1'); + expect(channel.on).toHaveBeenCalledTimes(3); + expect(channel.subscribe).toHaveBeenCalledOnce(); + expect(result).toBe(channel); + }); +}); diff --git a/src/lib/api/documents.ts b/src/lib/api/documents.ts index 05dcbea..ae519ea 100644 --- a/src/lib/api/documents.ts +++ b/src/lib/api/documents.ts @@ -133,6 +133,420 @@ export async function copyDocument( return data; } +// ============================================================ +// Auto-folder helpers for Events & Departments +// ============================================================ + +/** + * Find or create the org-level "Events" root folder. + * Uses a name + null parent_id lookup first to avoid duplicates. + */ +export async function ensureEventsFolder( + supabase: SupabaseClient, + orgId: string, + userId: string +): Promise { + // Look for existing "Events" folder at root level + const { data: existing } = await supabase + .from('documents') + .select('*') + .eq('org_id', orgId) + .eq('type', 'folder') + .eq('name', 'Events') + .is('parent_id', null) + .limit(1) + .single(); + + if (existing) return existing; + + // Create it + log.info('Creating Events root folder', { data: { orgId } }); + return createDocument(supabase, orgId, 'Events', 'folder', null, userId); +} + +/** + * Create a folder for a specific event inside the "Events" root folder. + * Also stores event_id on the document row for reliable lookup. + */ +export async function createEventFolder( + supabase: SupabaseClient, + orgId: string, + userId: string, + eventId: string, + eventName: string +): Promise { + const eventsFolder = await ensureEventsFolder(supabase, orgId, userId); + + // Check if folder already exists for this event + const { data: existing } = await (supabase as any) + .from('documents') + .select('*') + .eq('org_id', orgId) + .eq('type', 'folder') + .eq('event_id', eventId) + .eq('parent_id', eventsFolder.id) + .limit(1) + .single(); + + if (existing) return existing; + + log.info('Creating event folder', { data: { orgId, eventId, eventName } }); + const { data, error } = await (supabase as any) + .from('documents') + .insert({ + org_id: orgId, + name: eventName, + type: 'folder', + parent_id: eventsFolder.id, + created_by: userId, + content: null, + event_id: eventId, + }) + .select() + .single(); + + if (error) { + log.error('createEventFolder failed', { error, data: { orgId, eventId } }); + throw error; + } + return data; +} + +/** + * Create a folder for a department inside its event folder. + * Stores department_id on the document row for reliable lookup. + */ +export async function createDepartmentFolder( + supabase: SupabaseClient, + orgId: string, + userId: string, + eventId: string, + eventName: string, + departmentId: string, + departmentName: string +): Promise { + const eventFolder = await createEventFolder(supabase, orgId, userId, eventId, eventName); + + // Check if folder already exists for this department + const { data: existing } = await (supabase as any) + .from('documents') + .select('*') + .eq('org_id', orgId) + .eq('type', 'folder') + .eq('department_id', departmentId) + .eq('parent_id', eventFolder.id) + .limit(1) + .single(); + + if (existing) return existing; + + log.info('Creating department folder', { data: { orgId, eventId, departmentId, departmentName } }); + const { data, error } = await (supabase as any) + .from('documents') + .insert({ + org_id: orgId, + name: departmentName, + type: 'folder', + parent_id: eventFolder.id, + created_by: userId, + content: null, + event_id: eventId, + department_id: departmentId, + }) + .select() + .single(); + + if (error) { + log.error('createDepartmentFolder failed', { error, data: { orgId, departmentId } }); + throw error; + } + return data; +} + +/** + * Fetch all documents inside a specific folder. + */ +export async function fetchFolderContents( + supabase: SupabaseClient, + folderId: string +): Promise { + const { data, error } = await supabase + .from('documents') + .select('*') + .eq('parent_id', folderId) + .order('type', { ascending: false }) // folders first + .order('name'); + + if (error) { + log.error('fetchFolderContents failed', { error, data: { folderId } }); + throw error; + } + return data ?? []; +} + +/** + * Find the department folder by department_id. Returns null if not found. + */ +export async function findDepartmentFolder( + supabase: SupabaseClient, + departmentId: string +): Promise { + const { data } = await (supabase as any) + .from('documents') + .select('*') + .eq('type', 'folder') + .eq('department_id', departmentId) + .limit(1) + .single(); + + return data ?? null; +} + +// ============================================================ +// Finance folder helpers +// ============================================================ + +/** + * Find or create a "Finance" folder inside the event folder. + * Used as the root for all department finance documents (invoices, receipts). + */ +export async function ensureFinanceFolder( + supabase: SupabaseClient, + orgId: string, + userId: string, + eventId: string, + eventName: string +): Promise { + const eventFolder = await createEventFolder(supabase, orgId, userId, eventId, eventName); + + // Look for existing "Finance" folder inside event folder + const { data: existing } = await supabase + .from('documents') + .select('*') + .eq('org_id', orgId) + .eq('type', 'folder') + .eq('name', 'Finance') + .eq('parent_id', eventFolder.id) + .limit(1) + .single(); + + if (existing) return existing; + + log.info('Creating Finance folder', { data: { orgId, eventId } }); + const { data, error } = await (supabase as any) + .from('documents') + .insert({ + org_id: orgId, + name: 'Finance', + type: 'folder', + parent_id: eventFolder.id, + created_by: userId, + content: null, + event_id: eventId, + }) + .select() + .single(); + + if (error) { + log.error('ensureFinanceFolder failed', { error, data: { orgId, eventId } }); + throw error; + } + return data; +} + +/** + * Find or create a department subfolder inside the Finance folder. + * e.g. Events > [Event] > Finance > [Department Name] + */ +export async function ensureFinanceDeptFolder( + supabase: SupabaseClient, + orgId: string, + userId: string, + eventId: string, + eventName: string, + departmentId: string, + departmentName: string +): Promise { + const financeFolder = await ensureFinanceFolder(supabase, orgId, userId, eventId, eventName); + + // Look for existing dept subfolder inside Finance folder + const { data: existing } = await (supabase as any) + .from('documents') + .select('*') + .eq('org_id', orgId) + .eq('type', 'folder') + .eq('department_id', departmentId) + .eq('parent_id', financeFolder.id) + .limit(1) + .single(); + + if (existing) return existing; + + log.info('Creating finance dept folder', { data: { orgId, departmentId, departmentName } }); + const { data, error } = await (supabase as any) + .from('documents') + .insert({ + org_id: orgId, + name: departmentName, + type: 'folder', + parent_id: financeFolder.id, + created_by: userId, + content: null, + event_id: eventId, + department_id: departmentId, + }) + .select() + .single(); + + if (error) { + log.error('ensureFinanceDeptFolder failed', { error, data: { orgId, departmentId } }); + throw error; + } + return data; +} + +/** + * Find the Finance folder for an event. Returns null if not found. + */ +export async function findFinanceFolder( + supabase: SupabaseClient, + eventId: string +): Promise { + // Find the event folder first + const { data: eventFolder } = await (supabase as any) + .from('documents') + .select('id') + .eq('type', 'folder') + .eq('event_id', eventId) + .is('department_id', null) + .limit(1) + .single(); + + if (!eventFolder) return null; + + const { data } = await (supabase as any) + .from('documents') + .select('*') + .eq('type', 'folder') + .eq('name', 'Finance') + .eq('parent_id', eventFolder.id) + .limit(1) + .single(); + + return data ?? null; +} + +// ============================================================ +// File Upload helpers +// ============================================================ + +export interface FileMetadata { + storage_path: string; + file_name: string; + file_size: number; + mime_type: string; + public_url: string; +} + +/** + * Upload a file to Supabase Storage and create a document row of type "file". + * Files are stored under: {orgId}/{parentId}/{timestamp}_{filename} + */ +export async function uploadFile( + supabase: SupabaseClient, + orgId: string, + parentId: string | null, + userId: string, + file: File +): Promise { + const timestamp = Date.now(); + const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const storagePath = `${orgId}/${parentId ?? 'root'}/${timestamp}_${safeName}`; + + log.info('Uploading file', { data: { orgId, fileName: file.name, size: file.size, storagePath } }); + + const { error: uploadError } = await supabase.storage + .from('files') + .upload(storagePath, file, { + cacheControl: '3600', + upsert: false, + }); + + if (uploadError) { + log.error('File upload failed', { error: uploadError, data: { storagePath } }); + throw uploadError; + } + + // Get public URL + const { data: urlData } = supabase.storage.from('files').getPublicUrl(storagePath); + + const metadata: FileMetadata = { + storage_path: storagePath, + file_name: file.name, + file_size: file.size, + mime_type: file.type || 'application/octet-stream', + public_url: urlData.publicUrl, + }; + + // Create document row with type "file" + const { data, error } = await supabase + .from('documents') + .insert({ + org_id: orgId, + name: file.name, + type: 'file', + parent_id: parentId, + created_by: userId, + content: metadata as unknown as import('$lib/supabase/types').Json, + }) + .select() + .single(); + + if (error) { + // Clean up storage on DB failure + await supabase.storage.from('files').remove([storagePath]); + log.error('File document creation failed', { error, data: { storagePath } }); + throw error; + } + + log.info('File uploaded successfully', { data: { id: data.id, name: file.name, size: file.size } }); + return data; +} + +/** + * Delete a file from Storage when its document row is deleted. + */ +export async function deleteFileFromStorage( + supabase: SupabaseClient, + storagePath: string +): Promise { + const { error } = await supabase.storage.from('files').remove([storagePath]); + if (error) { + log.error('deleteFileFromStorage failed', { error, data: { storagePath } }); + throw error; + } +} + +/** + * Get the file metadata from a document's content field. + */ +export function getFileMetadata(doc: Document): FileMetadata | null { + if (doc.type !== 'file' || !doc.content) return null; + const content = doc.content as unknown as FileMetadata; + if (!content.storage_path) return null; + return content; +} + +/** + * Format file size in human-readable form. + */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + export function subscribeToDocuments( supabase: SupabaseClient, orgId: string, diff --git a/src/lib/api/event-tasks.test.ts b/src/lib/api/event-tasks.test.ts new file mode 100644 index 0000000..217809c --- /dev/null +++ b/src/lib/api/event-tasks.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchTaskColumns, + createTaskColumn, + renameTaskColumn, + deleteTaskColumn, + createTask, + updateTask, + deleteTask, + moveTask, + subscribeToEventTasks, +} from './event-tasks'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any; count?: number }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + // For count queries + if (resolvedValue.count !== undefined) { + chain.count = resolvedValue.count; + } + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any; count?: number }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Columns โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchTaskColumns', () => { + it('returns columns with tasks grouped', async () => { + const columns = [{ id: 'c1', name: 'To Do', event_id: 'e1', position: 0 }]; + const tasks = [{ id: 't1', title: 'Task 1', event_id: 'e1', column_id: 'c1', position: 0 }]; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + if (callIdx === 1) return resolve({ data: columns, error: null }); + return resolve({ data: tasks, error: null }); + }; + const sb = { from: vi.fn(() => chain) } as any; + + const result = await fetchTaskColumns(sb, 'e1'); + expect(result).toHaveLength(1); + expect(result[0].cards).toHaveLength(1); + expect(result[0].cards[0].title).toBe('Task 1'); + }); + + it('throws when column fetch fails', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'col fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(fetchTaskColumns(sb, 'e1')).rejects.toEqual({ message: 'col fail' }); + }); +}); + +describe('createTaskColumn', () => { + it('creates column with explicit position', async () => { + const col = { id: 'c1', name: 'Done', event_id: 'e1', position: 2 }; + const sb = mockSupabase({ data: col, error: null }); + expect(await createTaskColumn(sb, 'e1', 'Done', 2)).toEqual(col); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createTaskColumn(sb, 'e1', 'X', 0)).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('renameTaskColumn', () => { + it('renames without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(renameTaskColumn(sb, 'c1', 'Renamed')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(renameTaskColumn(sb, 'c1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteTaskColumn', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteTaskColumn(sb, 'c1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteTaskColumn(sb, 'c1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Tasks โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('createTask', () => { + it('creates task', async () => { + const task = { id: 't1', title: 'New Task', event_id: 'e1', column_id: 'c1', position: 0 }; + // Need two calls: count query + insert + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + if (callIdx === 1) return resolve({ count: 0, error: null }); + return resolve({ data: task, error: null }); + }; + chain.single = vi.fn(() => { + return Promise.resolve({ data: task, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createTask(sb, 'e1', 'c1', 'New Task', 'user1'); + expect(result).toEqual(task); + }); +}); + +describe('updateTask', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateTask(sb, 't1', { title: 'Updated' })).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateTask(sb, 't1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteTask', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteTask(sb, 't1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteTask(sb, 't1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('moveTask', () => { + it('reorders tasks in target column', async () => { + const colTasks = [ + { id: 't1', position: 0 }, + { id: 't2', position: 1 }, + ]; + const chain: any = {}; + const methods = ['from', 'select', 'update', 'eq', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: colTasks, error: null }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(moveTask(sb, 't3', 'c1', 1)).resolves.toBeUndefined(); + }); + + it('throws when fetch fails', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fetch fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(moveTask(sb, 't1', 'c1', 0)).rejects.toEqual({ message: 'fetch fail' }); + }); +}); + +describe('subscribeToEventTasks', () => { + it('sets up realtime subscription and returns channel', () => { + const channel: any = {}; + channel.on = vi.fn(() => channel); + channel.subscribe = vi.fn(() => channel); + const sb = { channel: vi.fn(() => channel) } as any; + + const result = subscribeToEventTasks(sb, 'e1', ['c1'], vi.fn(), vi.fn()); + expect(sb.channel).toHaveBeenCalledWith('event-tasks:e1'); + expect(channel.on).toHaveBeenCalledTimes(2); + expect(channel.subscribe).toHaveBeenCalledOnce(); + expect(result).toBe(channel); + }); +}); diff --git a/src/lib/api/events.test.ts b/src/lib/api/events.test.ts index 7365d75..d067fda 100644 --- a/src/lib/api/events.test.ts +++ b/src/lib/api/events.test.ts @@ -1,48 +1,410 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { + fetchEvents, + fetchEvent, + fetchEventBySlug, + createEvent, + updateEvent, + deleteEvent, + fetchEventMembers, + addEventMember, + removeEventMember, + fetchEventRoles, + createEventRole, + updateEventRole, + deleteEventRole, + fetchEventDepartments, + createEventDepartment, + updateEventDepartment, + updateDepartmentPlannedBudget, + deleteEventDepartment, + assignMemberDepartment, + unassignMemberDepartment, +} from './events'; -// Test the slugify logic (extracted inline since it's not exported) -function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^\w\s-]/g, '') - .replace(/[\s_]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 60) || 'event'; +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'upsert', 'update', 'delete', 'eq', 'in', 'like', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; } -describe('events API - slugify', () => { - it('converts simple name to slug', () => { - expect(slugify('Summer Conference')).toBe('summer-conference'); +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ fetchEvents โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchEvents', () => { + it('returns events with member counts', async () => { + const raw = [{ id: 'e1', name: 'Conf', event_members: [{ count: 5 }] }]; + const sb = mockSupabase({ data: raw, error: null }); + const result = await fetchEvents(sb, 'o1'); + expect(result).toHaveLength(1); + expect(result[0].member_count).toBe(5); }); - it('handles special characters', () => { - expect(slugify('Music & Arts Festival 2026!')).toBe('music-arts-festival-2026'); + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchEvents(sb, 'o1')).toEqual([]); }); - it('collapses multiple spaces and dashes', () => { - expect(slugify('My Big Event')).toBe('my-big-event'); - }); - - it('trims leading/trailing dashes', () => { - expect(slugify('--hello--')).toBe('hello'); - }); - - it('truncates to 60 characters', () => { - const longName = 'A'.repeat(100); - expect(slugify(longName).length).toBeLessThanOrEqual(60); - }); - - it('returns "event" for empty string', () => { - expect(slugify('')).toBe('event'); - }); - - it('handles unicode characters', () => { - const result = slugify('รrituse Korraldamine'); - expect(result).toBe('rituse-korraldamine'); - }); - - it('handles numbers', () => { - expect(slugify('Event 2026 Q1')).toBe('event-2026-q1'); + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEvents(sb, 'o1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ fetchEvent โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchEvent', () => { + it('returns event by id', async () => { + const event = { id: 'e1', name: 'Conf' }; + const sb = mockSupabase({ data: event, error: null }); + expect(await fetchEvent(sb, 'e1')).toEqual(event); + }); + + it('returns null when not found', async () => { + const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } }); + expect(await fetchEvent(sb, 'e1')).toBeNull(); + }); + + it('throws on other errors', async () => { + const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } }); + await expect(fetchEvent(sb, 'e1')).rejects.toEqual({ code: '42000', message: 'fail' }); + }); +}); + +// โโ fetchEventBySlug โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchEventBySlug', () => { + it('returns event by slug', async () => { + const event = { id: 'e1', slug: 'conf' }; + const sb = mockSupabase({ data: event, error: null }); + expect(await fetchEventBySlug(sb, 'o1', 'conf')).toEqual(event); + }); + + it('returns null when not found', async () => { + const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } }); + expect(await fetchEventBySlug(sb, 'o1', 'nope')).toBeNull(); + }); + + it('throws on other errors', async () => { + const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } }); + await expect(fetchEventBySlug(sb, 'o1', 'x')).rejects.toEqual({ code: '42000', message: 'fail' }); + }); +}); + +// โโ createEvent โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('createEvent', () => { + it('creates event with unique slug', async () => { + const event = { id: 'e1', name: 'Conf', slug: 'conf' }; + // Two calls: slug check + insert + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'like', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + if (callIdx === 1) return resolve({ data: [], error: null }); // no existing slugs + return resolve({ data: event, error: null }); + }; + chain.single = vi.fn(() => Promise.resolve({ data: event, error: null })); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createEvent(sb, 'o1', 'u1', { name: 'Conf' }); + expect(result).toEqual(event); + }); + + it('throws on insert error', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'like', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + if (callIdx === 1) return resolve({ data: [], error: null }); + return resolve({ data: null, error: { message: 'fail' } }); + }; + chain.single = vi.fn(() => Promise.resolve({ data: null, error: { message: 'fail' } })); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(createEvent(sb, 'o1', 'u1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ updateEvent โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('updateEvent', () => { + it('updates and returns event', async () => { + const event = { id: 'e1', name: 'Updated' }; + const sb = mockSupabase({ data: event, error: null }); + expect(await updateEvent(sb, 'e1', { name: 'Updated' })).toEqual(event); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateEvent(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ deleteEvent โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('deleteEvent', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteEvent(sb, 'e1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteEvent(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ fetchEventMembers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchEventMembers', () => { + it('returns empty array when no members', async () => { + const sb = mockSupabase({ data: [], error: null }); + expect(await fetchEventMembers(sb, 'e1')).toEqual([]); + }); + + it('returns members with profiles, roles, and departments', async () => { + const members = [{ id: 'm1', event_id: 'e1', user_id: 'u1', role_id: 'r1' }]; + const profiles = [{ id: 'u1', email: 'a@b.com', full_name: 'Alice' }]; + const roles = [{ id: 'r1', event_id: 'e1', name: 'Lead' }]; + const memberDepts = [{ event_member_id: 'm1', department_id: 'd1' }]; + const departments = [{ id: 'd1', event_id: 'e1', name: 'Logistics' }]; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + switch (callIdx) { + case 1: return resolve({ data: members, error: null }); // members + case 2: return resolve({ data: profiles, error: null }); // profiles + case 3: return resolve({ data: roles, error: null }); // roles + case 4: return resolve({ data: memberDepts, error: null }); // member-depts + case 5: return resolve({ data: departments, error: null }); // departments + default: return resolve({ data: [], error: null }); + } + }; + const sb = { from: vi.fn(() => chain) } as any; + + const result = await fetchEventMembers(sb, 'e1'); + expect(result).toHaveLength(1); + expect(result[0].profile?.full_name).toBe('Alice'); + expect(result[0].event_role?.name).toBe('Lead'); + expect(result[0].departments).toHaveLength(1); + expect(result[0].departments[0].name).toBe('Logistics'); + }); + + it('throws on error', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + await expect(fetchEventMembers(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ addEventMember โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('addEventMember', () => { + it('adds member', async () => { + const member = { id: 'm1', event_id: 'e1', user_id: 'u1', role: 'member' }; + const sb = mockSupabase({ data: member, error: null }); + expect(await addEventMember(sb, 'e1', 'u1')).toEqual(member); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(addEventMember(sb, 'e1', 'u1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ removeEventMember โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('removeEventMember', () => { + it('removes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(removeEventMember(sb, 'e1', 'u1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(removeEventMember(sb, 'e1', 'u1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Event Roles โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchEventRoles', () => { + it('returns roles', async () => { + const roles = [{ id: 'r1', name: 'Lead' }]; + const sb = mockSupabase({ data: roles, error: null }); + expect(await fetchEventRoles(sb, 'e1')).toEqual(roles); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEventRoles(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createEventRole', () => { + it('creates role', async () => { + const role = { id: 'r1', name: 'Lead', color: '#6366f1' }; + const sb = mockSupabase({ data: role, error: null }); + expect(await createEventRole(sb, 'e1', { name: 'Lead' })).toEqual(role); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createEventRole(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateEventRole', () => { + it('updates role', async () => { + const role = { id: 'r1', name: 'Updated' }; + const sb = mockSupabase({ data: role, error: null }); + expect(await updateEventRole(sb, 'r1', { name: 'Updated' })).toEqual(role); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateEventRole(sb, 'r1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteEventRole', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteEventRole(sb, 'r1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteEventRole(sb, 'r1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Event Departments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchEventDepartments', () => { + it('returns departments', async () => { + const depts = [{ id: 'd1', name: 'Logistics' }]; + const sb = mockSupabase({ data: depts, error: null }); + expect(await fetchEventDepartments(sb, 'e1')).toEqual(depts); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchEventDepartments(sb, 'e1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEventDepartments(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createEventDepartment', () => { + it('creates department', async () => { + const dept = { id: 'd1', name: 'Marketing', color: '#00A3E0' }; + const sb = mockSupabase({ data: dept, error: null }); + expect(await createEventDepartment(sb, 'e1', { name: 'Marketing' })).toEqual(dept); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createEventDepartment(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateEventDepartment', () => { + it('updates department', async () => { + const dept = { id: 'd1', name: 'Updated' }; + const sb = mockSupabase({ data: dept, error: null }); + expect(await updateEventDepartment(sb, 'd1', { name: 'Updated' })).toEqual(dept); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateEventDepartment(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateDepartmentPlannedBudget', () => { + it('updates planned budget', async () => { + const dept = { id: 'd1', planned_budget: 5000 }; + const sb = mockSupabase({ data: dept, error: null }); + expect(await updateDepartmentPlannedBudget(sb, 'd1', 5000)).toEqual(dept); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateDepartmentPlannedBudget(sb, 'd1', 0)).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteEventDepartment', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteEventDepartment(sb, 'd1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteEventDepartment(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Member-Department Assignments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('assignMemberDepartment', () => { + it('assigns member to department', async () => { + const md = { id: 'md1', event_member_id: 'm1', department_id: 'd1' }; + const sb = mockSupabase({ data: md, error: null }); + expect(await assignMemberDepartment(sb, 'm1', 'd1')).toEqual(md); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(assignMemberDepartment(sb, 'm1', 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('unassignMemberDepartment', () => { + it('unassigns without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(unassignMemberDepartment(sb, 'm1', 'd1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(unassignMemberDepartment(sb, 'm1', 'd1')).rejects.toEqual({ message: 'fail' }); }); }); diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index 11f903b..2314759 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -48,6 +48,7 @@ export interface EventDepartment { name: string; color: string; description: string | null; + planned_budget: number; sort_order: number; created_at: string | null; } @@ -492,6 +493,25 @@ export async function updateEventDepartment( return data as unknown as EventDepartment; } +export async function updateDepartmentPlannedBudget( + supabase: SupabaseClient, + deptId: string, + plannedBudget: number +): Promise { + const { data, error } = await (supabase as any) + .from('event_departments') + .update({ planned_budget: plannedBudget }) + .eq('id', deptId) + .select() + .single(); + + if (error) { + log.error('updateDepartmentPlannedBudget failed', { error, data: { deptId, plannedBudget } }); + throw error; + } + return data as unknown as EventDepartment; +} + export async function deleteEventDepartment( supabase: SupabaseClient, deptId: string diff --git a/src/lib/api/google-calendar-push.test.ts b/src/lib/api/google-calendar-push.test.ts new file mode 100644 index 0000000..0aeebd0 --- /dev/null +++ b/src/lib/api/google-calendar-push.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { getServiceAccountEmail } from './google-calendar-push'; + +// โโ getServiceAccountEmail (pure function, no network) โโโโโโโโโโโโโโโโโโโโโโโ + +describe('getServiceAccountEmail', () => { + it('extracts email from valid JSON key', () => { + const key = JSON.stringify({ + client_email: 'test@project.iam.gserviceaccount.com', + private_key: 'fake-key', + }); + expect(getServiceAccountEmail(key)).toBe('test@project.iam.gserviceaccount.com'); + }); + + it('extracts email from base64-encoded JSON key', () => { + const json = JSON.stringify({ + client_email: 'b64@project.iam.gserviceaccount.com', + private_key: 'fake-key', + }); + const b64 = Buffer.from(json).toString('base64'); + expect(getServiceAccountEmail(b64)).toBe('b64@project.iam.gserviceaccount.com'); + }); + + it('returns null for invalid key', () => { + expect(getServiceAccountEmail('not-json-or-base64')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(getServiceAccountEmail('')).toBeNull(); + }); +}); diff --git a/src/lib/api/google-calendar-push.ts b/src/lib/api/google-calendar-push.ts index b825a6f..224e091 100644 --- a/src/lib/api/google-calendar-push.ts +++ b/src/lib/api/google-calendar-push.ts @@ -75,7 +75,7 @@ export function getServiceAccountEmail(keyJson: string): string | null { /** * Fetch events from a Google Calendar using the service account. - * No need for the calendar to be public โ just shared with the service account. + * No need for the calendar to be public - just shared with the service account. */ export async function fetchCalendarEventsViaServiceAccount( keyJson: string, @@ -207,7 +207,7 @@ export async function deleteGoogleEvent( } ); - // 410 Gone means already deleted โ treat as success + // 410 Gone means already deleted - treat as success if (!response.ok && response.status !== 410) { const errorText = await response.text(); log.error('Failed to delete Google Calendar event', { error: errorText, data: { calendarId, googleEventId } }); diff --git a/src/lib/api/google-calendar.test.ts b/src/lib/api/google-calendar.test.ts index 4de9fe8..1b60bb2 100644 --- a/src/lib/api/google-calendar.test.ts +++ b/src/lib/api/google-calendar.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { extractCalendarId, getCalendarSubscribeUrl } from './google-calendar'; +import { describe, it, expect, vi } from 'vitest'; +import { extractCalendarId, getCalendarSubscribeUrl, fetchPublicCalendarEvents } from './google-calendar'; describe('extractCalendarId', () => { it('returns null for empty input', () => { @@ -59,3 +59,55 @@ describe('getCalendarSubscribeUrl', () => { expect(extractCalendarId(url)).toBe(calId); }); }); + +// โโ fetchPublicCalendarEvents โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchPublicCalendarEvents', () => { + it('returns events on success', async () => { + const events = [{ id: 'e1', summary: 'Meeting' }]; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items: events }), + }); + vi.stubGlobal('fetch', mockFetch); + + const result = await fetchPublicCalendarEvents( + 'cal@gmail.com', 'api-key', + new Date('2024-01-01'), new Date('2024-01-31') + ); + expect(result).toEqual(events); + expect(mockFetch).toHaveBeenCalledOnce(); + + vi.unstubAllGlobals(); + }); + + it('returns empty array when no items', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + })); + + const result = await fetchPublicCalendarEvents( + 'cal@gmail.com', 'key', + new Date(), new Date() + ); + expect(result).toEqual([]); + + vi.unstubAllGlobals(); + }); + + it('throws on non-ok response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + text: () => Promise.resolve('Forbidden'), + })); + + await expect(fetchPublicCalendarEvents( + 'cal@gmail.com', 'key', + new Date(), new Date() + )).rejects.toThrow('Failed to fetch calendar events (403)'); + + vi.unstubAllGlobals(); + }); +}); diff --git a/src/lib/api/kanban.test.ts b/src/lib/api/kanban.test.ts new file mode 100644 index 0000000..58e886e --- /dev/null +++ b/src/lib/api/kanban.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchBoards, + fetchBoardWithColumns, + createBoard, + updateBoard, + deleteBoard, + createColumn, + updateColumn, + deleteColumn, + createCard, + updateCard, + deleteCard, + moveCard, + subscribeToBoard, +} from './kanban'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Boards โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchBoards', () => { + it('returns boards for an org', async () => { + const boards = [{ id: 'b1', name: 'Board 1', org_id: 'o1' }]; + const sb = mockSupabase({ data: boards, error: null }); + expect(await fetchBoards(sb, 'o1')).toEqual(boards); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchBoards(sb, 'o1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchBoards(sb, 'o1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('fetchBoardWithColumns', () => { + it('returns board with columns and cards', async () => { + const board = { id: 'b1', name: 'Board', org_id: 'o1' }; + const columns = [{ id: 'c1', board_id: 'b1', name: 'To Do', position: 0 }]; + const cards = [{ id: 'k1', column_id: 'c1', title: 'Task', position: 0, assignee_id: null }]; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + + // Track calls: board.single, columns.order (thenable), cards.order (thenable), then tags/checklists/profiles + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: board, error: null }); + return Promise.resolve({ data: null, error: null }); + }); + + let thenIdx = 0; + chain.then = (resolve: any) => { + thenIdx++; + if (thenIdx === 1) return resolve({ data: columns, error: null }); // columns + if (thenIdx === 2) return resolve({ data: cards, error: null }); // cards + return resolve({ data: [], error: null }); // tags, checklists, profiles + }; + + const sb = { from: vi.fn(() => chain) } as any; + const result = await fetchBoardWithColumns(sb, 'b1'); + + expect(result).not.toBeNull(); + expect(result!.id).toBe('b1'); + expect(result!.columns).toHaveLength(1); + expect(result!.columns[0].cards).toHaveLength(1); + }); + + it('returns board with empty columns when no columns exist', async () => { + const board = { id: 'b1', name: 'Board', org_id: 'o1' }; + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: board, error: null }); + return Promise.resolve({ data: null, error: null }); + }); + + chain.then = (resolve: any) => resolve({ data: [], error: null }); // empty columns + + const sb = { from: vi.fn(() => chain) } as any; + const result = await fetchBoardWithColumns(sb, 'b1'); + + expect(result).not.toBeNull(); + expect(result!.columns).toEqual([]); + }); + + it('returns null when board not found', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + + chain.single = vi.fn(() => Promise.resolve({ data: null, error: null })); + chain.then = (resolve: any) => resolve({ data: [], error: null }); + + const sb = { from: vi.fn(() => chain) } as any; + const result = await fetchBoardWithColumns(sb, 'b1'); + expect(result).toBeNull(); + }); + + it('throws when board fetch fails', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'order', 'single', 'in']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => { + return Promise.resolve({ data: null, error: { message: 'board fail' } }); + }); + chain.then = (resolve: any) => resolve({ data: [], error: null }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(fetchBoardWithColumns(sb, 'b1')).rejects.toEqual({ message: 'board fail' }); + }); + + it('throws when columns fetch fails', async () => { + const board = { id: 'b1', name: 'Board' }; + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + + chain.single = vi.fn(() => Promise.resolve({ data: board, error: null })); + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'col fail' } }); + + const sb = { from: vi.fn(() => chain) } as any; + await expect(fetchBoardWithColumns(sb, 'b1')).rejects.toEqual({ message: 'col fail' }); + }); +}); + +describe('subscribeToBoard', () => { + it('sets up realtime subscription and returns channel', () => { + const channel: any = {}; + channel.on = vi.fn(() => channel); + channel.subscribe = vi.fn(() => channel); + const sb = { channel: vi.fn(() => channel) } as any; + + const result = subscribeToBoard(sb, 'b1', ['c1'], vi.fn(), vi.fn()); + expect(sb.channel).toHaveBeenCalledWith('kanban:b1'); + expect(channel.on).toHaveBeenCalledTimes(2); + expect(channel.subscribe).toHaveBeenCalledOnce(); + expect(result).toBe(channel); + }); +}); + +describe('createBoard', () => { + it('creates board and default columns', async () => { + const board = { id: 'b1', name: 'New Board', org_id: 'o1' }; + const sb = mockSupabase({ data: board, error: null }); + const result = await createBoard(sb, 'o1', 'New Board'); + expect(result).toEqual(board); + // Should have called from('kanban_columns') for default columns + expect(sb.from).toHaveBeenCalledWith('kanban_columns'); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createBoard(sb, 'o1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateBoard', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateBoard(sb, 'b1', 'Renamed')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateBoard(sb, 'b1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteBoard', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteBoard(sb, 'b1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteBoard(sb, 'b1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Columns โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('createColumn', () => { + it('creates and returns column', async () => { + const col = { id: 'c1', name: 'To Do', board_id: 'b1', position: 0 }; + const sb = mockSupabase({ data: col, error: null }); + expect(await createColumn(sb, 'b1', 'To Do', 0)).toEqual(col); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createColumn(sb, 'b1', 'X', 0)).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateColumn', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateColumn(sb, 'c1', { name: 'Done' })).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateColumn(sb, 'c1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteColumn', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteColumn(sb, 'c1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteColumn(sb, 'c1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Cards โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('createCard', () => { + it('creates and returns card', async () => { + const card = { id: 'k1', title: 'Task', column_id: 'c1', position: 0 }; + const sb = mockSupabase({ data: card, error: null }); + expect(await createCard(sb, 'c1', 'Task', 0, 'user1')).toEqual(card); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createCard(sb, 'c1', 'X', 0, 'user1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateCard', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateCard(sb, 'k1', { title: 'Updated' })).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateCard(sb, 'k1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteCard', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteCard(sb, 'k1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteCard(sb, 'k1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ moveCard โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('moveCard', () => { + it('reorders cards in target column', async () => { + const targetCards = [ + { id: 'k1', position: 0 }, + { id: 'k2', position: 1 }, + ]; + const chain: any = {}; + const methods = ['from', 'select', 'update', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: targetCards, error: null }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(moveCard(sb, 'k3', 'col1', 1)).resolves.toBeUndefined(); + }); + + it('throws when fetch fails', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fetch fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(moveCard(sb, 'k1', 'col1', 0)).rejects.toEqual({ message: 'fetch fail' }); + }); +}); diff --git a/src/lib/api/map.ts b/src/lib/api/map.ts new file mode 100644 index 0000000..7f80dea --- /dev/null +++ b/src/lib/api/map.ts @@ -0,0 +1,252 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; + +export interface MapLayer { + id: string; + department_id: string; + name: string; + layer_type: 'osm' | 'image'; + image_url: string | null; + image_width: number | null; + image_height: number | null; + center_lat: number; + center_lng: number; + zoom_level: number; + sort_order: number; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface MapPin { + id: string; + layer_id: string; + label: string; + description: string; + color: string; + lat: number; + lng: number; + bounds_north: number | null; + bounds_south: number | null; + bounds_east: number | null; + bounds_west: number | null; + sort_order: number; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface MapShape { + id: string; + layer_id: string; + shape_type: 'polygon' | 'rectangle'; + label: string; + color: string; + fill_opacity: number; + stroke_width: number; + vertices: [number, number][]; + rotation: number; + sort_order: number; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface MapLayerWithPins extends MapLayer { + pins: MapPin[]; + shapes: MapShape[]; +} + +// โโ Layers โโ + +export async function fetchMapLayers(supabase: SupabaseClient, departmentId: string): Promise { + const { data: layers, error: layerErr } = await (supabase as any) + .from('map_layers') + .select('*') + .eq('department_id', departmentId) + .order('sort_order'); + + if (layerErr) throw layerErr; + if (!layers || layers.length === 0) return []; + + const layerIds = layers.map((l: any) => l.id); + + const [pinResult, shapeResult] = await Promise.all([ + (supabase as any).from('map_pins').select('*').in('layer_id', layerIds).order('sort_order'), + (supabase as any).from('map_shapes').select('*').in('layer_id', layerIds).order('sort_order'), + ]); + + if (pinResult.error) throw pinResult.error; + if (shapeResult.error) throw shapeResult.error; + + const pins = pinResult.data ?? []; + const shapes = shapeResult.data ?? []; + + return layers.map((layer: any) => ({ + ...layer, + pins: pins.filter((p: any) => p.layer_id === layer.id), + shapes: shapes.filter((s: any) => s.layer_id === layer.id), + })); +} + +export async function createMapLayer( + supabase: SupabaseClient, + departmentId: string, + data: { + name: string; + layer_type: 'osm' | 'image'; + image_url?: string; + image_width?: number; + image_height?: number; + center_lat?: number; + center_lng?: number; + zoom_level?: number; + sort_order?: number; + }, +): Promise { + const { data: layer, error } = await (supabase as any) + .from('map_layers') + .insert({ + department_id: departmentId, + ...data, + }) + .select() + .single(); + + if (error) throw error; + return layer; +} + +export async function updateMapLayer( + supabase: SupabaseClient, + layerId: string, + data: Partial>, +): Promise { + const { data: layer, error } = await (supabase as any) + .from('map_layers') + .update({ ...data, updated_at: new Date().toISOString() }) + .eq('id', layerId) + .select() + .single(); + + if (error) throw error; + return layer; +} + +export async function deleteMapLayer(supabase: SupabaseClient, layerId: string): Promise { + const { error } = await (supabase as any) + .from('map_layers') + .delete() + .eq('id', layerId); + + if (error) throw error; +} + +// โโ Pins โโ + +export async function createMapPin( + supabase: SupabaseClient, + layerId: string, + data: { + label: string; + description?: string; + color?: string; + lat: number; + lng: number; + bounds_north?: number; + bounds_south?: number; + bounds_east?: number; + bounds_west?: number; + sort_order?: number; + }, +): Promise { + const { data: pin, error } = await (supabase as any) + .from('map_pins') + .insert({ + layer_id: layerId, + ...data, + }) + .select() + .single(); + + if (error) throw error; + return pin; +} + +export async function updateMapPin( + supabase: SupabaseClient, + pinId: string, + data: Partial>, +): Promise { + const { data: pin, error } = await (supabase as any) + .from('map_pins') + .update({ ...data, updated_at: new Date().toISOString() }) + .eq('id', pinId) + .select() + .single(); + + if (error) throw error; + return pin; +} + +export async function deleteMapPin(supabase: SupabaseClient, pinId: string): Promise { + const { error } = await (supabase as any) + .from('map_pins') + .delete() + .eq('id', pinId); + + if (error) throw error; +} + +// โโ Shapes โโ + +export async function createMapShape( + supabase: SupabaseClient, + layerId: string, + data: { + shape_type: 'polygon' | 'rectangle'; + label?: string; + color?: string; + fill_opacity?: number; + stroke_width?: number; + vertices: [number, number][]; + rotation?: number; + sort_order?: number; + }, +): Promise { + const { data: shape, error } = await (supabase as any) + .from('map_shapes') + .insert({ + layer_id: layerId, + ...data, + }) + .select() + .single(); + + if (error) throw error; + return shape; +} + +export async function updateMapShape( + supabase: SupabaseClient, + shapeId: string, + data: Partial>, +): Promise { + const { data: shape, error } = await (supabase as any) + .from('map_shapes') + .update({ ...data, updated_at: new Date().toISOString() }) + .eq('id', shapeId) + .select() + .single(); + + if (error) throw error; + return shape; +} + +export async function deleteMapShape(supabase: SupabaseClient, shapeId: string): Promise { + const { error } = await (supabase as any) + .from('map_shapes') + .delete() + .eq('id', shapeId); + + if (error) throw error; +} diff --git a/src/lib/api/org-contacts.test.ts b/src/lib/api/org-contacts.test.ts new file mode 100644 index 0000000..bc9ef8a --- /dev/null +++ b/src/lib/api/org-contacts.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchOrgContacts, + createOrgContact, + updateOrgContact, + deleteOrgContact, + fetchPinnedContacts, + pinContact, + unpinContact, +} from './org-contacts'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +function mockDeleteSupabase(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + for (const m of ['from', 'delete', 'eq']) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve(resolvedValue); + return { from: vi.fn(() => chain) } as any; +} + +// โโ Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('org-contacts API', () => { + describe('fetchOrgContacts', () => { + it('should return contacts ordered by name', async () => { + const contacts = [ + { id: 'c1', name: 'Alice', org_id: 'o1', category: 'vendor' }, + { id: 'c2', name: 'Bob', org_id: 'o1', category: 'general' }, + ]; + const supabase = mockSupabase({ data: contacts, error: null }); + + const result = await fetchOrgContacts(supabase, 'o1'); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Alice'); + expect(result[1].name).toBe('Bob'); + }); + + it('should return empty array when data is null', async () => { + const supabase = mockSupabase({ data: null, error: null }); + + const result = await fetchOrgContacts(supabase, 'o1'); + expect(result).toEqual([]); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'fetch fail' } }); + + await expect(fetchOrgContacts(supabase, 'o1')).rejects.toEqual({ message: 'fetch fail' }); + }); + }); + + describe('createOrgContact', () => { + it('should create a contact with defaults', async () => { + const created = { id: 'c1', name: 'Test', org_id: 'o1', category: 'general', color: '#00A3E0' }; + const supabase = mockSupabase({ data: created, error: null }); + + const result = await createOrgContact(supabase, 'o1', { name: 'Test' }); + + expect(result.id).toBe('c1'); + expect(result.category).toBe('general'); + }); + + it('should create a contact with all fields', async () => { + const created = { + id: 'c2', name: 'Full', org_id: 'o1', role: 'Manager', company: 'Acme', + email: 'a@b.com', phone: '+1234', website: 'https://acme.com', + notes: 'VIP', category: 'sponsor', color: '#FF0000', + }; + const supabase = mockSupabase({ data: created, error: null }); + + const result = await createOrgContact(supabase, 'o1', { + name: 'Full', role: 'Manager', company: 'Acme', + email: 'a@b.com', phone: '+1234', website: 'https://acme.com', + notes: 'VIP', category: 'sponsor', color: '#FF0000', + }); + + expect(result.role).toBe('Manager'); + expect(result.company).toBe('Acme'); + expect(result.category).toBe('sponsor'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'create fail' } }); + + await expect(createOrgContact(supabase, 'o1', { name: 'X' })).rejects.toEqual({ message: 'create fail' }); + }); + }); + + describe('updateOrgContact', () => { + it('should update and return the contact', async () => { + const updated = { id: 'c1', name: 'Updated', org_id: 'o1', category: 'vendor' }; + const supabase = mockSupabase({ data: updated, error: null }); + + const result = await updateOrgContact(supabase, 'c1', { name: 'Updated', category: 'vendor' }); + + expect(result.name).toBe('Updated'); + expect(result.category).toBe('vendor'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'update fail' } }); + + await expect(updateOrgContact(supabase, 'c1', { name: 'X' })).rejects.toEqual({ message: 'update fail' }); + }); + }); + + describe('deleteOrgContact', () => { + it('should delete without error', async () => { + const supabase = mockDeleteSupabase({ data: null, error: null }); + + await expect(deleteOrgContact(supabase, 'c1')).resolves.toBeUndefined(); + }); + + it('should throw on error', async () => { + const supabase = mockDeleteSupabase({ data: null, error: { message: 'delete fail' } }); + + await expect(deleteOrgContact(supabase, 'c1')).rejects.toEqual({ message: 'delete fail' }); + }); + }); + + describe('fetchPinnedContacts', () => { + it('should return pinned contacts for a department', async () => { + const pins = [ + { id: 'p1', department_id: 'd1', contact_id: 'c1' }, + { id: 'p2', department_id: 'd1', contact_id: 'c2' }, + ]; + const supabase = mockSupabase({ data: pins, error: null }); + + const result = await fetchPinnedContacts(supabase, 'd1'); + + expect(result).toHaveLength(2); + expect(result[0].contact_id).toBe('c1'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'pin fetch fail' } }); + + await expect(fetchPinnedContacts(supabase, 'd1')).rejects.toEqual({ message: 'pin fetch fail' }); + }); + }); + + describe('pinContact', () => { + it('should pin a contact and return the record', async () => { + const pinned = { id: 'p1', department_id: 'd1', contact_id: 'c1' }; + const supabase = mockSupabase({ data: pinned, error: null }); + + const result = await pinContact(supabase, 'd1', 'c1'); + + expect(result.department_id).toBe('d1'); + expect(result.contact_id).toBe('c1'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'pin fail' } }); + + await expect(pinContact(supabase, 'd1', 'c1')).rejects.toEqual({ message: 'pin fail' }); + }); + }); + + describe('unpinContact', () => { + it('should unpin without error', async () => { + // unpinContact chains .delete().eq().eq() - no .single() + const chain: any = {}; + for (const m of ['from', 'delete', 'eq']) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: null }); + const supabase = { from: vi.fn(() => chain) } as any; + + await expect(unpinContact(supabase, 'd1', 'c1')).resolves.toBeUndefined(); + }); + + it('should throw on error', async () => { + const chain: any = {}; + for (const m of ['from', 'delete', 'eq']) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'unpin fail' } }); + const supabase = { from: vi.fn(() => chain) } as any; + + await expect(unpinContact(supabase, 'd1', 'c1')).rejects.toEqual({ message: 'unpin fail' }); + }); + }); +}); diff --git a/src/lib/api/org-contacts.ts b/src/lib/api/org-contacts.ts new file mode 100644 index 0000000..97eef98 --- /dev/null +++ b/src/lib/api/org-contacts.ts @@ -0,0 +1,158 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { OrgContact, DepartmentPinnedContact } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.org-contacts'); + +function db(supabase: SupabaseClient) { + return supabase as any; +} + +// ============================================================ +// Org Contacts CRUD +// ============================================================ + +export async function fetchOrgContacts( + supabase: SupabaseClient, + orgId: string +): Promise { + const { data, error } = await db(supabase) + .from('org_contacts') + .select('*') + .eq('org_id', orgId) + .order('name'); + + if (error) { + log.error('fetchOrgContacts failed', { error, data: { orgId } }); + throw error; + } + return (data ?? []) as OrgContact[]; +} + +export async function createOrgContact( + supabase: SupabaseClient, + orgId: string, + params: { + name: string; + role?: string; + company?: string; + email?: string; + phone?: string; + website?: string; + notes?: string; + category?: string; + color?: string; + } +): Promise { + const { data, error } = await db(supabase) + .from('org_contacts') + .insert({ + org_id: orgId, + name: params.name, + role: params.role ?? null, + company: params.company ?? null, + email: params.email ?? null, + phone: params.phone ?? null, + website: params.website ?? null, + notes: params.notes ?? null, + category: params.category ?? 'general', + color: params.color ?? '#00A3E0', + }) + .select() + .single(); + + if (error) { + log.error('createOrgContact failed', { error, data: { orgId, name: params.name } }); + throw error; + } + return data as OrgContact; +} + +export async function updateOrgContact( + supabase: SupabaseClient, + contactId: string, + params: Partial> +): Promise { + const { data, error } = await db(supabase) + .from('org_contacts') + .update({ ...params, updated_at: new Date().toISOString() }) + .eq('id', contactId) + .select() + .single(); + + if (error) { + log.error('updateOrgContact failed', { error, data: { contactId } }); + throw error; + } + return data as OrgContact; +} + +export async function deleteOrgContact( + supabase: SupabaseClient, + contactId: string +): Promise { + const { error } = await db(supabase) + .from('org_contacts') + .delete() + .eq('id', contactId); + + if (error) { + log.error('deleteOrgContact failed', { error, data: { contactId } }); + throw error; + } +} + +// ============================================================ +// Department Pinned Contacts +// ============================================================ + +export async function fetchPinnedContacts( + supabase: SupabaseClient, + departmentId: string +): Promise { + const { data, error } = await db(supabase) + .from('department_pinned_contacts') + .select('*') + .eq('department_id', departmentId); + + if (error) { + log.error('fetchPinnedContacts failed', { error, data: { departmentId } }); + throw error; + } + return (data ?? []) as DepartmentPinnedContact[]; +} + +export async function pinContact( + supabase: SupabaseClient, + departmentId: string, + contactId: string +): Promise { + const { data, error } = await db(supabase) + .from('department_pinned_contacts') + .insert({ department_id: departmentId, contact_id: contactId }) + .select() + .single(); + + if (error) { + log.error('pinContact failed', { error, data: { departmentId, contactId } }); + throw error; + } + return data as DepartmentPinnedContact; +} + +export async function unpinContact( + supabase: SupabaseClient, + departmentId: string, + contactId: string +): Promise { + const { error } = await db(supabase) + .from('department_pinned_contacts') + .delete() + .eq('department_id', departmentId) + .eq('contact_id', contactId); + + if (error) { + log.error('unpinContact failed', { error, data: { departmentId, contactId } }); + throw error; + } +} diff --git a/src/lib/api/organizations.test.ts b/src/lib/api/organizations.test.ts new file mode 100644 index 0000000..1ab0770 --- /dev/null +++ b/src/lib/api/organizations.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchUserOrganizations, + createOrganization, + updateOrganization, + deleteOrganization, + fetchOrgMembers, + inviteMember, + updateMemberRole, + removeMember, + generateSlug, +} from './organizations'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'not', 'order', 'single', 'in']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ generateSlug (pure function) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('generateSlug', () => { + it('lowercases and replaces spaces with hyphens', () => { + expect(generateSlug('My Organization')).toBe('my-organization'); + }); + + it('removes special characters', () => { + expect(generateSlug('Test & Co. (2024)')).toBe('test-co-2024'); + }); + + it('trims leading/trailing hyphens', () => { + expect(generateSlug('---hello---')).toBe('hello'); + }); + + it('truncates to 50 characters', () => { + const long = 'a'.repeat(100); + expect(generateSlug(long).length).toBeLessThanOrEqual(50); + }); + + it('handles empty string', () => { + expect(generateSlug('')).toBe(''); + }); + + it('collapses multiple hyphens', () => { + expect(generateSlug('hello world')).toBe('hello-world'); + }); +}); + +// โโ fetchUserOrganizations โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchUserOrganizations', () => { + it('returns orgs with roles', async () => { + const data = [ + { role: 'admin', organizations: { id: 'o1', name: 'Org1', slug: 'org1' } }, + ]; + const sb = mockSupabase({ data, error: null }); + const result = await fetchUserOrganizations(sb); + expect(result).toHaveLength(1); + expect(result[0].role).toBe('admin'); + expect(result[0].id).toBe('o1'); + }); + + it('filters out null organizations', async () => { + const data = [ + { role: 'admin', organizations: { id: 'o1', name: 'Org1', slug: 'org1' } }, + { role: 'viewer', organizations: null }, + ]; + const sb = mockSupabase({ data, error: null }); + const result = await fetchUserOrganizations(sb); + expect(result).toHaveLength(1); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchUserOrganizations(sb)).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ createOrganization โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('createOrganization', () => { + it('creates and returns org', async () => { + const org = { id: 'o1', name: 'New Org', slug: 'new-org' }; + const sb = mockSupabase({ data: org, error: null }); + expect(await createOrganization(sb, 'New Org', 'new-org')).toEqual(org); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'dup slug' } }); + await expect(createOrganization(sb, 'X', 'x')).rejects.toEqual({ message: 'dup slug' }); + }); +}); + +// โโ updateOrganization โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('updateOrganization', () => { + it('updates and returns org', async () => { + const org = { id: 'o1', name: 'Updated' }; + const sb = mockSupabase({ data: org, error: null }); + expect(await updateOrganization(sb, 'o1', { name: 'Updated' })).toEqual(org); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateOrganization(sb, 'o1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ deleteOrganization โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('deleteOrganization', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteOrganization(sb, 'o1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteOrganization(sb, 'o1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ fetchOrgMembers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchOrgMembers', () => { + it('returns members', async () => { + const members = [{ id: 'm1', user_id: 'u1', role: 'admin' }]; + const sb = mockSupabase({ data: members, error: null }); + expect(await fetchOrgMembers(sb, 'o1')).toEqual(members); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchOrgMembers(sb, 'o1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchOrgMembers(sb, 'o1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ inviteMember โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('inviteMember', () => { + it('throws when user not found', async () => { + const sb = mockSupabase({ data: null, error: { message: 'not found' } }); + await expect(inviteMember(sb, 'o1', 'nobody@test.com')).rejects.toThrow('User not found'); + }); + + it('throws when user is already a member', async () => { + const profile = { id: 'u1', email: 'a@b.com' }; + const existing = { id: 'om1' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: profile, error: null }); // profile lookup + if (singleIdx === 2) return Promise.resolve({ data: existing, error: null }); // already member + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(inviteMember(sb, 'o1', 'a@b.com')).rejects.toThrow('already a member'); + }); + + it('invites successfully when user exists and is not a member', async () => { + const profile = { id: 'u1', email: 'a@b.com' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: profile, error: null }); // profile + if (singleIdx === 2) return Promise.resolve({ data: null, error: null }); // not a member + return Promise.resolve({ data: null, error: null }); + }); + chain.then = (resolve: any) => resolve({ data: null, error: null }); // insert success + const sb = { from: vi.fn(() => chain) } as any; + + await expect(inviteMember(sb, 'o1', 'a@b.com')).resolves.toBeUndefined(); + }); + + it('throws on insert error', async () => { + const profile = { id: 'u1', email: 'a@b.com' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: profile, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: null, error: null }); + return Promise.resolve({ data: null, error: null }); + }); + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'insert fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(inviteMember(sb, 'o1', 'a@b.com')).rejects.toEqual({ message: 'insert fail' }); + }); +}); + +// โโ updateMemberRole โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('updateMemberRole', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateMemberRole(sb, 'm1', 'editor')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateMemberRole(sb, 'm1', 'admin')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ removeMember โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('removeMember', () => { + it('removes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(removeMember(sb, 'm1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(removeMember(sb, 'm1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/schedule.test.ts b/src/lib/api/schedule.test.ts new file mode 100644 index 0000000..baff6f3 --- /dev/null +++ b/src/lib/api/schedule.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchStages, + createStage, + updateStage, + deleteStage, + fetchBlocks, + createBlock, + updateBlock, + deleteBlock, +} from './schedule'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Stages โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchStages', () => { + it('returns stages for a department', async () => { + const stages = [{ id: 's1', name: 'Main Stage', department_id: 'd1' }]; + const sb = mockSupabase({ data: stages, error: null }); + expect(await fetchStages(sb, 'd1')).toEqual(stages); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchStages(sb, 'd1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchStages(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createStage', () => { + it('creates with default color', async () => { + const stage = { id: 's1', name: 'VIP', color: '#6366f1' }; + const sb = mockSupabase({ data: stage, error: null }); + expect(await createStage(sb, 'd1', 'VIP')).toEqual(stage); + }); + + it('creates with custom color', async () => { + const stage = { id: 's2', name: 'Outdoor', color: '#00ff00' }; + const sb = mockSupabase({ data: stage, error: null }); + expect(await createStage(sb, 'd1', 'Outdoor', '#00ff00')).toEqual(stage); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createStage(sb, 'd1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateStage', () => { + it('updates and returns stage', async () => { + const stage = { id: 's1', name: 'Renamed' }; + const sb = mockSupabase({ data: stage, error: null }); + expect(await updateStage(sb, 's1', { name: 'Renamed' })).toEqual(stage); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateStage(sb, 's1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteStage', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteStage(sb, 's1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteStage(sb, 's1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Blocks โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchBlocks', () => { + it('returns blocks for a department', async () => { + const blocks = [{ id: 'b1', title: 'Opening', department_id: 'd1' }]; + const sb = mockSupabase({ data: blocks, error: null }); + expect(await fetchBlocks(sb, 'd1')).toEqual(blocks); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchBlocks(sb, 'd1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchBlocks(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createBlock', () => { + it('creates a block with minimal params', async () => { + const block = { id: 'b1', title: 'Keynote', start_time: '09:00', end_time: '10:00' }; + const sb = mockSupabase({ data: block, error: null }); + const result = await createBlock(sb, 'd1', { + title: 'Keynote', + start_time: '09:00', + end_time: '10:00', + }); + expect(result).toEqual(block); + }); + + it('creates a block with all params', async () => { + const block = { id: 'b2', title: 'Panel', speaker: 'John' }; + const sb = mockSupabase({ data: block, error: null }); + const result = await createBlock(sb, 'd1', { + title: 'Panel', + start_time: '10:00', + end_time: '11:00', + stage_id: 's1', + description: 'A panel', + color: '#ff0000', + speaker: 'John', + }, 'user1'); + expect(result).toEqual(block); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createBlock(sb, 'd1', { title: 'X', start_time: '09:00', end_time: '10:00' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateBlock', () => { + it('updates and returns block', async () => { + const block = { id: 'b1', title: 'Updated' }; + const sb = mockSupabase({ data: block, error: null }); + expect(await updateBlock(sb, 'b1', { title: 'Updated' })).toEqual(block); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateBlock(sb, 'b1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteBlock', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteBlock(sb, 'b1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteBlock(sb, 'b1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/sponsor-allocations.test.ts b/src/lib/api/sponsor-allocations.test.ts new file mode 100644 index 0000000..994a884 --- /dev/null +++ b/src/lib/api/sponsor-allocations.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchEventSponsorAllocations, + fetchDepartmentSponsorAllocations, + createSponsorAllocation, + updateSponsorAllocation, + deleteSponsorAllocation, +} from './sponsor-allocations'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + // Terminal - resolve the promise + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + // For non-single queries, make the chain itself thenable + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('sponsor-allocations API', () => { + describe('fetchEventSponsorAllocations', () => { + it('should return allocations stripped of join data', async () => { + const raw = [ + { id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 500, used_amount: 100, event_departments: { event_id: 'e1' } }, + { id: 'a2', sponsor_id: 's2', department_id: 'd2', allocated_amount: 300, used_amount: 0, event_departments: { event_id: 'e1' } }, + ]; + const supabase = mockSupabase({ data: raw, error: null }); + + const result = await fetchEventSponsorAllocations(supabase, 'e1'); + + expect(result).toHaveLength(2); + expect(result[0]).not.toHaveProperty('event_departments'); + expect(result[0].id).toBe('a1'); + expect(result[1].allocated_amount).toBe(300); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'fail' } }); + + await expect(fetchEventSponsorAllocations(supabase, 'e1')).rejects.toEqual({ message: 'fail' }); + }); + + it('should return empty array when data is null', async () => { + const supabase = mockSupabase({ data: null, error: null }); + + const result = await fetchEventSponsorAllocations(supabase, 'e1'); + expect(result).toEqual([]); + }); + }); + + describe('fetchDepartmentSponsorAllocations', () => { + it('should return allocations for a department', async () => { + const raw = [{ id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 200, used_amount: 50 }]; + const supabase = mockSupabase({ data: raw, error: null }); + + const result = await fetchDepartmentSponsorAllocations(supabase, 'd1'); + + expect(result).toHaveLength(1); + expect(result[0].department_id).toBe('d1'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'dept fail' } }); + + await expect(fetchDepartmentSponsorAllocations(supabase, 'd1')).rejects.toEqual({ message: 'dept fail' }); + }); + }); + + describe('createSponsorAllocation', () => { + it('should create and return an allocation', async () => { + const created = { id: 'new1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 1000, used_amount: 0, notes: null }; + const supabase = mockSupabase({ data: created, error: null }); + + const result = await createSponsorAllocation(supabase, { + sponsor_id: 's1', + department_id: 'd1', + allocated_amount: 1000, + }); + + expect(result.id).toBe('new1'); + expect(result.allocated_amount).toBe(1000); + expect(result.used_amount).toBe(0); + }); + + it('should pass used_amount and notes when provided', async () => { + const created = { id: 'new2', sponsor_id: 's1', department_id: 'd1', allocated_amount: 500, used_amount: 100, notes: 'test' }; + const supabase = mockSupabase({ data: created, error: null }); + + const result = await createSponsorAllocation(supabase, { + sponsor_id: 's1', + department_id: 'd1', + allocated_amount: 500, + used_amount: 100, + notes: 'test', + }); + + expect(result.used_amount).toBe(100); + expect(result.notes).toBe('test'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'create fail' } }); + + await expect( + createSponsorAllocation(supabase, { sponsor_id: 's1', department_id: 'd1', allocated_amount: 100 }) + ).rejects.toEqual({ message: 'create fail' }); + }); + }); + + describe('updateSponsorAllocation', () => { + it('should update and return the allocation', async () => { + const updated = { id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 750, used_amount: 200, notes: 'updated' }; + const supabase = mockSupabase({ data: updated, error: null }); + + const result = await updateSponsorAllocation(supabase, 'a1', { allocated_amount: 750, used_amount: 200, notes: 'updated' }); + + expect(result.allocated_amount).toBe(750); + expect(result.notes).toBe('updated'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'update fail' } }); + + await expect(updateSponsorAllocation(supabase, 'a1', { allocated_amount: 100 })).rejects.toEqual({ message: 'update fail' }); + }); + }); + + describe('deleteSponsorAllocation', () => { + it('should delete without error', async () => { + // delete doesn't call .single(), so we need a different mock + const chain: any = {}; + const methods = ['from', 'delete', 'eq']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: null }); + const supabase = { from: vi.fn(() => chain) } as any; + + await expect(deleteSponsorAllocation(supabase, 'a1')).resolves.toBeUndefined(); + }); + + it('should throw on error', async () => { + const chain: any = {}; + const methods = ['from', 'delete', 'eq']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'delete fail' } }); + const supabase = { from: vi.fn(() => chain) } as any; + + await expect(deleteSponsorAllocation(supabase, 'a1')).rejects.toEqual({ message: 'delete fail' }); + }); + }); +}); diff --git a/src/lib/api/sponsor-allocations.ts b/src/lib/api/sponsor-allocations.ts new file mode 100644 index 0000000..83fa6f7 --- /dev/null +++ b/src/lib/api/sponsor-allocations.ts @@ -0,0 +1,115 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { SponsorAllocation } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.sponsor-allocations'); + +function db(supabase: SupabaseClient) { + return supabase as any; +} + +// ============================================================ +// Fetch allocations for an event (via departments) +// ============================================================ + +export async function fetchEventSponsorAllocations( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_allocations') + .select('*, event_departments!inner(event_id)') + .eq('event_departments.event_id', eventId); + + if (error) { + log.error('fetchEventSponsorAllocations failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { event_departments, ...alloc } = d; + return alloc; + }) as SponsorAllocation[]; +} + +export async function fetchDepartmentSponsorAllocations( + supabase: SupabaseClient, + departmentId: string +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_allocations') + .select('*') + .eq('department_id', departmentId); + + if (error) { + log.error('fetchDepartmentSponsorAllocations failed', { error, data: { departmentId } }); + throw error; + } + return (data ?? []) as SponsorAllocation[]; +} + +// ============================================================ +// CRUD +// ============================================================ + +export async function createSponsorAllocation( + supabase: SupabaseClient, + params: { + sponsor_id: string; + department_id: string; + allocated_amount: number; + used_amount?: number; + notes?: string; + } +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_allocations') + .insert({ + sponsor_id: params.sponsor_id, + department_id: params.department_id, + allocated_amount: params.allocated_amount, + used_amount: params.used_amount ?? 0, + notes: params.notes ?? null, + }) + .select() + .single(); + + if (error) { + log.error('createSponsorAllocation failed', { error, data: params }); + throw error; + } + return data as SponsorAllocation; +} + +export async function updateSponsorAllocation( + supabase: SupabaseClient, + allocationId: string, + params: Partial> +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_allocations') + .update({ ...params, updated_at: new Date().toISOString() }) + .eq('id', allocationId) + .select() + .single(); + + if (error) { + log.error('updateSponsorAllocation failed', { error, data: { allocationId } }); + throw error; + } + return data as SponsorAllocation; +} + +export async function deleteSponsorAllocation( + supabase: SupabaseClient, + allocationId: string +): Promise { + const { error } = await db(supabase) + .from('sponsor_allocations') + .delete() + .eq('id', allocationId); + + if (error) { + log.error('deleteSponsorAllocation failed', { error, data: { allocationId } }); + throw error; + } +} diff --git a/src/lib/api/sponsors-event.test.ts b/src/lib/api/sponsors-event.test.ts new file mode 100644 index 0000000..ebb0de1 --- /dev/null +++ b/src/lib/api/sponsors-event.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchEventSponsorTiers, + fetchEventSponsors, + fetchEventDeliverables, +} from './sponsors'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('event-wide sponsor fetching', () => { + describe('fetchEventSponsorTiers', () => { + it('should return tiers stripped of join data', async () => { + const raw = [ + { id: 't1', name: 'Gold', sort_order: 0, event_departments: { event_id: 'e1' } }, + { id: 't2', name: 'Silver', sort_order: 1, event_departments: { event_id: 'e1' } }, + ]; + const supabase = mockSupabase({ data: raw, error: null }); + + const result = await fetchEventSponsorTiers(supabase, 'e1'); + + expect(result).toHaveLength(2); + expect(result[0]).not.toHaveProperty('event_departments'); + expect(result[0].name).toBe('Gold'); + expect(result[1].name).toBe('Silver'); + }); + + it('should return empty array when data is null', async () => { + const supabase = mockSupabase({ data: null, error: null }); + + const result = await fetchEventSponsorTiers(supabase, 'e1'); + expect(result).toEqual([]); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'tier fail' } }); + + await expect(fetchEventSponsorTiers(supabase, 'e1')).rejects.toEqual({ message: 'tier fail' }); + }); + }); + + describe('fetchEventSponsors', () => { + it('should return sponsors stripped of join data', async () => { + const raw = [ + { id: 's1', name: 'Acme', amount: 5000, status: 'confirmed', event_departments: { event_id: 'e1' } }, + { id: 's2', name: 'Beta', amount: 2000, status: 'prospect', event_departments: { event_id: 'e1' } }, + ]; + const supabase = mockSupabase({ data: raw, error: null }); + + const result = await fetchEventSponsors(supabase, 'e1'); + + expect(result).toHaveLength(2); + expect(result[0]).not.toHaveProperty('event_departments'); + expect(result[0].name).toBe('Acme'); + expect(result[0].amount).toBe(5000); + }); + + it('should return empty array when data is null', async () => { + const supabase = mockSupabase({ data: null, error: null }); + + const result = await fetchEventSponsors(supabase, 'e1'); + expect(result).toEqual([]); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'sponsor fail' } }); + + await expect(fetchEventSponsors(supabase, 'e1')).rejects.toEqual({ message: 'sponsor fail' }); + }); + }); + + describe('fetchEventDeliverables', () => { + it('should return deliverables stripped of join data', async () => { + const raw = [ + { id: 'd1', description: 'Logo placement', is_completed: false, sponsors: { department_id: 'dep1', event_departments: { event_id: 'e1' } } }, + ]; + const supabase = mockSupabase({ data: raw, error: null }); + + const result = await fetchEventDeliverables(supabase, 'e1'); + + expect(result).toHaveLength(1); + expect(result[0]).not.toHaveProperty('sponsors'); + expect(result[0].description).toBe('Logo placement'); + }); + + it('should return empty array when data is null', async () => { + const supabase = mockSupabase({ data: null, error: null }); + + const result = await fetchEventDeliverables(supabase, 'e1'); + expect(result).toEqual([]); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'deliverable fail' } }); + + await expect(fetchEventDeliverables(supabase, 'e1')).rejects.toEqual({ message: 'deliverable fail' }); + }); + }); +}); diff --git a/src/lib/api/sponsors.test.ts b/src/lib/api/sponsors.test.ts new file mode 100644 index 0000000..ca1d164 --- /dev/null +++ b/src/lib/api/sponsors.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + SPONSOR_STATUSES, + STATUS_LABELS, + STATUS_COLORS, + fetchSponsorTiers, + createSponsorTier, + updateSponsorTier, + deleteSponsorTier, + fetchSponsors, + createSponsor, + updateSponsor, + deleteSponsor, + fetchDeliverables, + fetchAllDeliverables, + createDeliverable, + updateDeliverable, + deleteDeliverable, +} from './sponsors'; + +// โโ Supabase mock builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('sponsor constants', () => { + it('SPONSOR_STATUSES has expected entries', () => { + expect(SPONSOR_STATUSES).toContain('prospect'); + expect(SPONSOR_STATUSES).toContain('confirmed'); + expect(SPONSOR_STATUSES).toContain('declined'); + expect(SPONSOR_STATUSES).toContain('active'); + expect(SPONSOR_STATUSES.length).toBe(5); + }); + + it('STATUS_LABELS has a label for every status', () => { + for (const s of SPONSOR_STATUSES) { + expect(STATUS_LABELS[s]).toBeDefined(); + expect(typeof STATUS_LABELS[s]).toBe('string'); + } + }); + + it('STATUS_COLORS has a color for every status', () => { + for (const s of SPONSOR_STATUSES) { + expect(STATUS_COLORS[s]).toBeDefined(); + expect(STATUS_COLORS[s]).toMatch(/^#/); + } + }); +}); + +// โโ Sponsor Tiers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchSponsorTiers', () => { + it('returns tiers for a department', async () => { + const tiers = [{ id: 't1', name: 'Gold', department_id: 'd1' }]; + const sb = mockSupabase({ data: tiers, error: null }); + expect(await fetchSponsorTiers(sb, 'd1')).toEqual(tiers); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchSponsorTiers(sb, 'd1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchSponsorTiers(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createSponsorTier', () => { + it('creates with default color', async () => { + const tier = { id: 't1', name: 'Silver', amount: 0, color: '#F59E0B' }; + const sb = mockSupabase({ data: tier, error: null }); + expect(await createSponsorTier(sb, 'd1', 'Silver')).toEqual(tier); + }); + + it('creates with custom amount and color', async () => { + const tier = { id: 't2', name: 'Platinum', amount: 10000, color: '#00ff00' }; + const sb = mockSupabase({ data: tier, error: null }); + expect(await createSponsorTier(sb, 'd1', 'Platinum', 10000, '#00ff00')).toEqual(tier); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createSponsorTier(sb, 'd1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateSponsorTier', () => { + it('updates and returns tier', async () => { + const tier = { id: 't1', name: 'Updated' }; + const sb = mockSupabase({ data: tier, error: null }); + expect(await updateSponsorTier(sb, 't1', { name: 'Updated' })).toEqual(tier); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateSponsorTier(sb, 't1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteSponsorTier', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteSponsorTier(sb, 't1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteSponsorTier(sb, 't1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Sponsors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchSponsors', () => { + it('returns sponsors for a department', async () => { + const sponsors = [{ id: 's1', name: 'Acme', department_id: 'd1' }]; + const sb = mockSupabase({ data: sponsors, error: null }); + expect(await fetchSponsors(sb, 'd1')).toEqual(sponsors); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchSponsors(sb, 'd1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchSponsors(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createSponsor', () => { + it('creates with minimal params', async () => { + const sponsor = { id: 's1', name: 'Acme', status: 'prospect', amount: 0 }; + const sb = mockSupabase({ data: sponsor, error: null }); + expect(await createSponsor(sb, 'd1', { name: 'Acme' })).toEqual(sponsor); + }); + + it('creates with all params', async () => { + const sponsor = { id: 's2', name: 'BigCo', status: 'confirmed', amount: 5000 }; + const sb = mockSupabase({ data: sponsor, error: null }); + expect(await createSponsor(sb, 'd1', { + name: 'BigCo', + tier_id: 't1', + contact_name: 'John', + contact_email: 'john@bigco.com', + contact_phone: '+1234', + website: 'https://bigco.com', + logo_url: 'https://bigco.com/logo.png', + status: 'confirmed', + amount: 5000, + notes: 'VIP sponsor', + })).toEqual(sponsor); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createSponsor(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateSponsor', () => { + it('updates and returns sponsor', async () => { + const sponsor = { id: 's1', name: 'Updated' }; + const sb = mockSupabase({ data: sponsor, error: null }); + expect(await updateSponsor(sb, 's1', { name: 'Updated' })).toEqual(sponsor); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateSponsor(sb, 's1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteSponsor', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteSponsor(sb, 's1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteSponsor(sb, 's1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โโ Deliverables โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + +describe('fetchDeliverables', () => { + it('returns deliverables for a sponsor', async () => { + const delivs = [{ id: 'dl1', description: 'Logo placement', sponsor_id: 's1' }]; + const sb = mockSupabase({ data: delivs, error: null }); + expect(await fetchDeliverables(sb, 's1')).toEqual(delivs); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchDeliverables(sb, 's1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchDeliverables(sb, 's1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('fetchAllDeliverables', () => { + it('returns empty array for empty sponsor list', async () => { + const sb = mockSupabase({ data: [], error: null }); + expect(await fetchAllDeliverables(sb, [])).toEqual([]); + }); + + it('returns deliverables for multiple sponsors', async () => { + const delivs = [ + { id: 'dl1', sponsor_id: 's1' }, + { id: 'dl2', sponsor_id: 's2' }, + ]; + const sb = mockSupabase({ data: delivs, error: null }); + expect(await fetchAllDeliverables(sb, ['s1', 's2'])).toEqual(delivs); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchAllDeliverables(sb, ['s1'])).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createDeliverable', () => { + it('creates with description only', async () => { + const deliv = { id: 'dl1', description: 'Banner', sponsor_id: 's1' }; + const sb = mockSupabase({ data: deliv, error: null }); + expect(await createDeliverable(sb, 's1', 'Banner')).toEqual(deliv); + }); + + it('creates with due date', async () => { + const deliv = { id: 'dl2', description: 'Video', sponsor_id: 's1', due_date: '2025-06-01' }; + const sb = mockSupabase({ data: deliv, error: null }); + expect(await createDeliverable(sb, 's1', 'Video', '2025-06-01')).toEqual(deliv); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createDeliverable(sb, 's1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateDeliverable', () => { + it('updates and returns deliverable', async () => { + const deliv = { id: 'dl1', description: 'Updated' }; + const sb = mockSupabase({ data: deliv, error: null }); + expect(await updateDeliverable(sb, 'dl1', { description: 'Updated' })).toEqual(deliv); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateDeliverable(sb, 'dl1', { description: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteDeliverable', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteDeliverable(sb, 'dl1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteDeliverable(sb, 'dl1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/sponsors.ts b/src/lib/api/sponsors.ts index 7a2c6a9..cf7f4e5 100644 --- a/src/lib/api/sponsors.ts +++ b/src/lib/api/sponsors.ts @@ -108,6 +108,70 @@ export async function deleteSponsorTier( } } +// ============================================================ +// Event-wide Tier + Sponsor fetching +// ============================================================ + +export async function fetchEventSponsorTiers( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_tiers') + .select('*, event_departments!inner(event_id)') + .eq('event_departments.event_id', eventId) + .order('sort_order'); + + if (error) { + log.error('fetchEventSponsorTiers failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { event_departments, ...tier } = d; + return tier; + }) as SponsorTier[]; +} + +export async function fetchEventSponsors( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('sponsors') + .select('*, event_departments!inner(event_id)') + .eq('event_departments.event_id', eventId) + .order('name'); + + if (error) { + log.error('fetchEventSponsors failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { event_departments, ...sponsor } = d; + return sponsor; + }) as Sponsor[]; +} + +export async function fetchEventDeliverables( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_deliverables') + .select('*, sponsors!inner(department_id, event_departments!inner(event_id))') + .eq('sponsors.event_departments.event_id', eventId) + .order('sort_order'); + + if (error) { + log.error('fetchEventDeliverables failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { sponsors, ...del } = d; + return del; + }) as SponsorDeliverable[]; +} + // ============================================================ // Sponsors // ============================================================ diff --git a/src/lib/components/documents/FileBrowser.svelte b/src/lib/components/documents/FileBrowser.svelte index fae3729..25286a5 100644 --- a/src/lib/components/documents/FileBrowser.svelte +++ b/src/lib/components/documents/FileBrowser.svelte @@ -1,11 +1,7 @@ + + + - + + + {#if fileDraggingOver} + + + cloud_upload + + Drop files to upload + + + + {/if} + - + {#each breadcrumbPath as crumb, i} @@ -515,7 +663,8 @@ ondragover={(e) => { e.preventDefault(); e.stopPropagation(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + if (e.dataTransfer) + e.dataTransfer.dropEffect = "move"; dragOverBreadcrumb = crumb.id ?? "__root__"; }} ondragleave={() => { @@ -539,7 +688,11 @@ }} > {#if i === 0} - home + home {:else} {crumb.name} {/if} @@ -547,7 +700,28 @@ {/each} - {m.btn_new()} + {#if fileUploading} + + progress_activity + {fileUploadProgress} + + {/if} + fileUploadInput?.click()}>Upload + {m.btn_new()} {#if currentFolderItems.length === 0} - - folder_open + + folder_open {m.files_empty()} {:else} @@ -597,15 +777,31 @@ handleContextMenu(e, item)} > {getDocIcon(item)} - {item.name} + + {item.name} + {#if item.type === "file"} + {@const fileMeta = + getFileMetadata(item)} + {#if fileMeta} + {formatFileSize( + fileMeta.file_size, + )} + {/if} + {/if} + {#if item.type === "folder"} {#if currentFolderItems.length === 0} - - folder_open + + folder_open {m.files_empty()} {:else} @@ -652,7 +854,9 @@ handleContextMenu(e, item)} > {getDocIcon(item)} @@ -661,6 +865,16 @@ class="font-body text-[12px] text-white text-center truncate w-full" >{item.name} + {#if item.type === "file"} + {@const fileMeta = getFileMetadata(item)} + {#if fileMeta} + {formatFileSize( + fileMeta.file_size, + )} + {/if} + {/if} {/each} {/if} diff --git a/src/lib/components/kanban/CardDetailModal.svelte b/src/lib/components/kanban/CardDetailModal.svelte index add8451..c35b54c 100644 --- a/src/lib/components/kanban/CardDetailModal.svelte +++ b/src/lib/components/kanban/CardDetailModal.svelte @@ -12,6 +12,7 @@ import type { KanbanCard } from "$lib/supabase/types"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "$lib/supabase/types"; + import { toasts } from "$lib/stores/toast.svelte"; let isMounted = $state(true); onDestroy(() => { @@ -304,6 +305,9 @@ due_date: dueDate || null, priority, } as KanbanCard); + toasts.success("Changes saved"); + } else { + toasts.error("Failed to save changes"); } isSaving = false; } @@ -338,7 +342,12 @@ .select() .single(); - if (!error && newCard) { + if (error) { + toasts.error("Failed to create card"); + isSaving = false; + return; + } + if (newCard) { // Persist checklist items added during creation if (checklist.length > 0) { await supabase.from("kanban_checklist_items").insert( @@ -359,6 +368,7 @@ })), ); } + toasts.success("Card created"); onCreate?.(newCard as KanbanCard); onClose(); } @@ -611,81 +621,122 @@ onclick={createTag} disabled={!newTagName.trim()} > - + Add + Add {/if} - + - {#each orgTags as tag} - toggleTag(tag.id)} + {#each orgTags.filter((t) => cardTagIds.has(t.id)) as tag} + {tag.name} - - {/each} - {#if !showTagManager} - {#if showTagInput} - - - e.key === "Enter" && createTag()} - /> - toggleTag(tag.id)} + aria-label="Remove tag {tag.name}" + > + close - Add - - + + {/each} + + + {#if !showTagManager} + + (showTagInput = !showTagInput)} + > + + Add tag + + {#if showTagInput} + + + { showTagInput = false; newTagName = ""; }} + > + - Cancel - - - {:else} - (showTagInput = true)} - > - + New tag - - {/if} + {#each orgTags.filter((t) => !cardTagIds.has(t.id)) as tag} + { + toggleTag(tag.id); + }} + > + + {tag.name} + + {/each} + {#if orgTags.filter((t) => !cardTagIds.has(t.id)).length === 0} + + All tags added + + {/if} + + + + e.key === "Enter" && + createTag()} + /> + + Add + + + + + {/if} + {/if} - - - (assigneeId = id)} - /> + + (assigneeId = id)} + /> + + void; onRenameColumn?: (columnId: string, newName: string) => void; canEdit?: boolean; + dateFormat?: string; } let { @@ -30,6 +31,7 @@ onDeleteColumn, onRenameColumn, canEdit = true, + dateFormat = "DD/MM/YYYY", }: Props = $props(); let columnMenuId = $state(null); @@ -83,11 +85,26 @@ dragOverCardIndex = null; } - function dropIndicatorClass(card: KanbanCard, cardIndex: number, columnId: string, totalCards: number): string { - if (!draggedCard || draggedCard.id === card.id || dragOverCardIndex?.columnId !== columnId) return ''; - if (dragOverCardIndex.index === cardIndex) return 'shadow-[0_-3px_0_0_var(--color-primary)]'; - if (dragOverCardIndex.index === cardIndex + 1 && cardIndex === totalCards - 1) return 'shadow-[0_3px_0_0_var(--color-primary)]'; - return ''; + function dropIndicatorClass( + card: KanbanCard, + cardIndex: number, + columnId: string, + totalCards: number, + ): string { + if ( + !draggedCard || + draggedCard.id === card.id || + dragOverCardIndex?.columnId !== columnId + ) + return ""; + if (dragOverCardIndex.index === cardIndex) + return "shadow-[0_-3px_0_0_var(--color-primary)]"; + if ( + dragOverCardIndex.index === cardIndex + 1 && + cardIndex === totalCards - 1 + ) + return "shadow-[0_3px_0_0_var(--color-primary)]"; + return ""; } function handleCardDragOver(e: DragEvent, columnId: string, index: number) { @@ -103,7 +120,8 @@ if ( dragOverCardIndex?.columnId === columnId && dragOverCardIndex?.index === dropIndex - ) return; + ) + return; dragOverColumn = columnId; dragOverCardIndex = { columnId, index: dropIndex }; @@ -255,7 +273,12 @@ {#each column.cards as card, cardIndex} handleCardDragOver(e, column.id, cardIndex)} > @@ -268,6 +291,7 @@ ondelete={canEdit ? (id) => onDeleteCard?.(id) : undefined} + {dateFormat} /> {/each} diff --git a/src/lib/components/kanban/KanbanCard.svelte b/src/lib/components/kanban/KanbanCard.svelte index 4d3f525..6275b10 100644 --- a/src/lib/components/kanban/KanbanCard.svelte +++ b/src/lib/components/kanban/KanbanCard.svelte @@ -1,6 +1,7 @@ - + - - Income - {formatCurrency(totalActualIncome)} - Planned: {formatCurrency(totalPlannedIncome)} + + + {m.budget_income()} + + + {formatCurrency(totalActualIncome)} + + + {m.budget_planned({ + amount: formatCurrency(totalPlannedIncome), + })} + - Expenses - {formatCurrency(totalActualExpense)} - Planned: {formatCurrency(totalPlannedExpense)} + + {m.budget_expenses()} + + + {formatCurrency(totalActualExpense)} + + + {m.budget_planned({ + amount: formatCurrency(totalPlannedExpense), + })} + {#if fullscreen} - - Planned Balance - {formatCurrency(plannedBalance)} + + + {m.budget_planned_balance()} + + + {formatCurrency(plannedBalance)} + - Actual Balance - {formatCurrency(actualBalance)} + + {m.budget_actual_balance()} + + + {formatCurrency(actualBalance)} + {/if} @@ -180,12 +263,20 @@ - {#each ['overview', 'income', 'expense'] as mode} + {#each ["overview", "income", "expense"] as mode} (viewMode = mode as 'overview' | 'income' | 'expense')} + class="px-2.5 py-1 rounded text-[11px] transition-colors {viewMode === + mode + ? 'bg-primary text-background' + : 'text-light/40 hover:text-light/70'}" + onclick={() => + (viewMode = mode as "overview" | "income" | "expense")} > - {mode === 'overview' ? 'All' : mode === 'income' ? 'Income' : 'Expenses'} + {mode === "overview" + ? m.budget_view_all() + : mode === "income" + ? m.budget_view_income() + : m.budget_view_expenses()} {/each} @@ -196,16 +287,27 @@ class="flex items-center gap-1 px-2 py-1 rounded-lg bg-light/5 hover:bg-light/10 text-light/60 text-[11px] transition-colors" onclick={() => (showAddCategoryModal = true)} > - category - Category + category + {m.budget_add_category()} {/if} openAddItem(viewMode === 'income' ? 'income' : 'expense')} + onclick={() => + openAddItem( + viewMode === "income" ? "income" : "expense", + )} > - add - Add Item + add + {m.budget_add_item()} {/if} @@ -214,78 +316,181 @@ {#if filteredItems.length === 0} - - account_balance - No budget items yet + + account_balance + {m.budget_no_items()} {:else} - - Type - Description - Category - Planned - Actual + + {m.budget_col_type()} + + {m.budget_col_description()} + + {m.budget_col_category()} + + {m.budget_col_planned()} + + {m.budget_col_actual()} {#if fullscreen} - Diff - + + {m.budget_col_diff()} + + + {m.budget_col_receipt()} + {:else} - + {/if} {#each filteredItems as item (item.id)} - {@const diff = Number(item.item_type === 'income' ? item.actual_amount - item.planned_amount : item.planned_amount - item.actual_amount)} + {@const diff = Number( + item.item_type === "income" + ? item.actual_amount - item.planned_amount + : item.planned_amount - item.actual_amount, + )} isEditor && openEditItem(item)} - onkeydown={(e) => e.key === 'Enter' && isEditor && openEditItem(item)} + onkeydown={(e) => + e.key === "Enter" && isEditor && openEditItem(item)} role="button" tabindex="0" > - - {item.item_type === 'income' ? 'arrow_downward' : 'arrow_upward'} + + {item.item_type === "income" + ? "arrow_downward" + : "arrow_upward"} - {item.description} + + {item.description} + {getCategoryName(item.category_id)} - {formatCurrency(Number(item.planned_amount))} - {formatCurrency(Number(item.actual_amount))} + + {formatCurrency(Number(item.planned_amount))} + + + {formatCurrency(Number(item.actual_amount))} + {#if fullscreen} - - {diff >= 0 ? '+' : ''}{formatCurrency(diff)} + + {diff >= 0 ? "+" : ""}{formatCurrency(diff)} - + + {#if Number(item.actual_amount) > 0 && !item.receipt_document_id} + { + e.stopPropagation(); + onUploadReceipt?.(item.id); + }} + title={m.budget_missing_receipt()} + > + warning + + {:else if item.receipt_document_id} + check_circle + {/if} {#if isEditor} { e.stopPropagation(); onDeleteItem(item.id); }} + onclick={(e) => { + e.stopPropagation(); + onDeleteItem(item.id); + }} > - delete + delete {/if} {:else} - + + {#if Number(item.actual_amount) > 0 && !item.receipt_document_id} + warning + {:else if item.receipt_document_id} + check_circle + {/if} {#if isEditor} { e.stopPropagation(); onDeleteItem(item.id); }} + onclick={(e) => { + e.stopPropagation(); + onDeleteItem(item.id); + }} > - delete + delete {/if} @@ -294,15 +499,37 @@ {/each} - + - Total - - - {formatCurrency(filteredItems.reduce((s, i) => s + Number(i.planned_amount), 0))} + + {m.budget_total()} - - {formatCurrency(filteredItems.reduce((s, i) => s + Number(i.actual_amount), 0))} + + + {formatCurrency( + filteredItems.reduce( + (s, i) => s + Number(i.planned_amount), + 0, + ), + )} + + + {formatCurrency( + filteredItems.reduce( + (s, i) => s + Number(i.actual_amount), + 0, + ), + )} {#if fullscreen} @@ -317,63 +544,93 @@ {#if showAddItemModal} - (showAddItemModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddItemModal = false)}> + (showAddItemModal = false)} + onkeydown={(e) => e.key === "Escape" && (showAddItemModal = false)} + > - e.stopPropagation()}> - {editingItem ? 'Edit' : 'Add'} Budget Item + e.stopPropagation()} + > + + {editingItem + ? m.budget_edit_item_title() + : m.budget_add_item_title()} + - - Description - + + + + + ({ + value: cat.id, + label: cat.name, + }))} + /> - - Type - - Expense - Income - - - - Category - - Uncategorized - {#each categories as cat} - {cat.name} - {/each} - - + + - - - Planned Amount - - - - Actual Amount - - - - - - Notes - - + - (showAddItemModal = false)}>Cancel - - {editingItem ? 'Save' : 'Add'} + (showAddItemModal = false)} + > + {m.btn_cancel()} + + + {editingItem ? m.btn_save() : m.budget_add_item()} @@ -383,27 +640,51 @@ {#if showAddCategoryModal} - (showAddCategoryModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddCategoryModal = false)}> + (showAddCategoryModal = false)} + onkeydown={(e) => e.key === "Escape" && (showAddCategoryModal = false)} + > - e.stopPropagation()}> - Add Category + e.stopPropagation()} + > + + {m.budget_add_category_title()} + - Name - + + {m.budget_category_name_label()} + + - Color + + {m.budget_category_color_label()} + {#each CATEGORY_COLORS as color} (newCategoryColor = color)} - aria-label="Select color {color}" - > + class="w-6 h-6 rounded-full border-2 transition-all {newCategoryColor === + color + ? 'border-white scale-110' + : 'border-transparent'}" + style="background-color: {color}" + onclick={() => (newCategoryColor = color)} + aria-label={m.budget_select_color({ color })} + > {/each} @@ -411,14 +692,27 @@ {#if categories.length > 0} - Existing Categories + + {m.budget_existing_categories()} + {#each categories as cat} - + {cat.name} {#if isEditor} - onDeleteCategory(cat.id)}> - close + + onDeleteCategory(cat.id)} + > + close {/if} @@ -429,9 +723,17 @@ - (showAddCategoryModal = false)}>Close - - Add + (showAddCategoryModal = false)} + > + {m.btn_close()} + + + {m.budget_add_category()} diff --git a/src/lib/components/modules/ChecklistWidget.svelte b/src/lib/components/modules/ChecklistWidget.svelte index dc49c9f..3ae9526 100644 --- a/src/lib/components/modules/ChecklistWidget.svelte +++ b/src/lib/components/modules/ChecklistWidget.svelte @@ -1,6 +1,7 @@ - + @@ -157,21 +154,24 @@ - - All - {#each CONTACT_CATEGORIES as cat} - {CATEGORY_LABELS[cat] ?? cat} - {/each} - + placeholder="" + options={[ + { value: "all", label: m.contacts_category_all() }, + ...CONTACT_CATEGORIES.map((cat) => ({ + value: cat, + label: CATEGORY_LABELS[cat] ?? cat, + })), + ]} + /> {#if isEditor} openContactModal()}> @@ -180,7 +180,7 @@ style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;" >add - Add + {m.contacts_add()} {/if} @@ -198,14 +198,16 @@ > {contacts.length === 0 - ? "No contacts yet" - : "No matches found"} + ? m.contacts_no_contacts() + : m.contacts_no_results()} {:else} {#each filteredContacts as contact (contact.id)} - + - {CATEGORY_ICONS[contact.category] ?? - "person"} + {CATEGORY_ICONS[ + contact.category ?? "general" + ] ?? "person"} @@ -282,8 +285,9 @@ - {CATEGORY_LABELS[contact.category] ?? - contact.category} + {CATEGORY_LABELS[ + contact.category ?? "general" + ] ?? contact.category} @@ -292,13 +296,11 @@ - + {#if contact.email} Email{m.contacts_email_label()} Phone{m.contacts_phone_label()} Website{m.contacts_website_label()} Role{m.contacts_role_label()} - {contact.role} @@ -352,7 +353,7 @@ {#if contact.notes} Notes{m.contacts_notes_label()} edit - Edit + {m.btn_edit()} - onDelete(contact.id)} + onclick={() => onDelete(contact.id)} > delete - Delete + {m.btn_delete()} {/if} @@ -404,135 +404,85 @@ (showContactModal = false)} - title={editingContact ? "Edit Contact" : "Add Contact"} + title={editingContact ? m.contacts_edit_title() : m.contacts_add_title()} > - - Name * - - - - Company - - - - - - - Role - - - - Category - - {#each CONTACT_CATEGORIES as cat} - {CATEGORY_LABELS[cat] ?? cat} - {/each} - - - - - - - Email - - - - Phone - - - - - - Website - + - - Notes - + + + ({ + value: cat, + label: CATEGORY_LABELS[cat] ?? cat, + }))} + /> + + + + + + + + + (showContactModal = false)}>Cancel (showContactModal = false)} + >{m.btn_cancel()} - {editingContact ? "Save" : "Create"} + {editingContact ? m.btn_save() : m.btn_create()} diff --git a/src/lib/components/modules/FilesWidget.svelte b/src/lib/components/modules/FilesWidget.svelte index 1598b20..d39ea08 100644 --- a/src/lib/components/modules/FilesWidget.svelte +++ b/src/lib/components/modules/FilesWidget.svelte @@ -1,32 +1,487 @@ - - folder - Department files and documents - goto(filesPath)} + folder_off + + {m.files_widget_no_folder()} + + +{:else if loading} + + progress_activity + +{:else} + + - Open Files - - + + + + + {#if draggingOver} + + + cloud_upload + + {m.files_widget_drop_files()} + + + + {/if} + + + {#if uploading} + + progress_activity + {uploadProgress} + + {/if} + + + + {documents.length} item{documents.length !== 1 + ? "s" + : ""} + + fileInput?.click()} + title="Upload files" + > + upload + Upload + + (showCreateModal = true)} + > + add + New + + + goto(`/${orgSlug}/documents/folder/${folderId}`)} + > + open_in_new + {m.files_widget_full_view()} + + + + + + {#if documents.length === 0 && !uploading} + + folder_open + + {m.files_widget_empty()} + + + Drag & drop files here or click Upload + + + {:else} + + {#each documents as doc} + {@const fileMeta = + doc.type === "file" ? getFileMetadata(doc) : null} + handleOpen(doc)} + > + {#if fileMeta} + {getFileIcon(fileMeta.mime_type)} + {:else} + {getIcon(doc.type)} + {/if} + + {doc.name} + {#if fileMeta} + {formatFileSize(fileMeta.file_size)} + {/if} + + {formatDate( + doc.updated_at ?? doc.created_at, + )} + + {/each} + + {/if} + +{/if} + + +{#if showCreateModal} + + (showCreateModal = false)} + onkeydown={(e) => e.key === "Escape" && (showCreateModal = false)} + > + + e.stopPropagation()} + > + + {m.files_widget_create_title()} + + + + + + + (showCreateModal = false)} + >{m.btn_cancel()} + + {creating ? m.btn_creating() : m.btn_create()} + + + + +{/if} diff --git a/src/lib/components/modules/KanbanWidget.svelte b/src/lib/components/modules/KanbanWidget.svelte index 97ac931..e1d77c0 100644 --- a/src/lib/components/modules/KanbanWidget.svelte +++ b/src/lib/components/modules/KanbanWidget.svelte @@ -1,37 +1,236 @@ - - view_kanban - - Task board for this department - - goto(tasksPath())} + folder_off + + {m.kanban_widget_no_folder()} + + +{:else if loading} + + progress_activity + +{:else} + + + + {boards.length} board{boards.length !== 1 ? "s" : ""} + (showCreateModal = true)} + > + add + {m.kanban_widget_create()} + + + + + {#if boards.length === 0} + + view_kanban + + {m.kanban_widget_no_boards()} + + + {:else} + + {#each boards as board} + + goto(`/${orgSlug}/documents/file/${board.id}`)} + > + view_kanban + {board.name} + + {/each} + + {/if} + +{/if} + + +{#if showCreateModal} + + (showCreateModal = false)} + onkeydown={(e) => e.key === "Escape" && (showCreateModal = false)} > - Open Tasks Board - - + + e.stopPropagation()} + > + + {m.kanban_widget_create_title()} + + + {m.kanban_widget_name_label()} + + + + (showCreateModal = false)} + >{m.btn_cancel()} + + {creating ? m.btn_creating() : m.btn_create()} + + + + +{/if} diff --git a/src/lib/components/modules/MapWidget.svelte b/src/lib/components/modules/MapWidget.svelte new file mode 100644 index 0000000..e90d888 --- /dev/null +++ b/src/lib/components/modules/MapWidget.svelte @@ -0,0 +1,1913 @@ + + + + + {#if layers.length > 1 || isEditor} + + {#each layers as layer, idx (layer.id)} + switchLayer(idx)} + oncontextmenu={(e) => { + if (isEditor) openLayerContextMenu(e, layer); + }} + > + {layer.layer_type === "image" + ? "image" + : "public"} + {layer.name} + + {/each} + {#if isEditor} + { + layerName = ""; + showLayerModal = true; + }} + > + add + + {/if} + + {/if} + + + + + + + {#if isEditor && activeLayer} + + {#each toolDefs as t (t.id)} + setTool(t.id)} + title={t.tip} + > + {t.icon} + + {/each} + + + + + { + const allColors = colors; + const current = + activeTool === "pin" ? pinColor : shapeColor; + const idx = allColors.indexOf(current); + const next = allColors[(idx + 1) % allColors.length]; + if (activeTool === "pin") pinColor = next; + else shapeColor = next; + }} + title="Cycle color" + > + + + + + + + (objectsPanelOpen = !objectsPanelOpen)} + title={m.map_objects()} + > + layers + {#if objectCount > 0} + {objectCount} + {/if} + + + + + {exporting ? "progress_activity" : "download"} + + + {:else} + + + (objectsPanelOpen = !objectsPanelOpen)} + title={m.map_objects()} + > + layers + {#if objectCount > 0} + {objectCount} + {/if} + + + {exporting ? "progress_activity" : "download"} + + + {/if} + + + {#if activeTool === "pen" && penVertices.length > 0} + + {m.map_pen_hint_points({ count: penVertices.length })} โ + {m.map_pen_hint_close()} + + {/if} + + + {#if objectsPanelOpen} + + + {m.map_objects()} + (objectsPanelOpen = false)} + > + close + + + + {#if activeLayer} + + {#each activeLayer.pins as pin (pin.id)} + selectObject(pin.id, "pin")} + > + location_on + {pin.label} + {#if isEditor} + { + e.stopPropagation(); + openEditPin(pin); + }} + title="Edit" + > + edit + + { + e.stopPropagation(); + handleDeletePin(pin.id); + }} + title="Delete" + > + close + + {/if} + + {/each} + + {#each activeLayer.shapes ?? [] as shape (shape.id)} + selectObject(shape.id, "shape")} + > + + {shape.shape_type === "rectangle" + ? "crop_landscape" + : "polyline"} + + {shape.label || shape.shape_type} + {#if isEditor} + { + e.stopPropagation(); + openEditShape(shape); + }} + title="Edit" + > + edit + + { + e.stopPropagation(); + duplicateShape(shape.id); + }} + title="Duplicate" + > + content_copy + + { + e.stopPropagation(); + handleDeleteShape(shape.id); + }} + title="Delete" + > + close + + {/if} + + {/each} + {#if pinCount === 0 && shapeCount === 0} + + {m.map_no_objects()} + + {/if} + {/if} + + + {/if} + + + + + { + showPinModal = false; + pendingLatLng = null; + }} + title={editingPin ? m.map_edit_pin() : m.map_add_pin()} +> + + + + + {m.map_color()} + + {#each colors as c} + (pinColor = c)} + > + {/each} + + + + { + showPinModal = false; + pendingLatLng = null; + }}>Cancel + Save + + + + + + (showShapeModal = false)} + title={m.map_edit_shape()} +> + + + + {m.map_color()} + + {#each colors as c} + (shapeColor = c)} + > + {/each} + + + + (showShapeModal = false)}>Cancel + Save + + + + + + (showImageModal = false)} + title={m.map_add_image_layer()} +> + + + {m.map_image_desc()} + + + fileInput?.click()} + disabled={imageUploading} + > + {#if imageUploading} + progress_activity + {m.map_uploading()} + {:else} + upload_file + {m.map_choose_image()} + {/if} + + + + + or + + + + + Load + + + + + + + (showLayerModal = false)} + title={m.map_add_layer()} +> + + + + { + handleAddLayer("osm"); + showLayerModal = false; + }} + > + public + {m.map_street_map()} + {m.map_osm_tiles()} + + { + showLayerModal = false; + showImageModal = true; + }} + > + image + {m.map_custom_image()} + {m.map_upload_venue()} + + + + + + +{#if layerCtx} + + { + e.preventDefault(); + closeLayerContextMenu(); + }} + > + e.stopPropagation()} + > + + edit + Rename + + + delete + Delete + + + +{/if} + + + (showRenameModal = false)} + title={m.map_rename_layer()} +> + + { + if (e.key === "Enter") handleRenameLayer(); + }} + /> + + (showRenameModal = false)}>Cancel + Save + + + + + { + if (e.key === "Escape") { + if (layerCtx) { + closeLayerContextMenu(); + return; + } + if (activeTool !== "grab") { + setTool("grab"); + } else { + deselectAll(); + } + } + const key = e.key.toLowerCase(); + if (key === "h") setTool("grab"); + if (key === "v") setTool("select"); + if (key === "p") setTool("pin"); + if (key === "g") setTool("pen"); + if (key === "r") setTool("rect"); + }} +/> + + diff --git a/src/lib/components/modules/NotesWidget.svelte b/src/lib/components/modules/NotesWidget.svelte index 15895e2..1b71c79 100644 --- a/src/lib/components/modules/NotesWidget.svelte +++ b/src/lib/components/modules/NotesWidget.svelte @@ -1,13 +1,25 @@ {#each notes as note (note.id)} @@ -90,7 +152,7 @@ { if (e.key === "Enter") handleCreateNote(); @@ -112,7 +174,7 @@ style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;" >add - New note + {m.notes_new()} {/if} {/if} @@ -122,14 +184,16 @@ {#if selectedNote} - + {#if editingTitle} { if (e.key === "Enter") confirmTitleEdit(); - if (e.key === "Escape") (editingTitle = false); + if (e.key === "Escape") editingTitle = false; }} onblur={confirmTitleEdit} class="bg-transparent text-body font-heading text-white border-b border-primary outline-none flex-1" @@ -142,27 +206,47 @@ {selectedNote.title} {/if} - {#if isEditor} - { - if (selectedNoteId) onDelete(selectedNoteId); - }} - title="Delete note" - > - delete + {#if folderId && orgId && orgSlug} + - - {/if} + {exporting + ? "progress_activity" + : "upload_file"} + + {/if} + {#if isEditor} + { + if (selectedNoteId) onDelete(selectedNoteId); + }} + title={m.notes_delete()} + > + delete + + {/if} + - {notes.length === 0 ? "No notes yet" : "Select a note"} + {notes.length === 0 ? m.notes_no_notes() : m.notes_select()} {/if} diff --git a/src/lib/components/modules/ScheduleWidget.svelte b/src/lib/components/modules/ScheduleWidget.svelte index d294ccd..7aa1208 100644 --- a/src/lib/components/modules/ScheduleWidget.svelte +++ b/src/lib/components/modules/ScheduleWidget.svelte @@ -1,6 +1,7 @@ - + @@ -207,7 +204,7 @@ : 'text-light/40 hover:text-light/70'}" onclick={() => (viewMode = "timeline")} > - Timeline + {m.schedule_timeline()} (viewMode = "list")} > - List + {m.schedule_list()} {#if isEditor} @@ -230,7 +227,7 @@ style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;" >add - Stage + {m.schedule_manage_stages()} openBlockModal()}> add - Block + {m.schedule_add_block()} {/if} @@ -284,7 +281,7 @@ style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;" >calendar_today - No schedule blocks yet + {m.schedule_no_blocks()} {:else if viewMode === "timeline"} @@ -325,9 +322,8 @@ - {formatTime(block.start_time)} โ {formatTime( - block.end_time, - )} + {formatTime(block.start_time)} โ + {formatTime(block.end_time)} - {new Date( - block.start_time, - ).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - })} + {new Date(block.start_time).toLocaleDateString( + undefined, + { + month: "short", + day: "numeric", + }, + )} {#if block.stage_id} @@ -459,113 +456,69 @@ (showBlockModal = false)} - title={editingBlock ? "Edit Block" : "Add Schedule Block"} + title={editingBlock + ? m.schedule_edit_block_title() + : m.schedule_add_block_title()} > - - Title - - + - - Date - - - - Start - - - - End - - - - - - Speaker / Host - + + + + {#if stages.length > 0} - - Stage - - No stage - {#each stages as stage (stage.id)} - {stage.name} - {/each} - - + ({ value: s.id, label: s.name }))} + /> {/if} - - Description - - + - Color + {m.schedule_block_color_label()} {#each PRESET_COLORS as c} (showBlockModal = false)}>Cancel (showBlockModal = false)} + >{m.btn_cancel()} - {editingBlock ? "Save" : "Create"} + {editingBlock ? m.btn_save() : m.btn_create()} @@ -606,24 +560,25 @@ (showStageModal = false)} - title="Add Stage" + title={m.schedule_add_stage_title()} > - Name{m.schedule_stage_name_placeholder()} - Color + {m.schedule_block_color_label()} {#each PRESET_COLORS as c} (showStageModal = false)}>Cancel (showStageModal = false)} + >{m.btn_cancel()} - Create + {m.btn_create()} diff --git a/src/lib/components/modules/SponsorsWidget.svelte b/src/lib/components/modules/SponsorsWidget.svelte index ee93f91..c9d81dc 100644 --- a/src/lib/components/modules/SponsorsWidget.svelte +++ b/src/lib/components/modules/SponsorsWidget.svelte @@ -1,12 +1,20 @@ - - Confirmed - {formatCurrency(totalCommitted)} - {sponsors.filter((s) => s.status === 'confirmed' || s.status === 'active').length} sponsors + + + Confirmed + + + {formatCurrency(totalCommitted)} + + + {sponsors.filter( + (s) => s.status === "confirmed" || s.status === "active", + ).length} sponsors + - Pipeline - {formatCurrency(totalProspect)} - {sponsors.filter((s) => s.status === 'prospect' || s.status === 'contacted').length} prospects + + Pipeline + + + {formatCurrency(totalProspect)} + + + {sponsors.filter( + (s) => s.status === "prospect" || s.status === "contacted", + ).length} prospects + {#if fullscreen} - Total Sponsors - {sponsors.length} + + Total Sponsors + + + {sponsors.length} + - - Tiers - {tiers.length} + + + Tiers + + + {tiers.length} + {/if} @@ -210,26 +290,29 @@ - - All Statuses - {#each STATUSES as status} - {STATUS_LABELS[status]} - {/each} - + placeholder="" + options={[ + { value: "all", label: m.sponsors_filter_all_statuses() }, + ...STATUSES.map((s) => ({ + value: s, + label: STATUS_LABELS[s], + })), + ]} + /> {#if tiers.length > 0} - - All Tiers - No Tier - {#each tiers as tier} - {tier.name} - {/each} - + placeholder="" + options={[ + { value: "all", label: m.sponsors_filter_all_tiers() }, + { value: "none", label: "No Tier" }, + ...tiers.map((t) => ({ value: t.id, label: t.name })), + ]} + /> {/if} {#if isEditor} @@ -239,16 +322,24 @@ class="flex items-center gap-1 px-2 py-1 rounded-lg bg-light/5 hover:bg-light/10 text-light/60 text-[11px] transition-colors" onclick={() => (showAddTierModal = true)} > - workspace_premium - Tiers + workspace_premium + {m.sponsors_add_tier()} {/if} - add - Add Sponsor + add + {m.sponsors_add_sponsor()} {/if} @@ -257,115 +348,218 @@ {#if filteredSponsors.length === 0} - - handshake - No sponsors yet + + handshake + {m.sponsors_no_sponsors()} {:else} {#each filteredSponsors as sponsor (sponsor.id)} {@const sponsorDeliverables = getDeliverables(sponsor.id)} - {@const completedCount = sponsorDeliverables.filter((d) => d.is_completed).length} + {@const completedCount = sponsorDeliverables.filter( + (d) => d.is_completed, + ).length} {@const isExpanded = expandedSponsor === sponsor.id} - + (expandedSponsor = isExpanded ? null : sponsor.id)} + onclick={() => + (expandedSponsor = isExpanded ? null : sponsor.id)} > - + - {sponsor.name} + {sponsor.name} {getTierName(sponsor.tier_id)} {#if sponsor.contact_name} - {sponsor.contact_name} + + {sponsor.contact_name} + {/if} - {formatCurrency(Number(sponsor.amount))} + {formatCurrency(Number(sponsor.amount))} {STATUS_LABELS[sponsor.status]} {#if sponsorDeliverables.length > 0} - {completedCount}/{sponsorDeliverables.length} + {completedCount}/{sponsorDeliverables.length} {/if} - expand_more + expand_more {#if isExpanded} - + {#if sponsor.contact_email} - - mail + + mail {sponsor.contact_email} {/if} {#if sponsor.contact_phone} - - phone + + phone {sponsor.contact_phone} {/if} {#if sponsor.website} - - language + + language Website {/if} {#if sponsor.notes} - {sponsor.notes} + + {sponsor.notes} + {/if} - Deliverables + + {m.sponsors_deliverables_label()} + {#if sponsorDeliverables.length > 0} {#each sponsorDeliverables as del (del.id)} - + onToggleDeliverable(del.id, !del.is_completed)} + class="w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 transition-colors {del.is_completed + ? 'bg-primary border-primary' + : 'border-light/20 hover:border-primary/50'}" + onclick={() => + onToggleDeliverable( + del.id, + !del.is_completed, + )} disabled={!isEditor} > {#if del.is_completed} - check + check {/if} - {del.description} + {del.description} {#if del.due_date} - {new Date(del.due_date).toLocaleDateString()} + {new Date( + del.due_date, + ).toLocaleDateString()} {/if} {#if isEditor} onDeleteDeliverable(del.id)} + onclick={() => + onDeleteDeliverable( + del.id, + )} > - close + close {/if} @@ -377,15 +571,26 @@ e.key === 'Enter' && handleAddDeliverable(sponsor.id)} + onkeydown={(e) => + e.key === "Enter" && + handleAddDeliverable( + sponsor.id, + )} /> handleAddDeliverable(sponsor.id)} + onclick={() => + handleAddDeliverable( + sponsor.id, + )} > - add + add {/if} @@ -398,14 +603,23 @@ class="flex items-center gap-1 px-2 py-1 rounded-lg bg-light/5 hover:bg-light/10 text-light/60 text-[11px] transition-colors" onclick={() => openEditSponsor(sponsor)} > - edit + edit Edit onDeleteSponsor(sponsor.id)} + onclick={() => + onDeleteSponsor(sponsor.id)} > - delete + delete Delete @@ -421,83 +635,114 @@ {#if showAddSponsorModal} - (showAddSponsorModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddSponsorModal = false)}> + (showAddSponsorModal = false)} + onkeydown={(e) => e.key === "Escape" && (showAddSponsorModal = false)} + > - e.stopPropagation()}> - {editingSponsor ? 'Edit' : 'Add'} Sponsor + e.stopPropagation()} + > + + {editingSponsor + ? m.sponsors_edit_sponsor_title() + : m.sponsors_add_sponsor_title()} + - - Sponsor Name - + + + + ({ + value: t.id, + label: t.name, + }))} + /> + ({ + value: s, + label: STATUS_LABELS[s], + }))} + /> + + + + + + + - - Tier - - No Tier - {#each tiers as tier} - {tier.name} - {/each} - - - - Status - - {#each STATUSES as status} - {STATUS_LABELS[status]} - {/each} - - + + - - Sponsorship Amount - - - - - - Contact Name - - - - Contact Email - - - - - - - Contact Phone - - - - Website - - - - - - Notes - - + - (showAddSponsorModal = false)}>Cancel - - {editingSponsor ? 'Save' : 'Add'} + (showAddSponsorModal = false)} + >{m.btn_cancel()} + + {editingSponsor ? m.btn_save() : m.sponsors_add_sponsor()} @@ -507,32 +752,63 @@ {#if showAddTierModal} - (showAddTierModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddTierModal = false)}> + (showAddTierModal = false)} + onkeydown={(e) => e.key === "Escape" && (showAddTierModal = false)} + > - e.stopPropagation()}> - Manage Tiers + e.stopPropagation()} + > + + {m.sponsors_add_tier_title()} + - Tier Name - + {m.sponsors_tier_name_label()} + - Min. Amount - + {m.sponsors_tier_amount_label()} + - Color + + {m.sponsors_tier_color_label()} + {#each TIER_COLORS as color} (tierColor = color)} - aria-label="Select color {color}" - > + class="w-6 h-6 rounded-full border-2 transition-all {tierColor === + color + ? 'border-white scale-110' + : 'border-transparent'}" + style="background-color: {color}" + onclick={() => (tierColor = color)} + aria-label={m.sponsors_select_color({ color })} + > {/each} @@ -540,18 +816,39 @@ {#if tiers.length > 0} - Existing Tiers + + {m.sponsors_existing_tiers()} + {#each tiers as tier} - + - - {tier.name} - {formatCurrency(Number(tier.amount))} + + {tier.name} + {formatCurrency( + Number(tier.amount), + )} {#if isEditor} - onDeleteTier(tier.id)}> - close + + onDeleteTier(tier.id)} + > + close {/if} @@ -562,9 +859,16 @@ - (showAddTierModal = false)}>Close - - Add Tier + (showAddTierModal = false)} + >{m.btn_close()} + + {m.sponsors_add_tier()} diff --git a/src/lib/components/settings/SettingsGeneral.svelte b/src/lib/components/settings/SettingsGeneral.svelte index 288f535..3369a8c 100644 --- a/src/lib/components/settings/SettingsGeneral.svelte +++ b/src/lib/components/settings/SettingsGeneral.svelte @@ -1,18 +1,55 @@ - + Organization details @@ -175,6 +415,29 @@ bind:value={orgSlug} placeholder="my-org" /> + + + + + Save Changes + + + + + {m.settings_social_title()} + + + {m.settings_social_desc()} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {m.settings_social_save()} + + + + + + + Preferences + + Currency, date format, timezone, and calendar defaults. + + + + + ({ + value: c.code, + label: c.label, + }))} + hint={`Preview: ${currencyPreview}`} + /> + + + + + + ({ + label: g.group, + options: g.zones.map((z) => ({ + value: z, + label: z.replace(/_/g, " "), + })), + }))} + /> + + + + + + + + Save Preferences + + + + + + + Event defaults + + Defaults applied when creating new events and departments. + + + + + + Default event color + + {#each EVENT_COLOR_PRESETS as color} + (defaultEventColor = color)} + > + {/each} + + + colorize + + + + + + + + + + + + + Default department modules + + Modules auto-added when a new department is created. + + + {#each DEPT_MODULE_OPTIONS as mod} + toggleModule(mod.value)} + > + {mod.icon} + {mod.label} + + {/each} + + + + + Save Event Defaults + + + + + + + Features + + Enable or disable features for this organization. + + + + + + + + + + account_balance + + Budget & Finances + + Income/expense tracking, planned vs actual budgets + + + + + + + + + handshake + + Sponsors + + Sponsor CRM with tiers, deliverables, and fund + tracking + + + + + + + + + contacts + + Contacts + + Vendor and contact directory per department + + + + + + + + + Save Features + + + {#if isOwner} - + Danger Zone Permanently delete this organization and all its data. @@ -199,10 +854,15 @@ {#if !isOwner} - - Leave Organization + + + Leave Organization + - Leave this organization. You will need to be re-invited to rejoin. + Leave this organization. You will need to be re-invited to + rejoin. - import { - Button, - Modal, - Input, - Select, - Avatar, - } from "$lib/components/ui"; + import { Button, Modal, Input, Select, Avatar } from "$lib/components/ui"; import { toasts } from "$lib/stores/toast.svelte"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "$lib/supabase/types"; @@ -177,7 +171,11 @@ })} - (showInviteModal = true)}> + (showInviteModal = true)} + > {m.settings_members_invite()} @@ -194,7 +192,9 @@ class="flex items-center justify-between py-2 px-3 bg-light/5 rounded-lg" > - {invite.email} + + {invite.email} + Invited as {invite.role} โข Expires {new Date( invite.expires_at, @@ -211,7 +211,11 @@ )} title={m.settings_members_copy_link()} > - content_copy + content_copy cancelInvite(invite.id)} title="Cancel invite" > - close + close @@ -272,7 +280,11 @@ onclick={() => openMemberModal(member)} title="Edit" > - edit + edit {/if} @@ -289,31 +301,42 @@ title="Invite Member" > - - Email address - - - - Role - + + + (showInviteModal = false)}>Cancel - Viewer - Can view content - Commenter - Can view and comment - Editor - Can create and edit content - Admin - Can manage members and settings - - - - (showInviteModal = false)}>Cancel @@ -351,29 +376,38 @@ - - Role - - Viewer - Commenter - Editor - Admin - - - + + Remove from organization - - (showMemberModal = false)}>Cancel + + (showMemberModal = false)}>Cancel Save + onclick={updateMemberRole}>Save {/if} diff --git a/src/lib/components/ui/ActivityFeed.svelte b/src/lib/components/ui/ActivityFeed.svelte index 1f2db1d..07df16f 100644 --- a/src/lib/components/ui/ActivityFeed.svelte +++ b/src/lib/components/ui/ActivityFeed.svelte @@ -77,7 +77,7 @@ const userName = entry.profiles?.full_name || entry.profiles?.email || "Someone"; const entityType = getEntityTypeLabel(entry.entity_type); - const name = entry.entity_name ?? "โ"; + const name = entry.entity_name ?? "-"; const map: Record string> = { create: () => @@ -95,9 +95,7 @@ {#if entries.length === 0} - + (null); + let dropdownStyle = $state(""); function getAssignee(id: string | null) { if (!id) return null; @@ -30,6 +32,14 @@ const assignee = $derived(getAssignee(value)); + function toggle() { + if (!isOpen && triggerEl) { + const rect = triggerEl.getBoundingClientRect(); + dropdownStyle = `top: ${rect.bottom + 8}px; left: ${rect.left}px; width: ${rect.width}px;`; + } + isOpen = !isOpen; + } + function select(userId: string | null) { onchange(userId); isOpen = false; @@ -43,66 +53,67 @@ {/if} - - (isOpen = !isOpen)} - > - {#if assignee} - - - {assignee.profiles.full_name || assignee.profiles.email} - - {:else} - - Unassigned - {/if} - + + {#if assignee} + + + {assignee.profiles.full_name || assignee.profiles.email} + + {:else} + + Unassigned + {/if} + - {#if isOpen} - - - (isOpen = false)} - > - + + (isOpen = false)} + > + + select(null)} > + + Unassigned + + {#each members as member} select(null)} + class="w-full px-4 py-2.5 text-left text-body-md hover:bg-dark transition-colors flex items-center gap-3 + {value === member.user_id ? 'bg-primary/10 text-primary' : 'text-white'}" + onclick={() => select(member.user_id)} > - - Unassigned + + + {member.profiles.full_name || member.profiles.email} + - {#each members as member} - select(member.user_id)} - > - - - {member.profiles.full_name || member.profiles.email} - - - {/each} - - {/if} - + {/each} + + {/if} diff --git a/src/lib/components/ui/EventCard.svelte b/src/lib/components/ui/EventCard.svelte index 4285283..4a9faa8 100644 --- a/src/lib/components/ui/EventCard.svelte +++ b/src/lib/components/ui/EventCard.svelte @@ -54,14 +54,12 @@ {#if startDate} {formatDate(startDate)}{endDate - ? ` โ ${formatDate(endDate)}` + ? ` - ${formatDate(endDate)}` : ""} {/if} {#if venueName} - ยท {venueName} + ยท {venueName} {/if} @@ -97,7 +95,7 @@ >calendar_today {formatDate(startDate)}{endDate - ? ` โ ${formatDate(endDate)}` + ? ` - ${formatDate(endDate)}` : ""} {/if} diff --git a/src/lib/components/ui/Input.svelte b/src/lib/components/ui/Input.svelte index fb8a2a1..d0615e4 100644 --- a/src/lib/components/ui/Input.svelte +++ b/src/lib/components/ui/Input.svelte @@ -9,6 +9,7 @@ | "number" | "tel" | "date" + | "time" | "datetime-local"; value?: string; placeholder?: string; @@ -20,6 +21,8 @@ autocomplete?: AutoFill; icon?: string; name?: string; + variant?: "default" | "compact"; + class?: string; oninput?: (e: Event) => void; onchange?: (e: Event) => void; onkeydown?: (e: KeyboardEvent) => void; @@ -37,22 +40,28 @@ autocomplete, icon, name, + variant = "default", + class: className, oninput, onchange, onkeydown, }: Props = $props(); + const isCompact = $derived(variant === "compact"); + let showPassword = $state(false); const inputId = `input-${crypto.randomUUID().slice(0, 8)}`; const isPassword = $derived(type === "password"); const inputType = $derived(isPassword && showPassword ? "text" : type); - + {#if label} {#if required}* {/if}{label} @@ -86,15 +95,19 @@ {onchange} {onkeydown} class=" - w-full p-3 bg-background text-white rounded-[32px] min-w-[192px] - font-medium font-input text-body - placeholder:text-white/40 - focus:outline-none focus:ring-2 focus:ring-primary + w-full {isCompact + ? 'px-3 py-2 bg-dark border border-light/10 rounded-xl text-body-sm' + : 'p-3 bg-background rounded-[32px] font-medium font-input text-body'} + text-white placeholder:text-light/30 + focus:outline-none {isCompact + ? 'focus:border-primary' + : 'focus:ring-2 focus:ring-primary'} disabled:opacity-30 disabled:cursor-not-allowed - transition-colors + transition-colors {className ?? ''} " - class:ring-1={error} - class:ring-error={error} + class:ring-1={error && !isCompact} + class:ring-error={error && !isCompact} + class:border-error={error && isCompact} class:pr-12={isPassword} /> {#if isPassword} @@ -118,8 +131,8 @@ {#if error} - {error} + {error} {:else if hint} - {hint} + {hint} {/if} diff --git a/src/lib/components/ui/Modal.svelte b/src/lib/components/ui/Modal.svelte index 896d5b9..8049f33 100644 --- a/src/lib/components/ui/Modal.svelte +++ b/src/lib/components/ui/Modal.svelte @@ -49,14 +49,14 @@ e.stopPropagation()} role="document" transition:fly={{ y: 10, duration: 200, easing: cubicOut }} > {#if title} close + >close {/if} - + {@render children()} diff --git a/src/lib/components/ui/PersonContactModal.svelte b/src/lib/components/ui/PersonContactModal.svelte new file mode 100644 index 0000000..6dbe295 --- /dev/null +++ b/src/lib/components/ui/PersonContactModal.svelte @@ -0,0 +1,183 @@ + + + + {#if profile} + + + + + {displayName} + {#if role || roleName} + + {#if roleName} + {roleName} + {/if} + {#if role && role !== "member"} + {role} + {/if} + + {/if} + {#if departments.length > 0} + + {#each departments as dept} + + + {dept.name} + + {/each} + + {/if} + + + + + + + {#if profile.email} + + mail + + Email + + {profile.email} + + + open_in_new + + {/if} + + + {#if profile.phone} + + call + + Phone + + {profile.phone} + + + open_in_new + + {/if} + + + {#if profile.discord_handle} + + forum + + Discord + + {profile.discord_handle} + + + + {/if} + + + {#if profile.shirt_size || profile.hoodie_size} + + checkroom + + Sizes + + {#if profile.shirt_size}Shirt: {profile.shirt_size}{/if} + {#if profile.shirt_size && profile.hoodie_size} + ยท + {/if} + {#if profile.hoodie_size}Hoodie: {profile.hoodie_size}{/if} + + + + {/if} + + + + {#if !profile.phone && !profile.discord_handle && !profile.shirt_size && !profile.hoodie_size} + + Only email available for this person + + {/if} + {/if} + diff --git a/src/lib/components/ui/Select.svelte b/src/lib/components/ui/Select.svelte index a481eef..b2aef60 100644 --- a/src/lib/components/ui/Select.svelte +++ b/src/lib/components/ui/Select.svelte @@ -4,15 +4,24 @@ label: string; } - interface Props { - value?: string; + interface OptionGroup { + label: string; options: Option[]; + } + + interface Props { + value?: string | null; + options?: Option[]; label?: string; placeholder?: string; error?: string; hint?: string; disabled?: boolean; required?: boolean; + name?: string; + groups?: OptionGroup[]; + variant?: "default" | "compact"; + class?: string; onchange?: (e: Event) => void; } @@ -25,17 +34,24 @@ hint, disabled = false, required = false, + name, + groups, + variant = "default", + class: className, onchange, }: Props = $props(); + const isCompact = $derived(variant === "compact"); const inputId = `select-${crypto.randomUUID().slice(0, 8)}`; - + {#if label} {#if required}* {/if}{label} @@ -46,27 +62,43 @@ bind:value {disabled} {required} + {name} {onchange} - class="w-full p-3 bg-background text-white rounded-[32px] min-w-[192px] - font-medium font-input text-body - focus:outline-none focus:ring-2 focus:ring-primary + class="w-full {isCompact + ? 'px-3 py-2 bg-dark border border-light/10 rounded-xl text-body-sm' + : 'p-3 bg-background rounded-[32px] font-medium font-input text-body'} + text-white + focus:outline-none {isCompact + ? 'focus:border-primary' + : 'focus:ring-2 focus:ring-primary'} disabled:opacity-30 disabled:cursor-not-allowed - transition-colors appearance-none cursor-pointer" - class:ring-1={error} - class:ring-error={error} + transition-colors appearance-none cursor-pointer {className ?? ''}" + class:ring-1={error && !isCompact} + class:ring-error={error && !isCompact} + class:border-error={error && isCompact} > {#if placeholder} {placeholder} {/if} - {#each options as option} - {option.label} - {/each} + {#if groups} + {#each groups as group} + + {#each group.options as option} + {option.label} + {/each} + + {/each} + {:else if options} + {#each options as option} + {option.label} + {/each} + {/if} {#if error} - {error} + {error} {:else if hint} - {hint} + {hint} {/if} diff --git a/src/lib/components/ui/Textarea.svelte b/src/lib/components/ui/Textarea.svelte index fdbd17a..582330c 100644 --- a/src/lib/components/ui/Textarea.svelte +++ b/src/lib/components/ui/Textarea.svelte @@ -9,6 +9,8 @@ required?: boolean; rows?: number; resize?: "none" | "vertical" | "horizontal" | "both"; + variant?: "default" | "compact"; + class?: string; } let { @@ -21,8 +23,11 @@ required = false, rows = 3, resize = "vertical", + variant = "default", + class: className, }: Props = $props(); + const isCompact = $derived(variant === "compact"); const inputId = `textarea-${crypto.randomUUID().slice(0, 8)}`; const resizeClasses = { @@ -33,11 +38,13 @@ }; - + {#if label} {#if required}* {/if}{label} @@ -50,19 +57,23 @@ {disabled} {required} {rows} - class="w-full p-3 bg-background text-white rounded-2xl min-w-[192px] - font-medium font-input text-body - placeholder:text-white/40 - focus:outline-none focus:ring-2 focus:ring-primary + class="w-full {isCompact + ? 'px-3 py-2 bg-dark border border-light/10 rounded-xl text-body-sm' + : 'p-3 bg-background rounded-2xl font-medium font-input text-body'} + text-white placeholder:text-light/30 + focus:outline-none {isCompact + ? 'focus:border-primary' + : 'focus:ring-2 focus:ring-primary'} disabled:opacity-30 disabled:cursor-not-allowed - transition-colors {resizeClasses[resize]}" - class:ring-1={error} - class:ring-error={error} + transition-colors {resizeClasses[resize]} {className ?? ''}" + class:ring-1={error && !isCompact} + class:ring-error={error && !isCompact} + class:border-error={error && isCompact} > {#if error} - {error} + {error} {:else if hint} - {hint} + {hint} {/if} diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts index 305a794..6639fc4 100644 --- a/src/lib/components/ui/index.ts +++ b/src/lib/components/ui/index.ts @@ -41,3 +41,4 @@ export { default as ImagePreviewModal } from './ImagePreviewModal.svelte'; export { default as Twemoji } from './Twemoji.svelte'; export { default as EmojiPicker } from './EmojiPicker.svelte'; export { default as VirtualList } from './VirtualList.svelte'; +export { default as PersonContactModal } from './PersonContactModal.svelte'; diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 92c1c9f..5b261c2 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -1,4 +1,4 @@ -๏ปฟexport type Json = +export type Json = | string | number | boolean @@ -65,6 +65,111 @@ export type Database = { }, ] } + budget_categories: { + Row: { + color: string | null + created_at: string | null + department_id: string + id: string + name: string + sort_order: number + } + Insert: { + color?: string | null + created_at?: string | null + department_id: string + id?: string + name: string + sort_order?: number + } + Update: { + color?: string | null + created_at?: string | null + department_id?: string + id?: string + name?: string + sort_order?: number + } + Relationships: [ + { + foreignKeyName: "budget_categories_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + ] + } + budget_items: { + Row: { + actual_amount: number + category_id: string | null + created_at: string | null + created_by: string | null + department_id: string + description: string + id: string + item_type: string + notes: string | null + planned_amount: number + receipt_document_id: string | null + sort_order: number + updated_at: string | null + } + Insert: { + actual_amount?: number + category_id?: string | null + created_at?: string | null + created_by?: string | null + department_id: string + description: string + id?: string + item_type?: string + notes?: string | null + planned_amount?: number + receipt_document_id?: string | null + sort_order?: number + updated_at?: string | null + } + Update: { + actual_amount?: number + category_id?: string | null + created_at?: string | null + created_by?: string | null + department_id?: string + description?: string + id?: string + item_type?: string + notes?: string | null + planned_amount?: number + receipt_document_id?: string | null + sort_order?: number + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "budget_items_category_id_fkey" + columns: ["category_id"] + isOneToOne: false + referencedRelation: "budget_categories" + referencedColumns: ["id"] + }, + { + foreignKeyName: "budget_items_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "budget_items_receipt_document_id_fkey" + columns: ["receipt_document_id"] + isOneToOne: false + referencedRelation: "documents" + referencedColumns: ["id"] + }, + ] + } calendar_events: { Row: { all_day: boolean | null @@ -362,6 +467,65 @@ export type Database = { }, ] } + department_contacts: { + Row: { + category: string | null + color: string | null + company: string | null + created_at: string + created_by: string | null + department_id: string + email: string | null + id: string + name: string + notes: string | null + phone: string | null + role: string | null + updated_at: string + website: string | null + } + Insert: { + category?: string | null + color?: string | null + company?: string | null + created_at?: string + created_by?: string | null + department_id: string + email?: string | null + id?: string + name: string + notes?: string | null + phone?: string | null + role?: string | null + updated_at?: string + website?: string | null + } + Update: { + category?: string | null + color?: string | null + company?: string | null + created_at?: string + created_by?: string | null + department_id?: string + email?: string | null + id?: string + name?: string + notes?: string | null + phone?: string | null + role?: string | null + updated_at?: string + website?: string | null + } + Relationships: [ + { + foreignKeyName: "department_contacts_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + ] + } department_dashboards: { Row: { created_at: string | null @@ -438,6 +602,42 @@ export type Database = { }, ] } + department_pinned_contacts: { + Row: { + contact_id: string + department_id: string + id: string + pinned_at: string + } + Insert: { + contact_id: string + department_id: string + id?: string + pinned_at?: string + } + Update: { + contact_id?: string + department_id?: string + id?: string + pinned_at?: string + } + Relationships: [ + { + foreignKeyName: "department_pinned_contacts_contact_id_fkey" + columns: ["contact_id"] + isOneToOne: false + referencedRelation: "org_contacts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "department_pinned_contacts_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + ] + } document_locks: { Row: { document_id: string @@ -475,6 +675,8 @@ export type Database = { content: Json | null created_at: string | null created_by: string | null + department_id: string | null + event_id: string | null id: string name: string org_id: string | null @@ -488,6 +690,8 @@ export type Database = { content?: Json | null created_at?: string | null created_by?: string | null + department_id?: string | null + event_id?: string | null id?: string name: string org_id?: string | null @@ -501,6 +705,8 @@ export type Database = { content?: Json | null created_at?: string | null created_by?: string | null + department_id?: string | null + event_id?: string | null id?: string name?: string org_id?: string | null @@ -511,6 +717,20 @@ export type Database = { updated_at?: string | null } Relationships: [ + { + foreignKeyName: "documents_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "documents_event_id_fkey" + columns: ["event_id"] + isOneToOne: false + referencedRelation: "events" + referencedColumns: ["id"] + }, { foreignKeyName: "documents_org_id_fkey" columns: ["org_id"] @@ -562,6 +782,7 @@ export type Database = { event_id: string id: string name: string + planned_budget: number sort_order: number } Insert: { @@ -572,6 +793,7 @@ export type Database = { event_id: string id?: string name: string + planned_budget?: number sort_order?: number } Update: { @@ -582,6 +804,7 @@ export type Database = { event_id?: string id?: string name?: string + planned_budget?: number sort_order?: number } Relationships: [ @@ -1170,6 +1393,65 @@ export type Database = { }, ] } + org_contacts: { + Row: { + category: string | null + color: string | null + company: string | null + created_at: string + created_by: string | null + email: string | null + id: string + name: string + notes: string | null + org_id: string + phone: string | null + role: string | null + updated_at: string + website: string | null + } + Insert: { + category?: string | null + color?: string | null + company?: string | null + created_at?: string + created_by?: string | null + email?: string | null + id?: string + name: string + notes?: string | null + org_id: string + phone?: string | null + role?: string | null + updated_at?: string + website?: string | null + } + Update: { + category?: string | null + color?: string | null + company?: string | null + created_at?: string + created_by?: string | null + email?: string | null + id?: string + name?: string + notes?: string | null + org_id?: string + phone?: string | null + role?: string | null + updated_at?: string + website?: string | null + } + Relationships: [ + { + foreignKeyName: "org_contacts_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } org_google_calendars: { Row: { calendar_id: string @@ -1365,35 +1647,80 @@ export type Database = { Row: { avatar_url: string | null created_at: string | null + currency: string + date_format: string + default_calendar_view: string + default_dept_layout: string + default_dept_modules: string[] + default_event_color: string + default_event_status: string + description: string | null + feature_budget: boolean + feature_chat: boolean + feature_contacts: boolean + feature_sponsors: boolean icon_url: string | null id: string matrix_space_id: string | null name: string slug: string + social_links: Json | null theme_color: string | null + timezone: string updated_at: string | null + week_start_day: string } Insert: { avatar_url?: string | null created_at?: string | null + currency?: string + date_format?: string + default_calendar_view?: string + default_dept_layout?: string + default_dept_modules?: string[] + default_event_color?: string + default_event_status?: string + description?: string | null + feature_budget?: boolean + feature_chat?: boolean + feature_contacts?: boolean + feature_sponsors?: boolean icon_url?: string | null id?: string matrix_space_id?: string | null name: string slug: string + social_links?: Json | null theme_color?: string | null + timezone?: string updated_at?: string | null + week_start_day?: string } Update: { avatar_url?: string | null created_at?: string | null + currency?: string + date_format?: string + default_calendar_view?: string + default_dept_layout?: string + default_dept_modules?: string[] + default_event_color?: string + default_event_status?: string + description?: string | null + feature_budget?: boolean + feature_chat?: boolean + feature_contacts?: boolean + feature_sponsors?: boolean icon_url?: string | null id?: string matrix_space_id?: string | null name?: string slug?: string + social_links?: Json | null theme_color?: string | null + timezone?: string updated_at?: string | null + week_start_day?: string } Relationships: [] } @@ -1406,6 +1733,7 @@ export type Database = { full_name: string | null hoodie_size: string | null id: string + is_platform_admin: boolean phone: string | null shirt_size: string | null updated_at: string | null @@ -1418,6 +1746,7 @@ export type Database = { full_name?: string | null hoodie_size?: string | null id: string + is_platform_admin?: boolean phone?: string | null shirt_size?: string | null updated_at?: string | null @@ -1430,12 +1759,310 @@ export type Database = { full_name?: string | null hoodie_size?: string | null id?: string + is_platform_admin?: boolean phone?: string | null shirt_size?: string | null updated_at?: string | null } Relationships: [] } + schedule_blocks: { + Row: { + color: string | null + created_at: string + created_by: string | null + department_id: string + description: string | null + end_time: string + id: string + sort_order: number + speaker: string | null + stage_id: string | null + start_time: string + title: string + updated_at: string + } + Insert: { + color?: string | null + created_at?: string + created_by?: string | null + department_id: string + description?: string | null + end_time: string + id?: string + sort_order?: number + speaker?: string | null + stage_id?: string | null + start_time: string + title: string + updated_at?: string + } + Update: { + color?: string | null + created_at?: string + created_by?: string | null + department_id?: string + description?: string | null + end_time?: string + id?: string + sort_order?: number + speaker?: string | null + stage_id?: string | null + start_time?: string + title?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "schedule_blocks_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "schedule_blocks_stage_id_fkey" + columns: ["stage_id"] + isOneToOne: false + referencedRelation: "schedule_stages" + referencedColumns: ["id"] + }, + ] + } + schedule_stages: { + Row: { + color: string | null + created_at: string + department_id: string + id: string + name: string + sort_order: number + } + Insert: { + color?: string | null + created_at?: string + department_id: string + id?: string + name: string + sort_order?: number + } + Update: { + color?: string | null + created_at?: string + department_id?: string + id?: string + name?: string + sort_order?: number + } + Relationships: [ + { + foreignKeyName: "schedule_stages_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + ] + } + sponsor_allocations: { + Row: { + allocated_amount: number + created_at: string + created_by: string | null + department_id: string + id: string + notes: string | null + sponsor_id: string + updated_at: string + used_amount: number + } + Insert: { + allocated_amount?: number + created_at?: string + created_by?: string | null + department_id: string + id?: string + notes?: string | null + sponsor_id: string + updated_at?: string + used_amount?: number + } + Update: { + allocated_amount?: number + created_at?: string + created_by?: string | null + department_id?: string + id?: string + notes?: string | null + sponsor_id?: string + updated_at?: string + used_amount?: number + } + Relationships: [ + { + foreignKeyName: "sponsor_allocations_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "sponsor_allocations_sponsor_id_fkey" + columns: ["sponsor_id"] + isOneToOne: false + referencedRelation: "sponsors" + referencedColumns: ["id"] + }, + ] + } + sponsor_deliverables: { + Row: { + created_at: string | null + description: string + due_date: string | null + id: string + is_completed: boolean + sort_order: number + sponsor_id: string + updated_at: string | null + } + Insert: { + created_at?: string | null + description: string + due_date?: string | null + id?: string + is_completed?: boolean + sort_order?: number + sponsor_id: string + updated_at?: string | null + } + Update: { + created_at?: string | null + description?: string + due_date?: string | null + id?: string + is_completed?: boolean + sort_order?: number + sponsor_id?: string + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "sponsor_deliverables_sponsor_id_fkey" + columns: ["sponsor_id"] + isOneToOne: false + referencedRelation: "sponsors" + referencedColumns: ["id"] + }, + ] + } + sponsor_tiers: { + Row: { + amount: number | null + color: string | null + created_at: string | null + department_id: string + id: string + name: string + sort_order: number + } + Insert: { + amount?: number | null + color?: string | null + created_at?: string | null + department_id: string + id?: string + name: string + sort_order?: number + } + Update: { + amount?: number | null + color?: string | null + created_at?: string | null + department_id?: string + id?: string + name?: string + sort_order?: number + } + Relationships: [ + { + foreignKeyName: "sponsor_tiers_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + ] + } + sponsors: { + Row: { + amount: number | null + contact_email: string | null + contact_name: string | null + contact_phone: string | null + created_at: string | null + created_by: string | null + department_id: string + id: string + logo_url: string | null + name: string + notes: string | null + status: string + tier_id: string | null + updated_at: string | null + website: string | null + } + Insert: { + amount?: number | null + contact_email?: string | null + contact_name?: string | null + contact_phone?: string | null + created_at?: string | null + created_by?: string | null + department_id: string + id?: string + logo_url?: string | null + name: string + notes?: string | null + status?: string + tier_id?: string | null + updated_at?: string | null + website?: string | null + } + Update: { + amount?: number | null + contact_email?: string | null + contact_name?: string | null + contact_phone?: string | null + created_at?: string | null + created_by?: string | null + department_id?: string + id?: string + logo_url?: string | null + name?: string + notes?: string | null + status?: string + tier_id?: string | null + updated_at?: string | null + website?: string | null + } + Relationships: [ + { + foreignKeyName: "sponsors_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "sponsors_tier_id_fkey" + columns: ["tier_id"] + isOneToOne: false + referencedRelation: "sponsor_tiers" + referencedColumns: ["id"] + }, + ] + } tags: { Row: { color: string | null @@ -1589,14 +2216,28 @@ export type Database = { } Functions: { compute_document_path: { Args: { doc_id: string }; Returns: string } - get_next_document_position: { - Args: { folder_id: string } - Returns: number + create_default_org_roles: { + Args: { p_org_id: string } + Returns: undefined } + get_next_document_position: + | { Args: { folder_id: string }; Returns: number } + | { Args: { p_org_id: string; p_parent_id: string }; Returns: number } is_org_member: { Args: { org_id: string }; Returns: boolean } + is_platform_admin: { Args: never; Returns: boolean } + seed_event_task_columns: { + Args: { p_event_id: string } + Returns: undefined + } } Enums: { - layout_preset: "single" | "split" | "grid" | "focus_sidebar" | "custom" + layout_preset: + | "single" + | "split" + | "grid" + | "focus_sidebar" + | "custom" + | "triple" module_type: | "kanban" | "files" @@ -1604,6 +2245,9 @@ export type Database = { | "notes" | "schedule" | "contacts" + | "budget" + | "sponsors" + | "map" } CompositeTypes: { [_ in never]: never @@ -1731,7 +2375,14 @@ export type CompositeTypes< export const Constants = { public: { Enums: { - layout_preset: ["single", "split", "grid", "focus_sidebar", "custom"], + layout_preset: [ + "single", + "split", + "grid", + "focus_sidebar", + "custom", + "triple", + ], module_type: [ "kanban", "files", @@ -1739,138 +2390,68 @@ export const Constants = { "notes", "schedule", "contacts", + "budget", + "sponsors", + "map", ], }, }, } as const -// ============================================================ -// Convenience type aliases used throughout the codebase -// ============================================================ -export type Profile = Tables<'profiles'>; -export type Organization = Tables<'organizations'>; -export type Document = Tables<'documents'>; -export type KanbanBoard = Tables<'kanban_boards'>; -export type KanbanColumn = Tables<'kanban_columns'>; -export type KanbanCard = Tables<'kanban_cards'>; -export type CalendarEvent = Tables<'calendar_events'>; -export type OrgRole = Tables<'org_roles'>; -export type MemberRole = string; -export type EventTaskColumn = Tables<'event_task_columns'>; -export type EventTask = Tables<'event_tasks'>; -export type DepartmentDashboard = Tables<'department_dashboards'>; -export type DashboardPanel = Tables<'dashboard_panels'>; -export type DepartmentChecklist = Tables<'department_checklists'>; -export type DepartmentChecklistItem = Tables<'department_checklist_items'>; -export type DepartmentNote = Tables<'department_notes'>; +// โโ Convenience type aliases โโ +type TableRow = Database['public']['Tables'][T]['Row']; + +export type Profile = TableRow<'profiles'>; +export type Organization = TableRow<'organizations'>; +export type Document = TableRow<'documents'>; +export type KanbanBoard = TableRow<'kanban_boards'>; +export type KanbanColumn = TableRow<'kanban_columns'>; +export type KanbanCard = TableRow<'kanban_cards'>; +export type KanbanComment = TableRow<'kanban_comments'>; +export type KanbanLabel = TableRow<'kanban_labels'>; +export type CardLabel = TableRow<'card_labels'>; +export type CardTag = TableRow<'card_tags'>; +export type ChecklistItem = TableRow<'checklist_items'>; +export type CardAssignee = TableRow<'card_assignees'>; +export type CalendarEvent = TableRow<'calendar_events'>; +export type OrgRole = TableRow<'org_roles'>; +export type OrgMember = TableRow<'org_members'>; +export type OrgInvite = TableRow<'org_invites'>; +export type OrgContact = TableRow<'org_contacts'>; +export type OrgGoogleCalendar = TableRow<'org_google_calendars'>; +export type Tag = TableRow<'tags'>; +export type ActivityLog = TableRow<'activity_log'>; +export type DocumentLock = TableRow<'document_locks'>; +export type MatrixCredential = TableRow<'matrix_credentials'>; +export type UserPreference = TableRow<'user_preferences'>; +export type Team = TableRow<'teams'>; +export type TeamMember = TableRow<'team_members'>; +export type Event = TableRow<'events'>; +export type EventMember = TableRow<'event_members'>; +export type EventRole = TableRow<'event_roles'>; +export type EventDepartment = TableRow<'event_departments'>; +export type EventMemberDepartment = TableRow<'event_member_departments'>; +export type EventAttendee = TableRow<'event_attendees'>; +export type EventTaskColumn = TableRow<'event_task_columns'>; +export type EventTask = TableRow<'event_tasks'>; +export type DepartmentDashboard = TableRow<'department_dashboards'>; +export type DashboardPanel = TableRow<'dashboard_panels'>; +export type DepartmentChecklist = TableRow<'department_checklists'>; +export type DepartmentChecklistItem = TableRow<'department_checklist_items'>; +export type DepartmentNote = TableRow<'department_notes'>; +export type DepartmentContact = TableRow<'department_contacts'>; +export type DepartmentPinnedContact = TableRow<'department_pinned_contacts'>; +export type ScheduleStage = TableRow<'schedule_stages'>; +export type ScheduleBlock = TableRow<'schedule_blocks'>; +export type BudgetCategory = TableRow<'budget_categories'>; +export type BudgetItem = TableRow<'budget_items'>; +export type SponsorTier = TableRow<'sponsor_tiers'>; +export type Sponsor = TableRow<'sponsors'>; +export type SponsorDeliverable = TableRow<'sponsor_deliverables'>; +export type SponsorAllocation = TableRow<'sponsor_allocations'>; +export type KanbanChecklistItem = TableRow<'kanban_checklist_items'>; + export type ModuleType = Database['public']['Enums']['module_type']; export type LayoutPreset = Database['public']['Enums']['layout_preset']; -// Schedule/Timeline types (migration 028 โ use db() cast until types regenerated) -export interface ScheduleStage { - id: string; - department_id: string; - name: string; - color: string | null; - sort_order: number; - created_at: string; -} - -export interface ScheduleBlock { - id: string; - department_id: string; - stage_id: string | null; - title: string; - description: string | null; - start_time: string; - end_time: string; - color: string | null; - speaker: string | null; - sort_order: number; - created_by: string | null; - created_at: string; - updated_at: string; -} - -// Budget/Finance types (migration 029 โ use db() cast until types regenerated) -export interface BudgetCategory { - id: string; - department_id: string; - name: string; - color: string | null; - sort_order: number; - created_at: string; -} - -export interface BudgetItem { - id: string; - department_id: string; - category_id: string | null; - description: string; - item_type: 'income' | 'expense'; - planned_amount: number; - actual_amount: number; - notes: string | null; - sort_order: number; - created_by: string | null; - created_at: string; - updated_at: string; -} - -// Sponsors & Partners types (migration 029) -export interface SponsorTier { - id: string; - department_id: string; - name: string; - amount: number; - color: string | null; - sort_order: number; - created_at: string; -} - -export interface Sponsor { - id: string; - department_id: string; - tier_id: string | null; - name: string; - contact_name: string | null; - contact_email: string | null; - contact_phone: string | null; - website: string | null; - logo_url: string | null; - status: 'prospect' | 'contacted' | 'confirmed' | 'declined' | 'active'; - amount: number; - notes: string | null; - created_by: string | null; - created_at: string; - updated_at: string; -} - -export interface SponsorDeliverable { - id: string; - sponsor_id: string; - description: string; - is_completed: boolean; - due_date: string | null; - sort_order: number; - created_at: string; - updated_at: string; -} - -// Contacts/Vendor Directory types (migration 028) -export interface DepartmentContact { - id: string; - department_id: string; - name: string; - role: string | null; - company: string | null; - email: string | null; - phone: string | null; - website: string | null; - notes: string | null; - category: string; - color: string | null; - created_by: string | null; - created_at: string; - updated_at: string; -} +export type MemberRole = string; diff --git a/src/lib/types/dom-to-image-more.d.ts b/src/lib/types/dom-to-image-more.d.ts new file mode 100644 index 0000000..6ab18ad --- /dev/null +++ b/src/lib/types/dom-to-image-more.d.ts @@ -0,0 +1,10 @@ +declare module 'dom-to-image-more' { + const domtoimage: { + toBlob(node: HTMLElement, options?: Record): Promise; + toPng(node: HTMLElement, options?: Record): Promise; + toJpeg(node: HTMLElement, options?: Record): Promise; + toSvg(node: HTMLElement, options?: Record): Promise; + toPixelData(node: HTMLElement, options?: Record): Promise; + }; + export default domtoimage; +} diff --git a/src/lib/utils/currency.ts b/src/lib/utils/currency.ts new file mode 100644 index 0000000..b8de224 --- /dev/null +++ b/src/lib/utils/currency.ts @@ -0,0 +1,174 @@ +/** + * Shared currency formatting utility. + * Reads org-level currency setting and formats amounts accordingly. + * + * For EUR, the symbol goes AFTER the number (e.g. "1 234,56 โฌ"). + * For USD/GBP, the symbol goes BEFORE (e.g. "$1,234.56"). + */ + +export interface CurrencyConfig { + currency: string; // ISO 4217 code: EUR, USD, GBP, etc. +} + +/** Locale map: which locale to use for each currency so symbol placement is correct */ +const CURRENCY_LOCALE_MAP: Record = { + EUR: 'de-DE', // puts โฌ after number: 1.234,56 โฌ + USD: 'en-US', // puts $ before number: $1,234.56 + GBP: 'en-GB', // puts ยฃ before number: ยฃ1,234.56 + SEK: 'sv-SE', // puts kr after number: 1 234,56 kr + NOK: 'nb-NO', // puts kr after number: 1 234,56 kr + DKK: 'da-DK', // puts kr after number: 1.234,56 kr + CHF: 'de-CH', // puts CHF before number: CHF 1'234.56 + PLN: 'pl-PL', // puts zล after number: 1 234,56 zล + CZK: 'cs-CZ', // puts Kฤ after number: 1 234,56 Kฤ + JPY: 'ja-JP', // puts ยฅ before number: ยฅ1,234 + CAD: 'en-CA', // puts $ before number: $1,234.56 + AUD: 'en-AU', // puts $ before number: $1,234.56 +}; + +/** All supported currencies with labels */ +export const SUPPORTED_CURRENCIES = [ + { code: 'EUR', label: 'Euro (โฌ)', symbol: 'โฌ' }, + { code: 'USD', label: 'US Dollar ($)', symbol: '$' }, + { code: 'GBP', label: 'British Pound (ยฃ)', symbol: 'ยฃ' }, + { code: 'SEK', label: 'Swedish Krona (kr)', symbol: 'kr' }, + { code: 'NOK', label: 'Norwegian Krone (kr)', symbol: 'kr' }, + { code: 'DKK', label: 'Danish Krone (kr)', symbol: 'kr' }, + { code: 'CHF', label: 'Swiss Franc (CHF)', symbol: 'CHF' }, + { code: 'PLN', label: 'Polish Zลoty (zล)', symbol: 'zล' }, + { code: 'CZK', label: 'Czech Koruna (Kฤ)', symbol: 'Kฤ' }, + { code: 'JPY', label: 'Japanese Yen (ยฅ)', symbol: 'ยฅ' }, + { code: 'CAD', label: 'Canadian Dollar ($)', symbol: '$' }, + { code: 'AUD', label: 'Australian Dollar ($)', symbol: '$' }, +] as const; + +/** + * Format a number as currency using the org's currency setting. + * Uses the correct locale for symbol placement. + */ +export function formatCurrency(amount: number, currency: string = 'EUR'): string { + const locale = CURRENCY_LOCALE_MAP[currency] ?? 'en-US'; + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + }).format(amount); +} + +/** Common timezones grouped by region */ +export const TIMEZONE_OPTIONS = [ + { + group: 'Europe', zones: [ + 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Helsinki', + 'Europe/Tallinn', 'Europe/Riga', 'Europe/Vilnius', 'Europe/Stockholm', + 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Warsaw', 'Europe/Prague', + 'Europe/Zurich', 'Europe/Amsterdam', 'Europe/Brussels', 'Europe/Madrid', + 'Europe/Rome', 'Europe/Athens', 'Europe/Bucharest', 'Europe/Moscow', + ] + }, + { + group: 'Americas', zones: [ + 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', + 'America/Toronto', 'America/Vancouver', 'America/Sao_Paulo', 'America/Mexico_City', + ] + }, + { + group: 'Asia / Pacific', zones: [ + 'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Singapore', 'Asia/Dubai', + 'Asia/Kolkata', 'Asia/Seoul', 'Australia/Sydney', 'Australia/Melbourne', + 'Pacific/Auckland', + ] + }, +]; + +/** + * Format a Date or ISO string using the org's date_format setting. + * Supports DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD. + * If `short` is true, returns abbreviated format (e.g. "31 Dec" or "Dec 31"). + */ +export function formatDate( + dateInput: string | Date | null | undefined, + dateFormat: string = 'DD/MM/YYYY', + short: boolean = false, +): string { + if (!dateInput) return ''; + const d = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; + if (isNaN(d.getTime())) return ''; + + const day = d.getDate(); + const month = d.getMonth(); // 0-indexed + const year = d.getFullYear(); + const pad = (n: number) => String(n).padStart(2, '0'); + + const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + if (short) { + // Short format for card previews etc. + if (dateFormat === 'MM/DD/YYYY') { + return `${MONTHS_SHORT[month]} ${day}`; + } + return `${day} ${MONTHS_SHORT[month]}`; + } + + switch (dateFormat) { + case 'MM/DD/YYYY': + return `${pad(month + 1)}/${pad(day)}/${year}`; + case 'YYYY-MM-DD': + return `${year}-${pad(month + 1)}-${pad(day)}`; + case 'DD/MM/YYYY': + default: + return `${pad(day)}/${pad(month + 1)}/${year}`; + } +} + +/** Date format options */ +export const DATE_FORMAT_OPTIONS = [ + { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY (31/12/2026)' }, + { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY (12/31/2026)' }, + { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD (2026-12-31)' }, +]; + +/** Week start day options */ +export const WEEK_START_OPTIONS = [ + { value: 'monday', label: 'Monday' }, + { value: 'sunday', label: 'Sunday' }, +]; + +/** Calendar view options */ +export const CALENDAR_VIEW_OPTIONS = [ + { value: 'month', label: 'Month' }, + { value: 'week', label: 'Week' }, + { value: 'day', label: 'Day' }, +]; + +/** Event status options */ +export const EVENT_STATUS_OPTIONS = [ + { value: 'planning', label: 'Planning' }, + { value: 'active', label: 'Active' }, +]; + +/** Dashboard layout options */ +export const DASHBOARD_LAYOUT_OPTIONS = [ + { value: 'single', label: 'Single column' }, + { value: 'split', label: 'Split (2 columns)' }, + { value: 'grid', label: 'Grid (2ร2)' }, + { value: 'focus_sidebar', label: 'Focus + sidebar' }, +]; + +/** Department module options */ +export const DEPT_MODULE_OPTIONS = [ + { value: 'kanban', label: 'Kanban', icon: 'view_kanban' }, + { value: 'files', label: 'Files', icon: 'folder' }, + { value: 'checklist', label: 'Checklist', icon: 'checklist' }, + { value: 'notes', label: 'Notes', icon: 'notes' }, + { value: 'schedule', label: 'Schedule', icon: 'schedule' }, + { value: 'contacts', label: 'Contacts', icon: 'contacts' }, + { value: 'budget', label: 'Budget', icon: 'account_balance' }, + { value: 'sponsors', label: 'Sponsors', icon: 'handshake' }, +]; + +/** Event color presets */ +export const EVENT_COLOR_PRESETS = [ + '#7986cb', '#33b679', '#8e24aa', '#e67c73', + '#f6bf26', '#f4511e', '#039be5', '#616161', + '#3f51b5', '#0b8043', '#d50000', '#f09300', +]; diff --git a/src/lib/utils/logger.test.ts b/src/lib/utils/logger.test.ts index 7032e94..c39303b 100644 --- a/src/lib/utils/logger.test.ts +++ b/src/lib/utils/logger.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { createLogger, setLogLevel, getRecentLogs, clearRecentLogs, dumpLogs } from './logger'; +import { createLogger, setLogLevel, getRecentLogs, clearRecentLogs, dumpLogs, getErrorMessage } from './logger'; describe('logger', () => { beforeEach(() => { @@ -111,4 +111,52 @@ describe('logger', () => { expect(recent[0].message).toBe('msg 10'); expect(recent[99].message).toBe('msg 109'); }); + + it('dumpLogs includes error formatting', () => { + const log = createLogger('ctx'); + log.error('fail', { error: new Error('boom'), data: { id: 1 } }); + + const dump = dumpLogs(); + expect(dump).toContain('[ERROR]'); + expect(dump).toContain('error: Error: boom'); + expect(dump).toContain('data:'); + }); + + it('debug level logs when minLevel is debug', () => { + setLogLevel('debug'); + const log = createLogger('test'); + log.debug('debug msg'); + expect(getRecentLogs()).toHaveLength(1); + expect(getRecentLogs()[0].level).toBe('debug'); + }); +}); + +describe('getErrorMessage', () => { + it('extracts message from Error instance', () => { + expect(getErrorMessage(new Error('oops'))).toBe('oops'); + }); + + it('returns string errors as-is', () => { + expect(getErrorMessage('string error')).toBe('string error'); + }); + + it('extracts message from object with message property', () => { + expect(getErrorMessage({ message: 'obj error' })).toBe('obj error'); + }); + + it('returns fallback for unknown types', () => { + expect(getErrorMessage(42)).toBe('An unexpected error occurred'); + }); + + it('returns custom fallback', () => { + expect(getErrorMessage(null, 'custom fallback')).toBe('custom fallback'); + }); + + it('returns fallback for object without message', () => { + expect(getErrorMessage({ code: 500 })).toBe('An unexpected error occurred'); + }); + + it('returns fallback for undefined', () => { + expect(getErrorMessage(undefined)).toBe('An unexpected error occurred'); + }); }); diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts index d10d807..322d1fc 100644 --- a/src/lib/utils/logger.ts +++ b/src/lib/utils/logger.ts @@ -40,7 +40,7 @@ const RESET = '\x1b[0m'; const BOLD = '\x1b[1m'; const DIM = '\x1b[2m'; -// Minimum level to output โ debug in dev, info in prod +// Minimum level to output - debug in dev, info in prod let minLevel: LogLevel = typeof window !== 'undefined' && window.location?.hostname === 'localhost' ? 'debug' : 'info'; function shouldLog(level: LogLevel): boolean { @@ -149,7 +149,7 @@ function log(level: LogLevel, context: string, message: string, extra?: { data?: return entry; } -// Ring buffer of recent logs โ useful for dumping context on crash +// Ring buffer of recent logs - useful for dumping context on crash const MAX_RECENT_LOGS = 100; const recentLogs: LogEntry[] = []; diff --git a/src/lib/utils/permissions.test.ts b/src/lib/utils/permissions.test.ts index b17d95b..6b7a8d2 100644 --- a/src/lib/utils/permissions.test.ts +++ b/src/lib/utils/permissions.test.ts @@ -8,13 +8,13 @@ import { } from './permissions'; describe('hasPermission', () => { - it('owner has wildcard โ grants any permission', () => { + it('owner has wildcard - grants any permission', () => { expect(hasPermission('owner', null, PERMISSIONS.DOCUMENTS_VIEW)).toBe(true); expect(hasPermission('owner', null, PERMISSIONS.SETTINGS_EDIT)).toBe(true); expect(hasPermission('owner', null, PERMISSIONS.MEMBERS_REMOVE)).toBe(true); }); - it('admin has wildcard โ grants any permission', () => { + it('admin has wildcard - grants any permission', () => { expect(hasPermission('admin', null, PERMISSIONS.ROLES_DELETE)).toBe(true); expect(hasPermission('admin', null, PERMISSIONS.KANBAN_CREATE)).toBe(true); }); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c62a183..ab26893 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,6 @@ - Organizations | Root + Organizations | root - + - - hub - - Root + - Sign Out + Sign Out @@ -80,43 +83,79 @@ - Your Organizations - Select an organization to get started + + Your Organizations + + + Select an organization to get started + (showCreateModal = true)} > - add + add New Organization {#if organizations.length === 0} - - - groups + + + groups - No organizations yet - Create your first organization to start collaborating + + No organizations yet + + + Create your first organization to start collaborating + (showCreateModal = true)} - >Create Organization + >Create Organization {:else} {#each organizations as org} - + - + {org.name.charAt(0).toUpperCase()} - {org.role} + {org.role} - {org.name} - /{org.slug} + + {org.name} + + + /{org.slug} + {/each} @@ -132,7 +171,9 @@ > - Organization Name + Organization Name {#if newOrgName} - URL: /{generateSlug(newOrgName)} + URL: /{generateSlug(newOrgName)} {/if} - - (showCreateModal = false)}>Cancel + + (showCreateModal = false)}>Cancel 0 ? ($totalUnreadCount > 99 ? "99+" : String($totalUnreadCount)) : null, - }, + // Chat disabled for now (feature_chat still in DB) + // ...(data.org.feature_chat + // ? [ + // { + // href: `/${data.org.slug}/chat`, + // label: "Chat", + // icon: "chat", + // badge: + // $totalUnreadCount > 0 + // ? $totalUnreadCount > 99 + // ? "99+" + // : String($totalUnreadCount) + // : null, + // }, + // ] + // : []), // Settings requires settings.view or admin role ...(canAccess("settings.view") ? [ @@ -154,7 +177,11 @@ function isNavigatingTo(href: string): boolean { const navTo = $navigating?.to?.url.pathname; - return !!navTo && navTo.startsWith(href) && !$page.url.pathname.startsWith(href); + return ( + !!navTo && + navTo.startsWith(href) && + !$page.url.pathname.startsWith(href) + ); } @@ -162,35 +189,21 @@
+ Drop files to upload +
{m.files_empty()}
+ All tags added +
Income
{formatCurrency(totalActualIncome)}
Planned: {formatCurrency(totalPlannedIncome)}
+ {m.budget_income()} +
+ {formatCurrency(totalActualIncome)} +
+ {m.budget_planned({ + amount: formatCurrency(totalPlannedIncome), + })} +
Expenses
{formatCurrency(totalActualExpense)}
Planned: {formatCurrency(totalPlannedExpense)}
+ {m.budget_expenses()} +
+ {formatCurrency(totalActualExpense)} +
+ {m.budget_planned({ + amount: formatCurrency(totalPlannedExpense), + })} +
Planned Balance
{formatCurrency(plannedBalance)}
+ {m.budget_planned_balance()} +
+ {formatCurrency(plannedBalance)} +
Actual Balance
{formatCurrency(actualBalance)}
+ {m.budget_actual_balance()} +
+ {formatCurrency(actualBalance)} +
No budget items yet
{m.budget_no_items()}
Color
+ {m.budget_category_color_label()} +
Existing Categories
+ {m.budget_existing_categories()} +
{contacts.length === 0 - ? "No contacts yet" - : "No matches found"} + ? m.contacts_no_contacts() + : m.contacts_no_results()}
edit - Edit + {m.btn_edit()} - onDelete(contact.id)} + onclick={() => onDelete(contact.id)} > delete - Delete + {m.btn_delete()}
Department files and documents
+ {m.files_widget_no_folder()} +
+ {m.files_widget_drop_files()} +
+ {m.files_widget_empty()} +
+ Drag & drop files here or click Upload +
- Task board for this department -
+ {m.kanban_widget_no_folder()} +
+ {m.kanban_widget_no_boards()} +
+ {m.map_image_desc()} +
No schedule blocks yet
{m.schedule_no_blocks()}
Confirmed
{formatCurrency(totalCommitted)}
{sponsors.filter((s) => s.status === 'confirmed' || s.status === 'active').length} sponsors
+ Confirmed +
+ {formatCurrency(totalCommitted)} +
+ {sponsors.filter( + (s) => s.status === "confirmed" || s.status === "active", + ).length} sponsors +
Pipeline
{formatCurrency(totalProspect)}
{sponsors.filter((s) => s.status === 'prospect' || s.status === 'contacted').length} prospects
+ Pipeline +
+ {formatCurrency(totalProspect)} +
+ {sponsors.filter( + (s) => s.status === "prospect" || s.status === "contacted", + ).length} prospects +
Total Sponsors
{sponsors.length}
+ Total Sponsors +
+ {sponsors.length} +
Tiers
{tiers.length}
+ Tiers +
+ {tiers.length} +
No sponsors yet
{m.sponsors_no_sponsors()}
{sponsor.contact_name}
+ {sponsor.contact_name} +
{sponsor.notes}
+ {sponsor.notes} +
Deliverables
+ {m.sponsors_deliverables_label()} +
+ {m.sponsors_tier_color_label()} +
Existing Tiers
+ {m.sponsors_existing_tiers()} +
+ {m.settings_social_desc()} +
+ Currency, date format, timezone, and calendar defaults. +
+ Defaults applied when creating new events and departments. +
+ Modules auto-added when a new department is created. +
+ Enable or disable features for this organization. +
Budget & Finances
+ Income/expense tracking, planned vs actual budgets +
Sponsors
+ Sponsor CRM with tiers, deliverables, and fund + tracking +
Contacts
+ Vendor and contact directory per department +
Permanently delete this organization and all its data. @@ -199,10 +854,15 @@ {#if !isOwner} -
- Leave this organization. You will need to be re-invited to rejoin. + Leave this organization. You will need to be re-invited to + rejoin.
{invite.email}
+ {invite.email} +
Invited as {invite.role} โข Expires {new Date( invite.expires_at, @@ -211,7 +211,11 @@ )} title={m.settings_members_copy_link()} > - content_copy + content_copy cancelInvite(invite.id)} title="Cancel invite" > - close + close
{error}
{hint}
Email
+ {profile.email} +
Phone
+ {profile.phone} +
Discord
+ {profile.discord_handle} +
Sizes
+ {#if profile.shirt_size}Shirt: {profile.shirt_size}{/if} + {#if profile.shirt_size && profile.hoodie_size} + ยท + {/if} + {#if profile.hoodie_size}Hoodie: {profile.hoodie_size}{/if} +
+ Only email available for this person +
Select an organization to get started
+ Select an organization to get started +
Create your first organization to start collaborating
+ Create your first organization to start collaborating +
/{org.slug}
+ /{org.slug} +
- URL: /{generateSlug(newOrgName)} + URL: /{generateSlug(newOrgName)}