mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Derive client domain types from the shared schema contracts
Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.
This commit is contained in:
@@ -66,7 +66,8 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Transport');
|
||||
// 'Transport' appears in the category section header and the spend breakdown chart.
|
||||
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
|
||||
@@ -76,7 +77,8 @@ describe('BudgetPanel', () => {
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Name');
|
||||
await screen.findByText('Total');
|
||||
// 'Total' appears both as a table header and in the chart total label.
|
||||
expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
|
||||
@@ -169,8 +171,9 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Transport');
|
||||
await screen.findByText('Hotels');
|
||||
// Each category appears in its section header and again in the breakdown chart.
|
||||
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
|
||||
@@ -200,7 +203,8 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('ToDelete');
|
||||
// 'ToDelete' appears in the category header and the breakdown chart.
|
||||
expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -390,7 +394,7 @@ describe('BudgetPanel', () => {
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
|
||||
total_price: 75,
|
||||
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
|
||||
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: 0 }],
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
@@ -425,7 +429,7 @@ describe('BudgetPanel', () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
// Use a user with id != 1 so they're not the owner
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
@@ -439,7 +443,7 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
@@ -484,7 +488,7 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
|
||||
@@ -19,6 +19,7 @@ interface NoteFile {
|
||||
filename: string
|
||||
original_name: string
|
||||
mime_type: string
|
||||
file_size?: number | null
|
||||
url?: string
|
||||
}
|
||||
|
||||
@@ -39,6 +40,8 @@ interface CollabNote {
|
||||
author?: { username: string; avatar: string | null }
|
||||
user?: { username: string; avatar: string | null }
|
||||
files?: NoteFile[]
|
||||
// Wire field: collabService embeds note files as `attachments` (with url).
|
||||
attachments?: NoteFile[]
|
||||
}
|
||||
|
||||
interface NoteAuthor {
|
||||
@@ -180,7 +183,7 @@ const formatTimestamp = (ts, t, locale) => {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z')
|
||||
const now = new Date()
|
||||
const diffMs = now - d
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
if (diffMins < 1) return t('collab.chat.justNow') || 'just now'
|
||||
if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago`
|
||||
@@ -240,7 +243,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
||||
// ── New Note Modal (portal to body) ─────────────────────────────────────────
|
||||
interface NoteFormModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
||||
onSubmit: (data: { title: string; content: string; category: string | null; website: string | null; color?: string | null; _pendingFiles?: File[]; files?: File[] }) => Promise<void>
|
||||
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||
existingCategories: string[]
|
||||
categoryColors: Record<string, string>
|
||||
@@ -849,7 +852,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
|
||||
)}
|
||||
</div>
|
||||
{/* Right: website + attachment thumbnails */}
|
||||
{(note.website || note.attachments?.length > 0) && (
|
||||
{(note.website || (note.attachments?.length ?? 0) > 0) && (
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
|
||||
{/* Website */}
|
||||
{note.website && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo, type CSSProperties } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
@@ -17,7 +17,7 @@ function useIsDesktop(breakpoint = 1024) {
|
||||
return isDesktop
|
||||
}
|
||||
|
||||
const card = {
|
||||
const card: CSSProperties = {
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)',
|
||||
overflow: 'hidden', minHeight: 0,
|
||||
|
||||
@@ -32,22 +32,23 @@ function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}
|
||||
notes: null,
|
||||
place: {
|
||||
id,
|
||||
trip_id: 1,
|
||||
name: `Place ${id}`,
|
||||
description: null,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
duration_minutes: 60,
|
||||
notes: null,
|
||||
transport_mode: 'walking',
|
||||
website: null,
|
||||
phone: null,
|
||||
...placeOverrides,
|
||||
},
|
||||
participants,
|
||||
@@ -83,7 +84,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(10, { place_time: '08:00' })],
|
||||
},
|
||||
@@ -95,7 +96,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
|
||||
},
|
||||
@@ -106,7 +107,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(21, { name: 'Museum' })],
|
||||
},
|
||||
@@ -118,7 +119,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
|
||||
},
|
||||
@@ -130,7 +131,7 @@ describe('WhatsNextWidget', () => {
|
||||
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
|
||||
},
|
||||
@@ -142,7 +143,7 @@ describe('WhatsNextWidget', () => {
|
||||
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
|
||||
},
|
||||
@@ -153,7 +154,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
|
||||
},
|
||||
@@ -164,7 +165,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
|
||||
},
|
||||
@@ -179,7 +180,7 @@ describe('WhatsNextWidget', () => {
|
||||
trip_id: 1,
|
||||
date: getFutureDate(i + 1),
|
||||
title: null,
|
||||
order: i,
|
||||
day_number: i,
|
||||
assignments: [],
|
||||
notes_items: [],
|
||||
notes: null,
|
||||
@@ -207,7 +208,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
|
||||
},
|
||||
@@ -218,7 +219,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(41, { name: 'Park' }, [])],
|
||||
},
|
||||
@@ -229,7 +230,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
|
||||
},
|
||||
@@ -241,7 +242,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [
|
||||
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
|
||||
@@ -263,7 +264,7 @@ describe('WhatsNextWidget', () => {
|
||||
if (now.getHours() > 0) {
|
||||
const pastTime = '00:01' // Very early — will be past for most of the day
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import type { TripFile } from '../../types';
|
||||
import FileManager from './FileManager';
|
||||
|
||||
// Mock getAuthUrl
|
||||
@@ -36,20 +37,21 @@ vi.mock('../../api/client', async (importOriginal) => {
|
||||
|
||||
import { filesApi } from '../../api/client';
|
||||
|
||||
const buildFile = (overrides = {}) => ({
|
||||
const buildFile = (overrides: Partial<TripFile> = {}): TripFile => ({
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
filename: 'report.pdf',
|
||||
original_name: 'report.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_size: 51200,
|
||||
created_at: '2025-01-10T08:00:00Z',
|
||||
url: '/uploads/trips/1/report.pdf',
|
||||
starred: false,
|
||||
starred: 0,
|
||||
deleted_at: null,
|
||||
place_id: null,
|
||||
reservation_id: null,
|
||||
day_id: null,
|
||||
uploaded_by: 1,
|
||||
uploader_name: 'Alice',
|
||||
uploaded_by_name: 'Alice',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -388,7 +390,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Hotel Paris' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Hotel Paris' });
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -418,7 +420,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Train Ticket' });
|
||||
const file = buildFile({ id: 1 });
|
||||
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
@@ -436,7 +438,7 @@ describe('FileManager', () => {
|
||||
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
|
||||
const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const place = buildPlace({ id: 10, name: 'Notre Dame' });
|
||||
const reservation = buildReservation({ id: 20, name: 'Airbnb' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Airbnb' });
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -527,7 +529,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Museum Pass' });
|
||||
// File already has reservation_id set to 20
|
||||
const file = buildFile({ id: 1, reservation_id: 20 });
|
||||
|
||||
|
||||
@@ -739,7 +739,7 @@ function AssignModal(S: FileManagerState) {
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -7,9 +7,10 @@ import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import InAppNotificationBell from './InAppNotificationBell';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
|
||||
let _notifId = 1;
|
||||
function buildNotification(overrides: Record<string, unknown> = {}) {
|
||||
function buildNotification(overrides: Partial<InAppNotification> = {}): InAppNotification {
|
||||
return {
|
||||
id: _notifId++,
|
||||
type: 'simple',
|
||||
@@ -20,15 +21,15 @@ function buildNotification(overrides: Record<string, unknown> = {}) {
|
||||
sender_avatar: null,
|
||||
recipient_id: 1,
|
||||
title_key: 'test',
|
||||
title_params: '{}',
|
||||
title_params: {},
|
||||
text_key: 'test.text',
|
||||
text_params: '{}',
|
||||
text_params: {},
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: 0,
|
||||
is_read: false,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
@@ -92,14 +93,7 @@ describe('InAppNotificationBell', () => {
|
||||
|
||||
it('FE-COMP-BELL-007: panel shows Mark all read button when panel is open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const notification = {
|
||||
id: 1, type: 'simple', scope: 'trip', target: 1, sender_id: 2,
|
||||
sender_username: 'alice', sender_avatar: null, recipient_id: 1,
|
||||
title_key: 'test', title_params: '{}', text_key: 'test.text', text_params: '{}',
|
||||
positive_text_key: null, negative_text_key: null, response: null,
|
||||
navigate_text_key: null, navigate_target: null, is_read: 0,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
};
|
||||
const notification = buildNotification({ id: 1, title_key: 'test', text_key: 'test.text' });
|
||||
seedStore(useInAppNotificationStore, { notifications: [notification], unreadCount: 1, isLoading: false });
|
||||
render(<InAppNotificationBell />);
|
||||
const bell = screen.getAllByRole('button')[0];
|
||||
@@ -153,7 +147,7 @@ describe('InAppNotificationBell', () => {
|
||||
|
||||
it('FE-COMP-BELL-013: Mark all read button NOT shown when unreadCount is 0', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useInAppNotificationStore, { notifications: [buildNotification({ is_read: 1 })], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn(), fetchUnreadCount: vi.fn() });
|
||||
seedStore(useInAppNotificationStore, { notifications: [buildNotification({ is_read: true })], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn(), fetchUnreadCount: vi.fn() });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
await screen.findByText('Notifications');
|
||||
|
||||
@@ -78,12 +78,12 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
||||
}
|
||||
|
||||
/** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
|
||||
export function optimizeRoute(places: Waypoint[]): Waypoint[] {
|
||||
export function optimizeRoute<T extends Waypoint>(places: T[]): T[] {
|
||||
const valid = places.filter((p) => p.lat && p.lng)
|
||||
if (valid.length <= 2) return places
|
||||
|
||||
const visited = new Set<number>()
|
||||
const result: Waypoint[] = []
|
||||
const result: T[] = []
|
||||
let current = valid[0]
|
||||
visited.add(0)
|
||||
result.push(current)
|
||||
|
||||
@@ -6,9 +6,10 @@ import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
import InAppNotificationItem from './InAppNotificationItem';
|
||||
|
||||
const buildNotification = (overrides = {}) => ({
|
||||
const buildNotification = (overrides: Partial<InAppNotification> = {}): InAppNotification => ({
|
||||
id: 1,
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
@@ -18,15 +19,15 @@ const buildNotification = (overrides = {}) => ({
|
||||
sender_avatar: null,
|
||||
recipient_id: 1,
|
||||
title_key: 'notifications.title',
|
||||
title_params: '{}',
|
||||
title_params: {},
|
||||
text_key: 'notifications.empty',
|
||||
text_params: '{}',
|
||||
text_params: {},
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: 0,
|
||||
is_read: false,
|
||||
created_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
@@ -62,12 +63,12 @@ describe('InAppNotificationItem', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-005: shows Mark as read button for unread notification', () => {
|
||||
render(<InAppNotificationItem notification={buildNotification({ is_read: 0 })} />);
|
||||
render(<InAppNotificationItem notification={buildNotification({ is_read: false })} />);
|
||||
expect(screen.getByTitle('Mark as read')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-006: does not show Mark as read button for read notification', () => {
|
||||
render(<InAppNotificationItem notification={buildNotification({ is_read: 1 })} />);
|
||||
render(<InAppNotificationItem notification={buildNotification({ is_read: true })} />);
|
||||
expect(screen.queryByTitle('Mark as read')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -80,7 +81,7 @@ describe('InAppNotificationItem', () => {
|
||||
const user = userEvent.setup();
|
||||
const markRead = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useInAppNotificationStore, { markRead });
|
||||
render(<InAppNotificationItem notification={buildNotification({ id: 42, is_read: 0 })} />);
|
||||
render(<InAppNotificationItem notification={buildNotification({ id: 42, is_read: false })} />);
|
||||
await user.click(screen.getByTitle('Mark as read'));
|
||||
expect(markRead).toHaveBeenCalledWith(42);
|
||||
});
|
||||
@@ -190,7 +191,7 @@ describe('InAppNotificationItem', () => {
|
||||
type: 'navigate',
|
||||
navigate_text_key: 'notifications.title',
|
||||
navigate_target: '/trips/1',
|
||||
is_read: 0,
|
||||
is_read: false,
|
||||
})}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createElement } from 'react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
|
||||
@@ -117,7 +117,8 @@ interface downloadTripPDFProps {
|
||||
places: Place[]
|
||||
assignments: AssignmentsMap
|
||||
categories: Category[]
|
||||
dayNotes: DayNotesMap
|
||||
// Flattened across days: each note carries its own day_id (see downloadTripPDF callers).
|
||||
dayNotes: DayNote[]
|
||||
reservations?: any[]
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
locale: string
|
||||
@@ -190,7 +191,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
dayReservations.forEach(r => {
|
||||
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem } from '../../types'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
|
||||
const VORSCHLAEGE = [
|
||||
{ name: 'Passport', category: 'Documents' },
|
||||
@@ -67,8 +67,6 @@ function katColor(kat, allCategories) {
|
||||
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
|
||||
}
|
||||
|
||||
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
||||
|
||||
/** Weight an item contributes to a total: unit weight times quantity (defaults: 0 g, qty 1). */
|
||||
export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number =>
|
||||
(i.weight_grams || 0) * (i.quantity || 1)
|
||||
@@ -818,7 +816,7 @@ function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSigna
|
||||
if (filter === 'erledigt') return i.checked
|
||||
return true
|
||||
})
|
||||
const groups = {}
|
||||
const groups: Record<string, PackingItem[]> = {}
|
||||
for (const item of filtered) {
|
||||
const kat = item.category || t('packing.defaultCategory')
|
||||
if (!groups[kat]) groups[kat] = []
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { vi, describe, it, expect, beforeEach, beforeAll } from 'vitest'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import type { Day, Place } from '../../types'
|
||||
import { PhotoUpload } from './PhotoUpload'
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -12,8 +13,8 @@ beforeAll(() => {
|
||||
|
||||
const defaultProps = {
|
||||
tripId: 1,
|
||||
days: [{ id: 1, day_number: 1, date: null }],
|
||||
places: [{ id: 1, name: 'Eiffel Tower' }],
|
||||
days: [{ id: 1, trip_id: 1, day_number: 1, date: null }] as Day[],
|
||||
places: [{ id: 1, trip_id: 1, name: 'Eiffel Tower' }] as Place[],
|
||||
onUpload: vi.fn().mockResolvedValue(undefined),
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -1393,7 +1393,7 @@ describe('DayPlanSidebar', () => {
|
||||
const assignment = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
|
||||
const flight = buildReservation({
|
||||
id: 77, trip_id: 1, type: 'flight', status: 'confirmed',
|
||||
date: '2025-06-01', reservation_time: '2025-06-01T10:00:00Z',
|
||||
reservation_time: '2025-06-01T10:00:00Z',
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place],
|
||||
@@ -1553,7 +1553,7 @@ describe('DayPlanSidebar', () => {
|
||||
const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 1, place: placeB })
|
||||
const flight = buildReservation({
|
||||
id: 77, trip_id: 1, type: 'flight', status: 'confirmed',
|
||||
date: '2025-06-01', reservation_time: '2025-06-01T12:00:00Z',
|
||||
reservation_time: '2025-06-01T12:00:00Z',
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [placeA, placeB],
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult, RouteSegment } from '../../types'
|
||||
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment } from '../../types'
|
||||
|
||||
const NOTE_ICONS = [
|
||||
{ id: 'FileText', Icon: FileText },
|
||||
@@ -169,7 +169,7 @@ interface DayPlanSidebarProps {
|
||||
onSelectDay: (dayId: number | null) => void
|
||||
onPlaceClick: (placeId: number) => void
|
||||
onDayDetail: (day: Day) => void
|
||||
accommodations?: Assignment[]
|
||||
accommodations?: Accommodation[]
|
||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||
onUpdateDayTitle: (dayId: number, title: string) => void
|
||||
onRouteCalculated: (dayId: number, route: RouteResult | null) => void
|
||||
@@ -277,9 +277,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [expandedDays, setExpandedDays] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(`day-expanded-${tripId}`)
|
||||
if (saved) return new Set(JSON.parse(saved))
|
||||
if (saved) return new Set<number>(JSON.parse(saved) as number[])
|
||||
} catch {}
|
||||
return new Set(days.map(d => d.id))
|
||||
return new Set<number>(days.map(d => d.id))
|
||||
})
|
||||
useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays])
|
||||
const [editingDayId, setEditingDayId] = useState(null)
|
||||
@@ -921,7 +921,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
const totalCost = useMemo(() => days.reduce((s, d) => {
|
||||
const da = assignments[String(d.id)] || []
|
||||
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
|
||||
return s + da.reduce((s2, a) => s2 + (Number(a.place?.price) || 0), 0)
|
||||
}, 0), [days, assignments])
|
||||
|
||||
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
|
||||
@@ -1416,8 +1416,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
>
|
||||
{/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
|
||||
{(() => {
|
||||
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
||||
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
||||
// anyGeoPlace is an assignment (has .place) or a bare place — read coords from either.
|
||||
const geoLat = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lat : anyGeoPlace.lat) : undefined
|
||||
const geoLng = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lng : anyGeoPlace.lng) : undefined
|
||||
const wLat = loc?.place?.lat ?? geoLat
|
||||
const wLng = loc?.place?.lng ?? geoLng
|
||||
const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
|
||||
return (
|
||||
<div style={{
|
||||
|
||||
@@ -23,6 +23,10 @@ interface PlaceFormData {
|
||||
notes: string
|
||||
transport_mode: string
|
||||
website: string
|
||||
// Populated from a maps-search pick (not part of the initial blank form).
|
||||
phone?: string
|
||||
google_place_id?: string
|
||||
osm_id?: string
|
||||
}
|
||||
|
||||
function isGoogleMapsUrl(input: string): boolean {
|
||||
|
||||
@@ -235,7 +235,7 @@ describe('PlaceInspector', () => {
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-017: "Remove from day" button appears when place IS assigned to selectedDay', () => {
|
||||
const assignmentInDay = [{ id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
@@ -250,7 +250,7 @@ describe('PlaceInspector', () => {
|
||||
it('FE-PLANNER-INSPECTOR-018: clicking remove calls onRemoveAssignment with dayId and assignmentId', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemoveAssignment = vi.fn();
|
||||
const assignmentInDay = [{ id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
@@ -406,7 +406,7 @@ describe('PlaceInspector', () => {
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030: linked reservation shown when selectedAssignmentId has a reservation', () => {
|
||||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||||
const assignmentInDay = [{ id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
@@ -423,7 +423,7 @@ describe('PlaceInspector', () => {
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-031: participants section shown when tripMembers > 1 and selectedAssignmentId is set', () => {
|
||||
const members = [buildUser({ id: 1 }), buildUser({ id: 2 })];
|
||||
const assignmentInDay = [{ id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
@@ -534,7 +534,7 @@ describe('PlaceInspector', () => {
|
||||
const member2 = buildUser({ id: 11, username: 'bob' });
|
||||
const members = [member1, member2];
|
||||
const assignmentInDay = [{
|
||||
id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null,
|
||||
id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null,
|
||||
participants: [{ user_id: 10 }],
|
||||
}];
|
||||
render(
|
||||
@@ -637,7 +637,7 @@ describe('PlaceInspector', () => {
|
||||
tripMembers={[member]}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': [{ id: 99, place: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null }] }}
|
||||
assignments={{ '1': [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }] }}
|
||||
/>
|
||||
);
|
||||
// "solo" username might be visible from other parts but participants box should not render
|
||||
|
||||
@@ -104,6 +104,7 @@ function formatFileSize(bytes) {
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar?: string | null
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo, type CSSProperties } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import apiClient from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -265,12 +265,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
)
|
||||
: []
|
||||
|
||||
const inputStyle = {
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)',
|
||||
}
|
||||
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
|
||||
const labelStyle: CSSProperties = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -13,7 +13,9 @@ import type { Trip } from '../../types'
|
||||
interface TripFormModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
|
||||
// Create returns the new trip (so we can attach members / upload the cover);
|
||||
// update resolves without a payload.
|
||||
onSave: (data: Record<string, string | number | null>) => Promise<{ trip?: Trip } | void> | void
|
||||
trip: Trip | null
|
||||
onCoverUpdate: (tripId: number, coverUrl: string) => void
|
||||
}
|
||||
@@ -106,22 +108,23 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
reminder_days: formData.reminder_days,
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
|
||||
})
|
||||
const createdTrip = result ? result.trip : undefined
|
||||
// Add selected members for newly created trips
|
||||
if (selectedMembers.length > 0 && result?.trip?.id) {
|
||||
if (selectedMembers.length > 0 && createdTrip?.id) {
|
||||
for (const userId of selectedMembers) {
|
||||
const user = allUsers.find(u => u.id === userId)
|
||||
if (user) {
|
||||
try { await tripsApi.addMember(result.trip.id, user.username) } catch {}
|
||||
try { await tripsApi.addMember(createdTrip.id, user.username) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Upload pending cover for newly created trips
|
||||
if (pendingCoverFile && result?.trip?.id) {
|
||||
if (pendingCoverFile && createdTrip?.id) {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('cover', pendingCoverFile)
|
||||
const data = await tripsApi.uploadCover(result.trip.id, fd)
|
||||
onCoverUpdate?.(result.trip.id, data.cover_image)
|
||||
const data = await tripsApi.uploadCover(createdTrip.id, fd)
|
||||
onCoverUpdate?.(createdTrip.id, data.cover_image)
|
||||
} catch {
|
||||
// Cover upload failed but trip was created
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('TripMembersModal', () => {
|
||||
it('FE-COMP-MEMBERS-016: share link section not rendered for non-owner', async () => {
|
||||
const nonOwner = buildUser({ id: 99, username: 'stranger' });
|
||||
seedStore(useAuthStore, { user: nonOwner, isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
@@ -190,7 +190,7 @@ describe('TripMembersModal', () => {
|
||||
|
||||
it('FE-COMP-MEMBERS-017: share link section visible for owner', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||||
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await screen.findByText('Public Link');
|
||||
@@ -199,7 +199,7 @@ describe('TripMembersModal', () => {
|
||||
it('FE-COMP-MEMBERS-018: create share link shows URL after clicking create', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||||
|
||||
// GET returns null token initially; POST returns a new token
|
||||
server.use(
|
||||
@@ -229,7 +229,7 @@ describe('TripMembersModal', () => {
|
||||
it('FE-COMP-MEMBERS-019: copy share link calls clipboard.writeText', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||||
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
@@ -261,7 +261,7 @@ describe('TripMembersModal', () => {
|
||||
it('FE-COMP-MEMBERS-020: delete share link removes URL and shows create button', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||||
|
||||
let deleteHandlerCalled = false;
|
||||
server.use(
|
||||
@@ -292,7 +292,7 @@ describe('TripMembersModal', () => {
|
||||
it('FE-COMP-MEMBERS-021: clicking permission toggle calls POST with updated perms', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||||
|
||||
let postedPerms: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
@@ -376,7 +376,7 @@ describe('TripMembersModal', () => {
|
||||
});
|
||||
|
||||
seedStore(useAuthStore, { user: memberUser, isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: ownerUser.id }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||||
|
||||
let deleteCalledForUserId: string | null = null;
|
||||
server.use(
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function VacayCalendar() {
|
||||
}, [selectedYear])
|
||||
|
||||
const companyHolidaySet = useMemo(() => {
|
||||
const s = new Set()
|
||||
const s = new Set<string>()
|
||||
companyHolidays.forEach(h => s.add(h.date))
|
||||
return s
|
||||
}, [companyHolidays])
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('VacayMonthCard', () => {
|
||||
it('FE-COMP-VACAYMONTHCARD-004: Holiday cell has tooltip with localName', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
holidays: { '2025-01-01': { localName: 'Neujahr', label: null, color: '#ef4444' } },
|
||||
holidays: { '2025-01-01': { name: 'Neujahr', localName: 'Neujahr', label: null, color: '#ef4444' } },
|
||||
}
|
||||
render(<VacayMonthCard {...props} />)
|
||||
// Jan 1 is a Wednesday — there may be multiple "1" text nodes, find the one with a title
|
||||
@@ -60,7 +60,7 @@ describe('VacayMonthCard', () => {
|
||||
it('FE-COMP-VACAYMONTHCARD-005: Holiday cell with label shows combined tooltip', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
holidays: { '2025-01-01': { localName: 'New Year', label: 'DE', color: '#ef4444' } },
|
||||
holidays: { '2025-01-01': { name: 'New Year', localName: 'New Year', label: 'DE', color: '#ef4444' } },
|
||||
}
|
||||
render(<VacayMonthCard {...props} />)
|
||||
const cell = screen.getByTitle('DE: New Year')
|
||||
@@ -95,7 +95,7 @@ describe('VacayMonthCard', () => {
|
||||
it('FE-COMP-VACAYMONTHCARD-008: Single vacation entry renders colored overlay', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
entryMap: { '2025-01-15': [{ person_color: '#6366f1' }] },
|
||||
entryMap: { '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' }] },
|
||||
}
|
||||
render(<VacayMonthCard {...props} />)
|
||||
const daySpan = screen.getByText('15')
|
||||
@@ -111,7 +111,7 @@ describe('VacayMonthCard', () => {
|
||||
it('FE-COMP-VACAYMONTHCARD-009: Day number font-weight is bold when entries exist', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
entryMap: { '2025-01-20': [{ person_color: '#6366f1' }] },
|
||||
entryMap: { '2025-01-20': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' }] },
|
||||
}
|
||||
render(<VacayMonthCard {...props} />)
|
||||
const daySpan = screen.getByText('20')
|
||||
@@ -131,7 +131,7 @@ describe('VacayMonthCard', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
entryMap: {
|
||||
'2025-01-15': [{ person_color: '#6366f1' }, { person_color: '#f43f5e' }],
|
||||
'2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' }, { date: '2025-01-15', user_id: 1, person_color: '#f43f5e' }],
|
||||
},
|
||||
}
|
||||
render(<VacayMonthCard {...props} />)
|
||||
@@ -149,10 +149,10 @@ describe('VacayMonthCard', () => {
|
||||
...baseProps,
|
||||
entryMap: {
|
||||
'2025-01-15': [
|
||||
{ person_color: '#6366f1' },
|
||||
{ person_color: '#f43f5e' },
|
||||
{ person_color: '#22c55e' },
|
||||
{ person_color: '#f59e0b' },
|
||||
{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' },
|
||||
{ date: '2025-01-15', user_id: 1, person_color: '#f43f5e' },
|
||||
{ date: '2025-01-15', user_id: 1, person_color: '#22c55e' },
|
||||
{ date: '2025-01-15', user_id: 1, person_color: '#f59e0b' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,14 +3,8 @@ import { Briefcase, Pencil } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { VacayStat } from '../../types'
|
||||
import type { VacayStat, TranslationFn } from '../../types'
|
||||
|
||||
interface VacayStatExtended extends VacayStat {
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
color: string | null
|
||||
total_available: number
|
||||
}
|
||||
|
||||
export default function VacayStats() {
|
||||
const { t } = useTranslation()
|
||||
@@ -50,17 +44,19 @@ export default function VacayStats() {
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
stat: VacayStatExtended
|
||||
stat: VacayStat
|
||||
isMe: boolean
|
||||
canEdit: boolean
|
||||
selectedYear: number
|
||||
onSave: (userId: number, year: number, days: number) => Promise<void>
|
||||
t: (key: string) => string
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [localDays, setLocalDays] = useState(s.vacation_days)
|
||||
// Holds the entitlement-day value while editing: a number on load, a string
|
||||
// once the user types into the number input.
|
||||
const [localDays, setLocalDays] = useState<number | string>(s.vacation_days)
|
||||
const pct = s.total_available > 0 ? Math.min(100, (s.used / s.total_available) * 100) : 0
|
||||
|
||||
// Sync local state when stats reload from server
|
||||
@@ -70,7 +66,7 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
|
||||
|
||||
const handleSave = () => {
|
||||
setEditing(false)
|
||||
const days = parseInt(localDays)
|
||||
const days = parseInt(String(localDays))
|
||||
if (!isNaN(days) && days >= 0 && days <= 365 && days !== s.vacation_days) {
|
||||
onSave(selectedYear, days, s.user_id)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
// Callers use both string keys and numeric ids (e.g. day/place ids) as values;
|
||||
// the component only does strict-equality lookups and key rendering, so either works.
|
||||
value: string | number
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
isHeader?: boolean
|
||||
@@ -13,8 +15,8 @@ interface SelectOption {
|
||||
}
|
||||
|
||||
interface CustomSelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
value: string | number
|
||||
onChange: (value: string | number) => void
|
||||
options?: SelectOption[]
|
||||
placeholder?: string
|
||||
searchable?: boolean
|
||||
|
||||
@@ -360,7 +360,7 @@ describe('AdminPage', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
|
||||
const heading = await screen.findByRole('heading', { name: /authentication methods/i });
|
||||
const card = heading.closest('.bg-white');
|
||||
const card = heading.closest<HTMLElement>('.bg-white');
|
||||
const toggles = within(card!).getAllByRole('button');
|
||||
fireEvent.click(toggles[0]); // First toggle = password_login
|
||||
|
||||
@@ -474,7 +474,7 @@ describe('AdminPage', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
|
||||
const mfaHeading = await screen.findByRole('heading', { name: /require two-factor/i });
|
||||
const mfaCard = mfaHeading.closest('.bg-white');
|
||||
const mfaCard = mfaHeading.closest<HTMLElement>('.bg-white');
|
||||
const mfaToggle = within(mfaCard!).getByRole('button');
|
||||
fireEvent.click(mfaToggle);
|
||||
|
||||
@@ -739,7 +739,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Find and click the Save button in the file types section
|
||||
const fileTypesHeading = screen.getByRole('heading', { name: /allowed file types/i });
|
||||
const fileTypesCard = fileTypesHeading.closest('.bg-white');
|
||||
const fileTypesCard = fileTypesHeading.closest<HTMLElement>('.bg-white');
|
||||
const saveBtn = within(fileTypesCard!).getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
@@ -765,7 +765,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Wait for OIDC section to appear
|
||||
const oidcHeading = await screen.findByRole('heading', { name: /single sign-on/i });
|
||||
const oidcCard = oidcHeading.closest('.bg-white');
|
||||
const oidcCard = oidcHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
// Type in the display name field (placeholder is 'z.B. Google, Authentik, Keycloak')
|
||||
const displayNameInput = within(oidcCard!).getByPlaceholderText('z.B. Google, Authentik, Keycloak');
|
||||
@@ -800,7 +800,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// The Email (SMTP) panel header has the enable toggle
|
||||
const emailHeading = await screen.findByRole('heading', { name: /email \(smtp\)/i });
|
||||
const emailPanel = emailHeading.closest('.bg-white');
|
||||
const emailPanel = emailHeading.closest<HTMLElement>('.bg-white');
|
||||
const emailToggle = within(emailPanel!).getAllByRole('button')[0];
|
||||
fireEvent.click(emailToggle);
|
||||
|
||||
@@ -842,7 +842,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Click Save in the email panel
|
||||
const emailHeading = screen.getByRole('heading', { name: /email \(smtp\)/i });
|
||||
const emailPanel = emailHeading.closest('.bg-white');
|
||||
const emailPanel = emailHeading.closest<HTMLElement>('.bg-white');
|
||||
const saveBtn = within(emailPanel!).getByRole('button', { name: /^save$/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
@@ -964,7 +964,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Wait for the API Keys section to appear
|
||||
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
||||
const apiKeysCard = apiKeysHeading.closest('.bg-white');
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
// Type in the maps key field (type="password" by default)
|
||||
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
|
||||
@@ -999,7 +999,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Wait for the API Keys section
|
||||
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
||||
const apiKeysCard = apiKeysHeading.closest('.bg-white');
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
// Type a key value to enable the Test button
|
||||
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
|
||||
@@ -1126,7 +1126,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Click the TLS toggle (skip TLS certificate check)
|
||||
const tlsToggleText = screen.getByText('Skip TLS certificate check');
|
||||
const tlsCard = tlsToggleText.closest('div');
|
||||
const tlsCard = tlsToggleText.closest<HTMLElement>('div');
|
||||
// The toggle button is a sibling container
|
||||
const allToggles = screen.getAllByRole('button');
|
||||
// Find toggle near the TLS text
|
||||
@@ -1170,7 +1170,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Find the email panel and click its "Send test email" button (scoped to avoid admin webhook panel)
|
||||
const emailHeading = screen.getByRole('heading', { name: /email \(smtp\)/i });
|
||||
const emailPanel = emailHeading.closest('.bg-white');
|
||||
const emailPanel = emailHeading.closest<HTMLElement>('.bg-white');
|
||||
const testBtn = within(emailPanel!).getByRole('button', { name: /send test email/i });
|
||||
fireEvent.click(testBtn);
|
||||
|
||||
@@ -1207,7 +1207,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Find the webhook panel heading ('Webhook') — exact match to avoid 'Admin Webhook'
|
||||
const webhookHeading = screen.getByRole('heading', { name: /^webhook$/i });
|
||||
const webhookCard = webhookHeading.closest('.bg-white');
|
||||
const webhookCard = webhookHeading.closest<HTMLElement>('.bg-white');
|
||||
// Find the toggle button in webhook card
|
||||
const webhookToggle = within(webhookCard!).getByRole('button');
|
||||
fireEvent.click(webhookToggle);
|
||||
@@ -1245,7 +1245,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Find the Save button in the admin webhook panel
|
||||
const adminWebhookHeading = screen.getByRole('heading', { name: /admin webhook/i });
|
||||
const adminWebhookCard = adminWebhookHeading.closest('.bg-white');
|
||||
const adminWebhookCard = adminWebhookHeading.closest<HTMLElement>('.bg-white');
|
||||
const saveBtn = within(adminWebhookCard!).getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
@@ -1284,7 +1284,7 @@ describe('AdminPage', () => {
|
||||
// The channel column header is t('settings.notificationPreferences.email') = 'Email' (CSS uppercases it)
|
||||
// Find the AdminNotificationsPanel by its h2 heading role='heading'
|
||||
const matrixHeading = await screen.findByRole('heading', { name: /^notifications$/i });
|
||||
const matrixCard = matrixHeading.closest('.bg-white');
|
||||
const matrixCard = matrixHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
// The matrix toggle button is inside the card (not a checkbox — it's a button toggle)
|
||||
const matrixToggle = matrixCard?.querySelector('button');
|
||||
@@ -1308,7 +1308,7 @@ describe('AdminPage', () => {
|
||||
|
||||
// Wait for the OIDC section — heading is 'Single Sign-On (OIDC)'
|
||||
const oidcHeading = await screen.findByRole('heading', { name: /single sign-on/i });
|
||||
const oidcCard = oidcHeading.closest('.bg-white');
|
||||
const oidcCard = oidcHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
// Issuer field (placeholder: https://accounts.google.com)
|
||||
const issuerInput = within(oidcCard!).getByPlaceholderText('https://accounts.google.com');
|
||||
@@ -1320,12 +1320,12 @@ describe('AdminPage', () => {
|
||||
|
||||
// Client ID field
|
||||
const clientIdLabel = within(oidcCard!).getByText('Client ID');
|
||||
const clientIdInput = clientIdLabel.closest('div')!.querySelector('input')!;
|
||||
const clientIdInput = clientIdLabel.closest<HTMLElement>('div')!.querySelector('input')!;
|
||||
fireEvent.change(clientIdInput, { target: { value: 'my-client-id' } });
|
||||
|
||||
// Client Secret field
|
||||
const clientSecretLabel = within(oidcCard!).getByText('Client Secret');
|
||||
const clientSecretInput = clientSecretLabel.closest('div')!.querySelector('input')!;
|
||||
const clientSecretInput = clientSecretLabel.closest<HTMLElement>('div')!.querySelector('input')!;
|
||||
fireEvent.change(clientSecretInput, { target: { value: 'my-client-secret' } });
|
||||
|
||||
// Verify the inputs updated
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('DashboardPage', () => {
|
||||
|
||||
describe('FE-PAGE-DASH-011: Archive trip moves it to the archive filter', () => {
|
||||
it('archiving a trip removes it from active and shows it under the archive filter', async () => {
|
||||
const archivedTrip = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10', is_archived: true });
|
||||
const archivedTrip = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10', is_archived: 1 });
|
||||
server.use(
|
||||
http.put('/api/trips/:id', () => HttpResponse.json({ trip: archivedTrip })),
|
||||
);
|
||||
@@ -273,7 +273,7 @@ describe('DashboardPage', () => {
|
||||
|
||||
describe('FE-PAGE-DASH-014: Archive filter reveals archived trips', () => {
|
||||
it('shows archived trips when the archive filter is selected', async () => {
|
||||
const oldTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: true });
|
||||
const oldTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: 1 });
|
||||
server.use(
|
||||
http.get('/api/trips', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
@@ -414,8 +414,8 @@ describe('DashboardPage', () => {
|
||||
describe('FE-PAGE-DASH-020: Archived section - restore trip', () => {
|
||||
it('clicking restore in archived section moves trip back to active list', async () => {
|
||||
const activeTrip = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10' });
|
||||
const archivedTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: true });
|
||||
const restoredTrip = { ...archivedTrip, is_archived: false };
|
||||
const archivedTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: 1 });
|
||||
const restoredTrip = { ...archivedTrip, is_archived: 0 };
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', ({ request }) => {
|
||||
@@ -624,7 +624,7 @@ describe('DashboardPage', () => {
|
||||
describe('FE-PAGE-DASH-027: Archive filter toggles archived trips in and out of view', () => {
|
||||
it('shows archived trips under the archive filter and hides them under planned', async () => {
|
||||
const activeTrip = buildTrip({ title: 'Active Trip', start_date: '2026-08-01', end_date: '2026-08-10' });
|
||||
const archivedTrip = buildTrip({ title: 'Old Archived Trip', start_date: '2024-03-01', end_date: '2024-03-07', is_archived: true });
|
||||
const archivedTrip = buildTrip({ title: 'Old Archived Trip', start_date: '2024-03-01', end_date: '2024-03-07', is_archived: 1 });
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', ({ request }) => {
|
||||
@@ -660,8 +660,8 @@ describe('DashboardPage', () => {
|
||||
describe('FE-PAGE-DASH-028: Unarchive action restores trip to active list', () => {
|
||||
it('clicking restore on an archived trip removes it from archived section', async () => {
|
||||
const activeTrip = buildTrip({ title: 'My Active Trip', start_date: '2026-08-01', end_date: '2026-08-10' });
|
||||
const archivedTrip = buildTrip({ title: 'Restored Trip', start_date: '2024-06-01', end_date: '2024-06-07', is_archived: true });
|
||||
const restoredTrip = { ...archivedTrip, is_archived: false };
|
||||
const archivedTrip = buildTrip({ title: 'Restored Trip', start_date: '2024-06-01', end_date: '2024-06-07', is_archived: 1 });
|
||||
const restoredTrip = { ...archivedTrip, is_archived: 0 };
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', ({ request }) => {
|
||||
|
||||
@@ -25,10 +25,10 @@ function buildPhoto(overrides: Partial<Photo> = {}): Photo {
|
||||
return {
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
filename: 'photo1.jpg',
|
||||
url: '/uploads/photos/photo1.jpg',
|
||||
original_name: 'photo1.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
size: 12345,
|
||||
file_size: 12345,
|
||||
caption: null,
|
||||
place_id: null,
|
||||
day_id: null,
|
||||
|
||||
@@ -72,7 +72,7 @@ export function useTripPlanner() {
|
||||
|
||||
useEffect(() => {
|
||||
addonsApi.enabled().then(data => {
|
||||
const map = {}
|
||||
const map: Record<string, boolean> = {}
|
||||
data.addons.forEach(a => { map[a.id] = true })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
|
||||
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
|
||||
@@ -413,7 +413,6 @@ export function useTripPlanner() {
|
||||
lng: capturedPlace.lng,
|
||||
address: capturedPlace.address,
|
||||
category_id: capturedPlace.category_id,
|
||||
icon: capturedPlace.icon,
|
||||
price: capturedPlace.price,
|
||||
})
|
||||
for (const { dayId, orderIndex } of capturedAssignments) {
|
||||
@@ -444,7 +443,7 @@ export function useTripPlanner() {
|
||||
const newPlace = await tripActions.addPlace(tripId, {
|
||||
name: place.name, description: place.description,
|
||||
lat: place.lat, lng: place.lng, address: place.address,
|
||||
category_id: place.category_id, icon: place.icon, price: place.price,
|
||||
category_id: place.category_id, price: place.price,
|
||||
})
|
||||
for (const a of capturedAssignments.filter(x => x.placeId === place.id)) {
|
||||
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('budgetSlice', () => {
|
||||
const item = buildBudgetItem({
|
||||
id: 8,
|
||||
trip_id: 1,
|
||||
members: [{ user_id: 3, paid: false }],
|
||||
members: [{ user_id: 3, paid: 0, username: 'carol' }],
|
||||
});
|
||||
seedStore(useTripStore, { budgetItems: [item] });
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { budgetApi } from '../../api/client'
|
||||
import { budgetRepo } from '../../repo/budgetRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { BudgetItem, BudgetMember } from '../../types'
|
||||
import type { BudgetItem, BudgetItemMember } from '../../types'
|
||||
import type { BudgetCreateItemRequest, BudgetUpdateItemRequest } from '@trek/shared'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
@@ -13,7 +14,7 @@ export interface BudgetSlice {
|
||||
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
|
||||
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
|
||||
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
|
||||
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetMember[]; item: BudgetItem }>
|
||||
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetItemMember[]; item: BudgetItem }>
|
||||
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
|
||||
@@ -31,7 +32,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
|
||||
addBudgetItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await budgetApi.create(tripId, data)
|
||||
const result = await budgetApi.create(tripId, data as BudgetCreateItemRequest)
|
||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
@@ -41,7 +42,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
|
||||
updateBudgetItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await budgetApi.update(tripId, id, data)
|
||||
const result = await budgetApi.update(tripId, id, data as BudgetUpdateItemRequest)
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
@@ -80,7 +81,10 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid } : m) }
|
||||
// The server persists `paid` as 0/1; the optimistic update stores the
|
||||
// boolean toggle value (truthy-compatible) — narrow it to the member's
|
||||
// numeric type without changing the stored runtime value.
|
||||
? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid: paid as unknown as number } : m) }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
|
||||
import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetItemMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
@@ -250,7 +250,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
||||
case 'assignment:reordered': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const currentItems = state.assignments[dayKey] || []
|
||||
const orderedIds: number[] = payload.orderedIds || []
|
||||
const orderedIds: number[] = (payload.orderedIds as number[] | undefined) || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = currentItems.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
@@ -356,7 +356,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
||||
case 'budget:members-updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i =>
|
||||
i.id === payload.itemId ? { ...i, members: payload.members as BudgetMember[], persons: payload.persons as number } : i
|
||||
i.id === payload.itemId ? { ...i, members: payload.members as BudgetItemMember[], persons: payload.persons as number } : i
|
||||
),
|
||||
}
|
||||
case 'budget:member-paid-updated':
|
||||
|
||||
@@ -35,7 +35,7 @@ interface VacayYearsResponse {
|
||||
|
||||
interface VacayEntriesResponse {
|
||||
entries: VacayEntry[]
|
||||
companyHolidays: string[]
|
||||
companyHolidays: { date: string; note?: string }[]
|
||||
}
|
||||
|
||||
interface VacayStatsResponse {
|
||||
@@ -109,7 +109,7 @@ interface VacayState {
|
||||
isFused: boolean
|
||||
years: number[]
|
||||
entries: VacayEntry[]
|
||||
companyHolidays: string[]
|
||||
companyHolidays: { date: string; note?: string }[]
|
||||
stats: VacayStat[]
|
||||
selectedYear: number
|
||||
selectedUserId: number | null
|
||||
|
||||
+72
-187
@@ -1,4 +1,53 @@
|
||||
// Shared types for the TREK travel planner
|
||||
// Shared types for the TREK travel planner.
|
||||
//
|
||||
// Domain entity/response types are now sourced from @trek/shared — the single
|
||||
// source of truth shared with the server. The Zod schemas there are built to
|
||||
// match the REAL server response shapes (see shared/src/<domain>/*.schema.ts,
|
||||
// each documented against the producing service). Re-exported here so the rest
|
||||
// of the client keeps importing from '../types' unchanged.
|
||||
import type {
|
||||
Trip,
|
||||
TripMember,
|
||||
Day,
|
||||
DayNote,
|
||||
Place,
|
||||
AssignmentPlace,
|
||||
PlaceCategory,
|
||||
Assignment,
|
||||
AssignmentParticipant,
|
||||
PackingItem,
|
||||
PackingBag,
|
||||
PackingBagMember,
|
||||
BudgetItem,
|
||||
BudgetItemMember,
|
||||
Reservation,
|
||||
ReservationEndpoint,
|
||||
Accommodation,
|
||||
Tag,
|
||||
Category,
|
||||
} from '@trek/shared'
|
||||
|
||||
export type {
|
||||
Trip,
|
||||
TripMember,
|
||||
Day,
|
||||
DayNote,
|
||||
Place,
|
||||
AssignmentPlace,
|
||||
PlaceCategory,
|
||||
Assignment,
|
||||
AssignmentParticipant,
|
||||
PackingItem,
|
||||
PackingBag,
|
||||
PackingBagMember,
|
||||
BudgetItem,
|
||||
BudgetItemMember,
|
||||
Reservation,
|
||||
ReservationEndpoint,
|
||||
Accommodation,
|
||||
Tag,
|
||||
Category,
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
@@ -14,85 +63,6 @@ export interface User {
|
||||
must_change_password?: boolean
|
||||
}
|
||||
|
||||
export interface Trip {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
start_date: string
|
||||
end_date: string
|
||||
cover_url: string | null
|
||||
is_archived: boolean
|
||||
reminder_days: number
|
||||
owner_id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Day {
|
||||
id: number
|
||||
trip_id: number
|
||||
day_number?: number
|
||||
date: string
|
||||
title: string | null
|
||||
notes: string | null
|
||||
assignments: Assignment[]
|
||||
notes_items: DayNote[]
|
||||
}
|
||||
|
||||
export interface Place {
|
||||
id: number
|
||||
trip_id: number
|
||||
name: string
|
||||
description: string | null
|
||||
notes: string | null
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
address: string | null
|
||||
category_id: number | null
|
||||
icon: string | null
|
||||
price: string | null
|
||||
currency: string | null
|
||||
image_url: string | null
|
||||
google_place_id: string | null
|
||||
osm_id: string | null
|
||||
route_geometry: string | null
|
||||
place_time: string | null
|
||||
end_time: string | null
|
||||
duration_minutes: number | null
|
||||
transport_mode: string | null
|
||||
website: string | null
|
||||
phone: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Assignment {
|
||||
id: number
|
||||
day_id: number
|
||||
place_id?: number
|
||||
order_index: number
|
||||
notes: string | null
|
||||
place: Place
|
||||
}
|
||||
|
||||
export interface DayNote {
|
||||
id: number
|
||||
day_id: number
|
||||
text: string
|
||||
time: string | null
|
||||
icon: string | null
|
||||
sort_order?: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PackingItem {
|
||||
id: number
|
||||
trip_id: number
|
||||
name: string
|
||||
category: string | null
|
||||
checked: number
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
id: number
|
||||
trip_id: number
|
||||
@@ -106,82 +76,6 @@ export interface TodoItem {
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: number
|
||||
name: string
|
||||
color: string | null
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
icon: string | null
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface BudgetItem {
|
||||
id: number
|
||||
trip_id: number
|
||||
name: string
|
||||
amount: number
|
||||
currency: string
|
||||
category: string | null
|
||||
paid_by: number | null
|
||||
persons: number
|
||||
members: BudgetMember[]
|
||||
expense_date: string | null
|
||||
}
|
||||
|
||||
export interface BudgetMember {
|
||||
user_id: number
|
||||
paid: boolean
|
||||
}
|
||||
|
||||
export interface ReservationEndpoint {
|
||||
id?: number
|
||||
reservation_id?: number
|
||||
role: 'from' | 'to' | 'stop'
|
||||
sequence: number
|
||||
name: string
|
||||
code: string | null
|
||||
lat: number
|
||||
lng: number
|
||||
timezone: string | null
|
||||
local_time: string | null
|
||||
local_date: string | null
|
||||
}
|
||||
|
||||
export interface Reservation {
|
||||
id: number
|
||||
trip_id: number
|
||||
name: string
|
||||
title?: string
|
||||
type: string
|
||||
status: 'pending' | 'confirmed'
|
||||
date: string | null
|
||||
time: string | null
|
||||
reservation_time?: string | null
|
||||
reservation_end_time?: string | null
|
||||
location?: string | null
|
||||
confirmation_number: string | null
|
||||
notes: string | null
|
||||
url: string | null
|
||||
day_id?: number | null
|
||||
end_day_id?: number | null
|
||||
place_id?: number | null
|
||||
assignment_id?: number | null
|
||||
accommodation_id?: number | null
|
||||
accommodation_start_day_id?: number | null
|
||||
accommodation_end_day_id?: number | null
|
||||
day_plan_position?: number | null
|
||||
day_positions?: Record<number, number> | null
|
||||
metadata?: Record<string, string> | string | null
|
||||
needs_review?: number
|
||||
endpoints?: ReservationEndpoint[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TripFile {
|
||||
id: number
|
||||
trip_id: number
|
||||
@@ -200,8 +94,10 @@ export interface TripFile {
|
||||
deleted_at?: string | null
|
||||
created_at: string
|
||||
reservation_title?: string
|
||||
linked_reservation_ids?: number[]
|
||||
url?: string
|
||||
linked_reservation_ids?: (number | null)[]
|
||||
linked_place_ids?: (number | null)[]
|
||||
/** Served download path — always present on list/create/update responses (formatFile). */
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@@ -271,41 +167,20 @@ export interface UserWithOidc extends User {
|
||||
oidc_issuer?: string | null
|
||||
}
|
||||
|
||||
// Accommodation type
|
||||
export interface Accommodation {
|
||||
id: number
|
||||
trip_id: number
|
||||
name: string
|
||||
address: string | null
|
||||
check_in: string | null
|
||||
check_in_end: string | null
|
||||
check_out: string | null
|
||||
confirmation_number: string | null
|
||||
notes: string | null
|
||||
url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Trip member (owner or collaborator)
|
||||
export interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
email?: string
|
||||
avatar_url?: string | null
|
||||
role?: string
|
||||
}
|
||||
|
||||
// Photo type
|
||||
// Photo type — trip photo as consumed by the PhotosPage / PhotoGallery /
|
||||
// PhotoLightbox surface (photos table joined with a served `url`). file_size is
|
||||
// the photos.file_size column; url is the served upload path.
|
||||
export interface Photo {
|
||||
id: number
|
||||
trip_id: number
|
||||
filename: string
|
||||
trip_id?: number
|
||||
url: string
|
||||
original_name: string
|
||||
mime_type: string
|
||||
size: number
|
||||
mime_type?: string
|
||||
file_size?: number | null
|
||||
caption: string | null
|
||||
place_id: number | null
|
||||
day_id: number | null
|
||||
taken_at?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -381,6 +256,8 @@ export interface VacayPlan {
|
||||
block_weekends: boolean
|
||||
carry_over_enabled: boolean
|
||||
company_holidays_enabled: boolean
|
||||
// Comma-separated weekday indices (e.g. '0,6'); stored as TEXT on vacay_plans.
|
||||
weekend_days?: string
|
||||
week_start?: number
|
||||
name?: string
|
||||
year?: number
|
||||
@@ -403,10 +280,18 @@ export interface VacayEntry {
|
||||
person_name?: string
|
||||
}
|
||||
|
||||
// Vacay per-user stats row as returned by getStats
|
||||
// (server/src/services/vacayService.ts -> getStats).
|
||||
export interface VacayStat {
|
||||
user_id: number
|
||||
person_name: string
|
||||
person_color: string
|
||||
year: number
|
||||
vacation_days: number
|
||||
carried_over: number
|
||||
total_available: number
|
||||
used: number
|
||||
remaining: number
|
||||
}
|
||||
|
||||
export interface HolidayInfo {
|
||||
|
||||
@@ -79,6 +79,6 @@ export function splitReservationDateTime(value?: string | null): { date: string
|
||||
|
||||
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
||||
const da = assignments[String(dayId)] || []
|
||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
||||
const total = da.reduce((s, a) => s + (parseFloat(String(a.place?.price ?? '')) || 0), 0)
|
||||
return total > 0 ? `${total.toFixed(0)} ${currency}` : null
|
||||
}
|
||||
|
||||
@@ -66,14 +66,15 @@ export function buildTrip(overrides: Partial<Trip> = {}): Trip {
|
||||
const id = next();
|
||||
return {
|
||||
id,
|
||||
name: `Trip ${id}`,
|
||||
user_id: 1,
|
||||
title: `Trip ${id}`,
|
||||
description: null,
|
||||
start_date: '2025-06-01',
|
||||
end_date: '2025-06-05',
|
||||
cover_url: null,
|
||||
is_archived: false,
|
||||
currency: 'EUR',
|
||||
cover_image: null,
|
||||
is_archived: 0,
|
||||
reminder_days: 7,
|
||||
owner_id: 1,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
updated_at: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
@@ -105,14 +106,19 @@ export function buildPlace(overrides: Partial<Place> = {}): Place {
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: 60,
|
||||
notes: null,
|
||||
transport_mode: 'walking',
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
@@ -154,6 +160,7 @@ export function buildPackingItem(overrides: Partial<PackingItem> = {}): PackingI
|
||||
name: `Packing item ${id}`,
|
||||
category: null,
|
||||
checked: 0,
|
||||
sort_order: 0,
|
||||
quantity: 1,
|
||||
...overrides,
|
||||
};
|
||||
@@ -181,14 +188,16 @@ export function buildBudgetItem(overrides: Partial<BudgetItem> = {}): BudgetItem
|
||||
return {
|
||||
id,
|
||||
trip_id: 1,
|
||||
category: 'Other',
|
||||
name: `Budget item ${id}`,
|
||||
amount: 100,
|
||||
currency: 'EUR',
|
||||
category: null,
|
||||
paid_by: null,
|
||||
total_price: 100,
|
||||
persons: 1,
|
||||
days: null,
|
||||
note: null,
|
||||
sort_order: 0,
|
||||
members: [],
|
||||
expense_date: null,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -198,14 +207,14 @@ export function buildReservation(overrides: Partial<Reservation> = {}): Reservat
|
||||
return {
|
||||
id,
|
||||
trip_id: 1,
|
||||
name: `Reservation ${id}`,
|
||||
title: `Reservation ${id}`,
|
||||
type: 'restaurant',
|
||||
status: 'confirmed',
|
||||
date: null,
|
||||
time: null,
|
||||
reservation_time: null,
|
||||
reservation_end_time: null,
|
||||
location: null,
|
||||
confirmation_number: null,
|
||||
notes: null,
|
||||
url: null,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
@@ -219,6 +228,7 @@ export function buildTripFile(overrides: Partial<TripFile> = {}): TripFile {
|
||||
filename: 'test.pdf',
|
||||
original_name: 'test.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
url: `/api/trips/1/files/${id}/download`,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
@@ -240,6 +250,7 @@ export function buildCategory(overrides: Partial<Category> = {}): Category {
|
||||
return {
|
||||
id,
|
||||
name: `Category ${id}`,
|
||||
color: '#6366f1',
|
||||
icon: 'restaurant',
|
||||
user_id: 1,
|
||||
...overrides,
|
||||
|
||||
@@ -25,9 +25,17 @@ export function resetAllStores(): void {
|
||||
usePermissionsStore.setState(initialPermsState, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests routinely seed a store with a partially-populated slice of state,
|
||||
* including partial nested objects (e.g. only `settings.time_format`). The
|
||||
* store's own setState wants the exact field types, so seeding accepts a
|
||||
* deep-partial view and casts at the boundary.
|
||||
*/
|
||||
type DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> } : T;
|
||||
|
||||
export function seedStore<T extends object>(
|
||||
store: { setState: (partial: Partial<T>, replace?: boolean) => void },
|
||||
state: Partial<T>,
|
||||
state: DeepPartial<T>,
|
||||
): void {
|
||||
store.setState(state);
|
||||
store.setState(state as Partial<T>);
|
||||
}
|
||||
|
||||
@@ -623,13 +623,13 @@ describe('API namespace smoke tests', () => {
|
||||
// ── tripsApi additional methods ──────────────────────────────────────────────
|
||||
|
||||
it('tripsApi.create posts new trip', async () => {
|
||||
server.use(http.post('/api/trips', () => HttpResponse.json({ id: 1, name: 'Test' })));
|
||||
await expect(tripsApi.create({ name: 'Test' })).resolves.toMatchObject({ id: 1 });
|
||||
server.use(http.post('/api/trips', () => HttpResponse.json({ id: 1, title: 'Test' })));
|
||||
await expect(tripsApi.create({ title: 'Test' })).resolves.toMatchObject({ id: 1 });
|
||||
});
|
||||
|
||||
it('tripsApi.update puts trip data', async () => {
|
||||
server.use(http.put('/api/trips/1', () => HttpResponse.json({ id: 1 })));
|
||||
await expect(tripsApi.update(1, { name: 'Updated' })).resolves.toMatchObject({ id: 1 });
|
||||
await expect(tripsApi.update(1, { title: 'Updated' })).resolves.toMatchObject({ id: 1 });
|
||||
});
|
||||
|
||||
it('tripsApi.delete deletes a trip', async () => {
|
||||
@@ -765,7 +765,7 @@ describe('API namespace smoke tests', () => {
|
||||
|
||||
it('reservationsApi.create creates a reservation', async () => {
|
||||
server.use(http.post('/api/trips/1/reservations', () => HttpResponse.json({ id: 1 })));
|
||||
await expect(reservationsApi.create(1, { name: 'Hotel' })).resolves.toMatchObject({ id: 1 });
|
||||
await expect(reservationsApi.create(1, { title: 'Hotel' })).resolves.toMatchObject({ id: 1 });
|
||||
});
|
||||
|
||||
it('reservationsApi.delete deletes a reservation', async () => {
|
||||
@@ -784,7 +784,7 @@ describe('API namespace smoke tests', () => {
|
||||
|
||||
it('accommodationsApi.create creates accommodation', async () => {
|
||||
server.use(http.post('/api/trips/1/accommodations', () => HttpResponse.json({ id: 1 })));
|
||||
await expect(accommodationsApi.create(1, { name: 'Hotel' })).resolves.toMatchObject({ id: 1 });
|
||||
await expect(accommodationsApi.create(1, { place_id: 1, start_day_id: 1, end_day_id: 1 })).resolves.toMatchObject({ id: 1 });
|
||||
});
|
||||
|
||||
it('accommodationsApi.delete deletes accommodation', async () => {
|
||||
|
||||
@@ -33,14 +33,15 @@ import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation,
|
||||
|
||||
const makeTrip = (id = 1): Trip => ({
|
||||
id,
|
||||
name: `Trip ${id}`,
|
||||
user_id: 42,
|
||||
title: `Trip ${id}`,
|
||||
description: null,
|
||||
start_date: '2026-07-01',
|
||||
end_date: '2026-07-05',
|
||||
cover_url: null,
|
||||
is_archived: false,
|
||||
currency: 'EUR',
|
||||
cover_image: null,
|
||||
is_archived: 0,
|
||||
reminder_days: 3,
|
||||
owner_id: 42,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
@@ -65,7 +66,6 @@ const makePlace = (id: number, tripId = 1): Place => ({
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
@@ -102,14 +102,14 @@ describe('offlineDb — trips', () => {
|
||||
await upsertTrip(trip);
|
||||
const stored = await offlineDb.trips.get(10);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.name).toBe('Trip 10');
|
||||
expect(stored!.title).toBe('Trip 10');
|
||||
});
|
||||
|
||||
it('upsertTrip overwrites an existing trip (put semantics)', async () => {
|
||||
await upsertTrip(makeTrip(1));
|
||||
await upsertTrip({ ...makeTrip(1), name: 'Updated' });
|
||||
await upsertTrip({ ...makeTrip(1), title: 'Updated' });
|
||||
const stored = await offlineDb.trips.get(1);
|
||||
expect(stored!.name).toBe('Updated');
|
||||
expect(stored!.title).toBe('Updated');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('offlineDb — places', () => {
|
||||
|
||||
describe('offlineDb — packing / todo / budget / reservations / files', () => {
|
||||
it('upserts packing items', async () => {
|
||||
const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, quantity: 1 };
|
||||
const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, sort_order: 0, quantity: 1 };
|
||||
await upsertPackingItems([item]);
|
||||
expect(await offlineDb.packingItems.count()).toBe(1);
|
||||
});
|
||||
@@ -149,8 +149,8 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => {
|
||||
|
||||
it('upserts budget items', async () => {
|
||||
const item: BudgetItem = {
|
||||
id: 1, trip_id: 1, name: 'Flight', amount: 500, currency: 'EUR',
|
||||
category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null,
|
||||
id: 1, trip_id: 1, name: 'Flight', total_price: 500,
|
||||
category: 'Transport', persons: 1, members: [], expense_date: null, sort_order: 0,
|
||||
};
|
||||
await upsertBudgetItems([item]);
|
||||
expect(await offlineDb.budgetItems.count()).toBe(1);
|
||||
@@ -158,8 +158,8 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => {
|
||||
|
||||
it('upserts reservations', async () => {
|
||||
const item: Reservation = {
|
||||
id: 1, trip_id: 1, name: 'Hotel', type: 'hotel', status: 'confirmed',
|
||||
date: null, time: null, confirmation_number: null, notes: null, url: null, created_at: '2026-01-01T00:00:00Z',
|
||||
id: 1, trip_id: 1, title: 'Hotel', type: 'hotel', status: 'confirmed',
|
||||
reservation_time: null, confirmation_number: null, notes: null, created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
await upsertReservations([item]);
|
||||
expect(await offlineDb.reservations.count()).toBe(1);
|
||||
@@ -168,7 +168,7 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => {
|
||||
it('upserts trip files', async () => {
|
||||
const file: TripFile = {
|
||||
id: 1, trip_id: 1, filename: 'ticket.pdf', original_name: 'Ticket.pdf',
|
||||
mime_type: 'application/pdf', created_at: '2026-01-01T00:00:00Z',
|
||||
mime_type: 'application/pdf', url: '/api/trips/1/files/1/download', created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
await upsertTripFiles([file]);
|
||||
expect(await offlineDb.tripFiles.count()).toBe(1);
|
||||
@@ -238,7 +238,7 @@ describe('offlineDb — clearTripData', () => {
|
||||
await upsertTrip(makeTrip(1));
|
||||
await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
|
||||
await upsertPlaces([makePlace(10, 1)]);
|
||||
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, quantity: 1 };
|
||||
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, sort_order: 0, quantity: 1 };
|
||||
await upsertPackingItems([item]);
|
||||
|
||||
// Also add data for a different trip — should NOT be removed
|
||||
|
||||
@@ -2,15 +2,15 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildBudgetItem } from '../../helpers/factories';
|
||||
import type { BudgetMember } from '../../../src/types';
|
||||
import type { BudgetItemMember } from '../../../src/types';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('remoteEventHandler > budget', () => {
|
||||
const member1: BudgetMember = { user_id: 5, paid: false };
|
||||
const member2: BudgetMember = { user_id: 6, paid: true };
|
||||
const member1: BudgetItemMember = { user_id: 5, paid: 0, username: 'eve' };
|
||||
const member2: BudgetItemMember = { user_id: 6, paid: 1, username: 'frank' };
|
||||
|
||||
const seedData = () => {
|
||||
useTripStore.setState({
|
||||
@@ -40,12 +40,12 @@ describe('remoteEventHandler > budget', () => {
|
||||
|
||||
it('FE-WSEVT-BUDGET-003: budget:updated replaces item in array', () => {
|
||||
seedData();
|
||||
const updated = buildBudgetItem({ id: 1, name: 'Updated Hotel', amount: 500 });
|
||||
const updated = buildBudgetItem({ id: 1, name: 'Updated Hotel', total_price: 500 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'budget:updated', item: updated });
|
||||
const { budgetItems } = useTripStore.getState();
|
||||
const item = budgetItems.find(i => i.id === 1);
|
||||
expect(item?.name).toBe('Updated Hotel');
|
||||
expect(item?.amount).toBe(500);
|
||||
expect(item?.total_price).toBe(500);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-BUDGET-004: budget:deleted removes item by ID', () => {
|
||||
@@ -58,7 +58,7 @@ describe('remoteEventHandler > budget', () => {
|
||||
|
||||
it('FE-WSEVT-BUDGET-005: budget:members-updated replaces entire members array and persons count', () => {
|
||||
seedData();
|
||||
const newMembers: BudgetMember[] = [{ user_id: 7, paid: true }, { user_id: 8, paid: false }];
|
||||
const newMembers: BudgetItemMember[] = [{ user_id: 7, paid: 1, username: 'grace' }, { user_id: 8, paid: 0, username: 'heidi' }];
|
||||
useTripStore.getState().handleRemoteEvent({
|
||||
type: 'budget:members-updated',
|
||||
itemId: 1,
|
||||
@@ -86,8 +86,8 @@ describe('remoteEventHandler > budget', () => {
|
||||
const item = budgetItems.find(i => i.id === 1);
|
||||
const m = item?.members?.find(m => m.user_id === 5);
|
||||
expect(m?.paid).toBe(true);
|
||||
// Other item members unchanged
|
||||
// Other item members unchanged (member2 keeps its seeded paid value)
|
||||
const item2 = budgetItems.find(i => i.id === 2);
|
||||
expect(item2?.members?.[0].paid).toBe(true);
|
||||
expect(item2?.members?.[0].paid).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,13 +10,13 @@ beforeEach(() => {
|
||||
describe('remoteEventHandler > reservations', () => {
|
||||
const seedData = () => {
|
||||
useTripStore.setState({
|
||||
reservations: [buildReservation({ id: 1, name: 'Hotel Paris' })],
|
||||
reservations: [buildReservation({ id: 1, title: 'Hotel Paris' })],
|
||||
});
|
||||
};
|
||||
|
||||
it('FE-WSEVT-RESERV-001: reservation:created prepends new reservation to array', () => {
|
||||
seedData();
|
||||
const newRes = buildReservation({ id: 99, name: 'Flight' });
|
||||
const newRes = buildReservation({ id: 99, title: 'Flight' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: newRes });
|
||||
const { reservations } = useTripStore.getState();
|
||||
expect(reservations).toHaveLength(2);
|
||||
@@ -25,19 +25,19 @@ describe('remoteEventHandler > reservations', () => {
|
||||
|
||||
it('FE-WSEVT-RESERV-002: reservation:created is idempotent — no duplicate if same ID', () => {
|
||||
seedData();
|
||||
const duplicate = buildReservation({ id: 1, name: 'Hotel Paris Dup' });
|
||||
const duplicate = buildReservation({ id: 1, title: 'Hotel Paris Dup' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: duplicate });
|
||||
const { reservations } = useTripStore.getState();
|
||||
expect(reservations).toHaveLength(1);
|
||||
expect(reservations[0].name).toBe('Hotel Paris');
|
||||
expect(reservations[0].title).toBe('Hotel Paris');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-RESERV-003: reservation:updated replaces reservation in array', () => {
|
||||
seedData();
|
||||
const updated = buildReservation({ id: 1, name: 'Hotel Lyon' });
|
||||
const updated = buildReservation({ id: 1, title: 'Hotel Lyon' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:updated', reservation: updated });
|
||||
const { reservations } = useTripStore.getState();
|
||||
expect(reservations[0].name).toBe('Hotel Lyon');
|
||||
expect(reservations[0].title).toBe('Hotel Lyon');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-RESERV-004: reservation:deleted removes reservation by ID', () => {
|
||||
@@ -49,8 +49,8 @@ describe('remoteEventHandler > reservations', () => {
|
||||
|
||||
it('FE-WSEVT-RESERV-005: reservation:created ordering — newest is first', () => {
|
||||
seedData();
|
||||
const r2 = buildReservation({ id: 2, name: 'Second' });
|
||||
const r3 = buildReservation({ id: 3, name: 'Third' });
|
||||
const r2 = buildReservation({ id: 2, title: 'Second' });
|
||||
const r3 = buildReservation({ id: 3, title: 'Third' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r2 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r3 });
|
||||
const { reservations } = useTripStore.getState();
|
||||
|
||||
@@ -9,21 +9,21 @@ beforeEach(() => {
|
||||
|
||||
describe('remoteEventHandler > trip', () => {
|
||||
it('FE-WSEVT-TRIP-001: trip:updated replaces trip in state', () => {
|
||||
const originalTrip = buildTrip({ id: 1, name: 'Paris Trip' });
|
||||
const originalTrip = buildTrip({ id: 1, title: 'Paris Trip' });
|
||||
useTripStore.setState({ trip: originalTrip });
|
||||
const updatedTrip = buildTrip({ id: 1, name: 'Paris & Lyon Trip' });
|
||||
const updatedTrip = buildTrip({ id: 1, title: 'Paris & Lyon Trip' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip });
|
||||
const { trip } = useTripStore.getState();
|
||||
expect(trip?.name).toBe('Paris & Lyon Trip');
|
||||
expect(trip?.title).toBe('Paris & Lyon Trip');
|
||||
});
|
||||
|
||||
it('FE-WSEVT-TRIP-002: trip:updated does not affect other state fields', () => {
|
||||
const existingPlace = buildPlace({ id: 55, name: 'Eiffel Tower' });
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: 1, name: 'Original' }),
|
||||
trip: buildTrip({ id: 1, title: 'Original' }),
|
||||
places: [existingPlace],
|
||||
});
|
||||
const updatedTrip = buildTrip({ id: 1, name: 'Updated' });
|
||||
const updatedTrip = buildTrip({ id: 1, title: 'Updated' });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip });
|
||||
const { places } = useTripStore.getState();
|
||||
expect(places).toHaveLength(1);
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('budgetSlice', () => {
|
||||
const existing = buildBudgetItem({ trip_id: 1 });
|
||||
seedStore(useTripStore, { budgetItems: [existing] });
|
||||
|
||||
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel', amount: 200 });
|
||||
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel', total_price: 200 });
|
||||
|
||||
expect(result.name).toBe('Hotel');
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(2);
|
||||
@@ -64,7 +64,7 @@ describe('budgetSlice', () => {
|
||||
|
||||
describe('updateBudgetItem', () => {
|
||||
it('FE-BUDGET-004: updateBudgetItem replaces item in array', async () => {
|
||||
const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', amount: 100 });
|
||||
const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', total_price: 100 });
|
||||
seedStore(useTripStore, { budgetItems: [item] });
|
||||
|
||||
server.use(
|
||||
@@ -74,16 +74,16 @@ describe('budgetSlice', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().updateBudgetItem(1, 10, { name: 'Updated', amount: 150 });
|
||||
const result = await useTripStore.getState().updateBudgetItem(1, 10, { name: 'Updated', total_price: 150 });
|
||||
|
||||
expect(result.name).toBe('Updated');
|
||||
expect(useTripStore.getState().budgetItems[0].name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('FE-BUDGET-005: updateBudgetItem with total_price triggers loadReservations when reservation_id present', async () => {
|
||||
const item = buildBudgetItem({ id: 10, trip_id: 1, amount: 100 });
|
||||
const item = buildBudgetItem({ id: 10, trip_id: 1, total_price: 100 });
|
||||
const initialReservation = buildReservation({ trip_id: 1 });
|
||||
const newReservation = buildReservation({ trip_id: 1, name: 'Refreshed Reservation' });
|
||||
const newReservation = buildReservation({ trip_id: 1, title: 'Refreshed Reservation' });
|
||||
seedStore(useTripStore, {
|
||||
budgetItems: [item],
|
||||
reservations: [initialReservation],
|
||||
@@ -106,7 +106,7 @@ describe('budgetSlice', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(useTripStore.getState().reservations).toHaveLength(1);
|
||||
expect(useTripStore.getState().reservations[0].name).toBe('Refreshed Reservation');
|
||||
expect(useTripStore.getState().reservations[0].title).toBe('Refreshed Reservation');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ describe('budgetSlice', () => {
|
||||
|
||||
describe('toggleBudgetMemberPaid', () => {
|
||||
it('FE-BUDGET-008: toggleBudgetMemberPaid updates paid status after API success', async () => {
|
||||
const member = { user_id: 5, paid: false };
|
||||
const member = { user_id: 5, paid: 0, username: 'dave' };
|
||||
const item = buildBudgetItem({ id: 10, trip_id: 1, members: [member] });
|
||||
seedStore(useTripStore, { budgetItems: [item] });
|
||||
|
||||
|
||||
@@ -42,20 +42,20 @@ describe('reservationsSlice', () => {
|
||||
|
||||
describe('addReservation', () => {
|
||||
it('FE-RESERV-002: addReservation prepends to reservations array', async () => {
|
||||
const existing = buildReservation({ trip_id: 1, name: 'Existing' });
|
||||
const existing = buildReservation({ trip_id: 1, title: 'Existing' });
|
||||
seedStore(useTripStore, { reservations: [existing] });
|
||||
|
||||
const result = await useTripStore.getState().addReservation(1, {
|
||||
name: 'New Hotel',
|
||||
title: 'New Hotel',
|
||||
type: 'hotel',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
expect(result.name).toBe('New Hotel');
|
||||
expect(result.title).toBe('New Hotel');
|
||||
const reservations = useTripStore.getState().reservations;
|
||||
expect(reservations).toHaveLength(2);
|
||||
// addReservation prepends
|
||||
expect(reservations[0].name).toBe('New Hotel');
|
||||
expect(reservations[0].title).toBe('New Hotel');
|
||||
});
|
||||
|
||||
it('FE-RESERV-003: addReservation on failure throws', async () => {
|
||||
@@ -66,14 +66,14 @@ describe('reservationsSlice', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
useTripStore.getState().addReservation(1, { name: 'Fail' })
|
||||
useTripStore.getState().addReservation(1, { title: 'Fail' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReservation', () => {
|
||||
it('FE-RESERV-004: updateReservation replaces item in array by id', async () => {
|
||||
const reservation = buildReservation({ id: 10, trip_id: 1, name: 'Old', status: 'pending' });
|
||||
const reservation = buildReservation({ id: 10, trip_id: 1, title: 'Old', status: 'pending' });
|
||||
seedStore(useTripStore, { reservations: [reservation] });
|
||||
|
||||
server.use(
|
||||
@@ -83,10 +83,10 @@ describe('reservationsSlice', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().updateReservation(1, 10, { name: 'Updated Hotel' });
|
||||
const result = await useTripStore.getState().updateReservation(1, 10, { title: 'Updated Hotel' });
|
||||
|
||||
expect(result.name).toBe('Updated Hotel');
|
||||
expect(useTripStore.getState().reservations[0].name).toBe('Updated Hotel');
|
||||
expect(result.title).toBe('Updated Hotel');
|
||||
expect(useTripStore.getState().reservations[0].title).toBe('Updated Hotel');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -201,14 +201,14 @@ describe('tripStore', () => {
|
||||
|
||||
describe('updateTrip', () => {
|
||||
it('FE-TRIP-008: updateTrip persists and refreshes trip + days', async () => {
|
||||
const updatedTrip = buildTrip({ id: 1, name: 'Updated Trip' });
|
||||
const updatedTrip = buildTrip({ id: 1, title: 'Updated Trip' });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1', () => HttpResponse.json({ trip: updatedTrip })),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
||||
);
|
||||
|
||||
const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' });
|
||||
const result = await useTripStore.getState().updateTrip(1, { title: 'Updated Trip' });
|
||||
|
||||
expect(result).toEqual(updatedTrip);
|
||||
expect(useTripStore.getState().trip).toEqual(updatedTrip);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../../src/utils/formatters';
|
||||
import type { AssignmentsMap } from '../../../src/types';
|
||||
|
||||
// dayTotalCost intentionally exercises edge-case price inputs (string / non-numeric),
|
||||
// which are looser than the canonical AssignmentsMap shape — hence the casts below.
|
||||
const asMap = (m: unknown): AssignmentsMap => m as AssignmentsMap;
|
||||
|
||||
describe('currencyDecimals', () => {
|
||||
it('returns 0 for zero-decimal currencies', () => {
|
||||
@@ -68,7 +73,7 @@ describe('dayTotalCost', () => {
|
||||
{ id: 1, day_id: 1, order_index: 0, notes: null, place: { id: 1, trip_id: 1, name: 'P', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: null, image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
|
||||
],
|
||||
};
|
||||
expect(dayTotalCost(1, assignments, 'EUR')).toBeNull();
|
||||
expect(dayTotalCost(1, asMap(assignments), 'EUR')).toBeNull();
|
||||
});
|
||||
|
||||
it('sums prices across assignments', () => {
|
||||
@@ -78,7 +83,7 @@ describe('dayTotalCost', () => {
|
||||
{ id: 2, day_id: 1, order_index: 1, notes: null, place: { id: 2, trip_id: 1, name: 'B', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: '30', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
|
||||
],
|
||||
};
|
||||
expect(dayTotalCost(1, assignments, 'EUR')).toBe('50 EUR');
|
||||
expect(dayTotalCost(1, asMap(assignments), 'EUR')).toBe('50 EUR');
|
||||
});
|
||||
|
||||
it('ignores non-numeric price strings', () => {
|
||||
@@ -87,7 +92,7 @@ describe('dayTotalCost', () => {
|
||||
{ id: 1, day_id: 1, order_index: 0, notes: null, place: { id: 1, trip_id: 1, name: 'A', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: 'free', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
|
||||
],
|
||||
};
|
||||
expect(dayTotalCost(1, assignments, 'EUR')).toBeNull();
|
||||
expect(dayTotalCost(1, asMap(assignments), 'EUR')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the dayId key to look up assignments', () => {
|
||||
@@ -96,7 +101,7 @@ describe('dayTotalCost', () => {
|
||||
{ id: 3, day_id: 2, order_index: 0, notes: null, place: { id: 3, trip_id: 1, name: 'C', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: '10', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
|
||||
],
|
||||
};
|
||||
expect(dayTotalCost(1, assignments, 'USD')).toBeNull();
|
||||
expect(dayTotalCost(2, assignments, 'USD')).toBe('10 USD');
|
||||
expect(dayTotalCost(1, asMap(assignments), 'USD')).toBeNull();
|
||||
expect(dayTotalCost(2, asMap(assignments), 'USD')).toBe('10 USD');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { assignmentPlaceSchema } from '../place/place.schema';
|
||||
|
||||
/**
|
||||
* Assignment API contract — single source of truth for the place↔day itinerary
|
||||
@@ -11,6 +12,38 @@ import { z } from 'zod';
|
||||
* request schemas + the bespoke 404/400 controller messages pin the rest.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Assignment participant embedded on an assignment
|
||||
* (server/src/services/queryHelpers.ts -> loadParticipantsByAssignmentIds).
|
||||
*/
|
||||
export const assignmentParticipantSchema = z.object({
|
||||
user_id: z.number(),
|
||||
username: z.string(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
});
|
||||
export type AssignmentParticipant = z.infer<typeof assignmentParticipantSchema>;
|
||||
|
||||
/**
|
||||
* Assignment entity as returned by the day/assignment endpoints
|
||||
* (server/src/services/queryHelpers.ts -> formatAssignmentWithPlace, and
|
||||
* assignmentService.getAssignmentWithPlace). The embedded `place` is the trimmed
|
||||
* assignment-place projection, NOT the full place pool entity. `assignment_time`
|
||||
* /`assignment_end_time` carry the per-assignment override times.
|
||||
*/
|
||||
export const assignmentSchema = z.object({
|
||||
id: z.number(),
|
||||
day_id: z.number(),
|
||||
place_id: z.number(),
|
||||
order_index: z.number(),
|
||||
notes: z.string().nullable().optional(),
|
||||
assignment_time: z.string().nullable().optional(),
|
||||
assignment_end_time: z.string().nullable().optional(),
|
||||
participants: z.array(assignmentParticipantSchema).optional(),
|
||||
created_at: z.string().optional(),
|
||||
place: assignmentPlaceSchema,
|
||||
});
|
||||
export type Assignment = z.infer<typeof assignmentSchema>;
|
||||
|
||||
export const assignmentCreateRequestSchema = z.object({
|
||||
place_id: z.union([z.number(), z.string()]),
|
||||
notes: z.string().nullable().optional(),
|
||||
|
||||
@@ -12,6 +12,44 @@ import { z } from 'zod';
|
||||
* linked reservation's metadata (and broadcasts reservation:updated).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Budget item member as embedded on a budget item
|
||||
* (server/src/services/budgetService.ts -> loadItemMembers). `paid` is the raw
|
||||
* SQLite INTEGER (0/1); `avatar_url` is the resolved avatar (avatarUrl()).
|
||||
*/
|
||||
export const budgetItemMemberSchema = z.object({
|
||||
user_id: z.number(),
|
||||
paid: z.number(),
|
||||
username: z.string(),
|
||||
avatar_url: z.string().nullable().optional(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
budget_item_id: z.number().optional(),
|
||||
});
|
||||
export type BudgetItemMember = z.infer<typeof budgetItemMemberSchema>;
|
||||
|
||||
/**
|
||||
* Budget item entity as returned by the budget list/create/update endpoints
|
||||
* (server/src/services/budgetService.ts). Columns of the `budget_items` table
|
||||
* plus the embedded `members` array. total_price is SQLite REAL.
|
||||
*/
|
||||
export const budgetItemSchema = z.object({
|
||||
id: z.number(),
|
||||
trip_id: z.number(),
|
||||
category: z.string(),
|
||||
name: z.string(),
|
||||
total_price: z.number(),
|
||||
persons: z.number().nullable().optional(),
|
||||
days: z.number().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
reservation_id: z.number().nullable().optional(),
|
||||
paid_by_user_id: z.number().nullable().optional(),
|
||||
expense_date: z.string().nullable().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
created_at: z.string().optional(),
|
||||
members: z.array(budgetItemMemberSchema).optional(),
|
||||
});
|
||||
export type BudgetItem = z.infer<typeof budgetItemSchema>;
|
||||
|
||||
export const budgetCreateItemRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { assignmentSchema } from '../assignment/assignment.schema';
|
||||
|
||||
/**
|
||||
* Day + day-note API contract — single source of truth for the
|
||||
@@ -11,6 +12,39 @@ import { z } from 'zod';
|
||||
* (the legacy validateStringLengths middleware) — reproduced in the controller.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Day note entity (server day_notes table / dayNoteService). `sort_order` is
|
||||
* SQLite REAL; `icon` defaults to a note emoji.
|
||||
*/
|
||||
export const dayNoteSchema = z.object({
|
||||
id: z.number(),
|
||||
day_id: z.number(),
|
||||
trip_id: z.number().optional(),
|
||||
text: z.string(),
|
||||
time: z.string().nullable().optional(),
|
||||
icon: z.string().nullable().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
created_at: z.string().optional(),
|
||||
});
|
||||
export type DayNote = z.infer<typeof dayNoteSchema>;
|
||||
|
||||
/**
|
||||
* Day entity as returned by the day list/get endpoints
|
||||
* (server/src/services/dayService.ts -> listDays). Columns of the `days` table
|
||||
* plus the embedded `assignments` and `notes_items` arrays.
|
||||
*/
|
||||
export const daySchema = z.object({
|
||||
id: z.number(),
|
||||
trip_id: z.number(),
|
||||
day_number: z.number().optional(),
|
||||
date: z.string().nullable().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
assignments: z.array(assignmentSchema).optional(),
|
||||
notes_items: z.array(dayNoteSchema).optional(),
|
||||
});
|
||||
export type Day = z.infer<typeof daySchema>;
|
||||
|
||||
export const dayCreateRequestSchema = z.object({
|
||||
date: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
|
||||
@@ -13,6 +13,56 @@ import { z } from 'zod';
|
||||
|
||||
const open = z.record(z.string(), z.unknown());
|
||||
|
||||
/**
|
||||
* Packing item entity as returned by the packing endpoints
|
||||
* (server/src/services/packingService.ts -> SELECT * FROM packing_items).
|
||||
* `checked` is the raw SQLite INTEGER (0/1). Columns match the packing_items
|
||||
* table (see server DB): weight_grams/bag_id are nullable, quantity defaults 1.
|
||||
*/
|
||||
export const packingItemSchema = z.object({
|
||||
id: z.number(),
|
||||
trip_id: z.number(),
|
||||
name: z.string(),
|
||||
checked: z.number(),
|
||||
category: z.string().nullable().optional(),
|
||||
sort_order: z.number(),
|
||||
weight_grams: z.number().nullable().optional(),
|
||||
bag_id: z.number().nullable().optional(),
|
||||
quantity: z.number().optional(),
|
||||
created_at: z.string().optional(),
|
||||
});
|
||||
export type PackingItem = z.infer<typeof packingItemSchema>;
|
||||
|
||||
/**
|
||||
* Packing bag member embedded on a bag (server packingService -> listBags).
|
||||
* `avatar` is the resolved avatar URL.
|
||||
*/
|
||||
export const packingBagMemberSchema = z.object({
|
||||
user_id: z.number(),
|
||||
username: z.string(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
});
|
||||
export type PackingBagMember = z.infer<typeof packingBagMemberSchema>;
|
||||
|
||||
/**
|
||||
* Packing bag entity (server packingService -> listBags). Columns of the
|
||||
* packing_bags table plus the embedded `members` array (and the optional
|
||||
* `assigned_username` join present on updateBag).
|
||||
*/
|
||||
export const packingBagSchema = z.object({
|
||||
id: z.number(),
|
||||
trip_id: z.number(),
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
weight_limit_grams: z.number().nullable().optional(),
|
||||
sort_order: z.number(),
|
||||
user_id: z.number().nullable().optional(),
|
||||
assigned_username: z.string().nullable().optional(),
|
||||
created_at: z.string().optional(),
|
||||
members: z.array(packingBagMemberSchema).optional(),
|
||||
});
|
||||
export type PackingBag = z.infer<typeof packingBagSchema>;
|
||||
|
||||
export const packingCreateItemRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { categorySchema } from '../category/category.schema';
|
||||
import { tagSchema } from '../tag/tag.schema';
|
||||
|
||||
/**
|
||||
* Place API contract — single source of truth for the /api/trips/:tripId/places
|
||||
@@ -14,6 +16,90 @@ import { z } from 'zod';
|
||||
|
||||
const open = z.record(z.string(), z.unknown());
|
||||
|
||||
/**
|
||||
* Embedded category as returned on a place — a trimmed projection of the
|
||||
* categories row (id/name/color/icon), built inline by placeService and
|
||||
* getPlaceWithTags. `null` when the place has no category_id.
|
||||
*/
|
||||
export const placeCategorySchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
name: z.string().nullable(),
|
||||
color: z.string().nullable(),
|
||||
icon: z.string().nullable(),
|
||||
})
|
||||
.nullable();
|
||||
export type PlaceCategory = z.infer<typeof placeCategorySchema>;
|
||||
|
||||
/**
|
||||
* Full place entity as returned by the place list / get / create / update
|
||||
* endpoints (server/src/services/placeService.ts -> getPlaceWithTags). All
|
||||
* columns of the `places` table (see server/data DB) plus the joined `category`
|
||||
* projection and `tags` array. Numbers (lat/lng/price) are SQLite REAL, ids are
|
||||
* INTEGER; provider-derived columns are nullable.
|
||||
*/
|
||||
export const placeSchema = z.object({
|
||||
id: z.number(),
|
||||
trip_id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
lat: z.number().nullable().optional(),
|
||||
lng: z.number().nullable().optional(),
|
||||
address: z.string().nullable().optional(),
|
||||
category_id: z.number().nullable().optional(),
|
||||
price: z.number().nullable().optional(),
|
||||
currency: z.string().nullable().optional(),
|
||||
reservation_status: z.string().nullable().optional(),
|
||||
reservation_notes: z.string().nullable().optional(),
|
||||
reservation_datetime: z.string().nullable().optional(),
|
||||
place_time: z.string().nullable().optional(),
|
||||
end_time: z.string().nullable().optional(),
|
||||
duration_minutes: z.number().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
image_url: z.string().nullable().optional(),
|
||||
google_place_id: z.string().nullable().optional(),
|
||||
osm_id: z.string().nullable().optional(),
|
||||
route_geometry: z.string().nullable().optional(),
|
||||
website: z.string().nullable().optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
transport_mode: z.string().nullable().optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
category: placeCategorySchema.optional(),
|
||||
tags: z.array(tagSchema.partial()).optional(),
|
||||
});
|
||||
export type Place = z.infer<typeof placeSchema>;
|
||||
|
||||
/**
|
||||
* Trimmed place projection embedded inside a day-assignment response
|
||||
* (server/src/services/queryHelpers.ts -> formatAssignmentWithPlace). This is a
|
||||
* SUBSET of the full place: no trip_id / osm_id / route_geometry / created_at /
|
||||
* reservation_* — only the fields the planner needs to render the itinerary card.
|
||||
*/
|
||||
export const assignmentPlaceSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
lat: z.number().nullable().optional(),
|
||||
lng: z.number().nullable().optional(),
|
||||
address: z.string().nullable().optional(),
|
||||
category_id: z.number().nullable().optional(),
|
||||
price: z.number().nullable().optional(),
|
||||
currency: z.string().nullable().optional(),
|
||||
place_time: z.string().nullable().optional(),
|
||||
end_time: z.string().nullable().optional(),
|
||||
duration_minutes: z.number().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
image_url: z.string().nullable().optional(),
|
||||
transport_mode: z.string().nullable().optional(),
|
||||
google_place_id: z.string().nullable().optional(),
|
||||
website: z.string().nullable().optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
category: placeCategorySchema.optional(),
|
||||
tags: z.array(tagSchema.partial()).optional(),
|
||||
});
|
||||
export type AssignmentPlace = z.infer<typeof assignmentPlaceSchema>;
|
||||
|
||||
export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) }));
|
||||
export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>;
|
||||
|
||||
|
||||
@@ -15,6 +15,91 @@ import { z } from 'zod';
|
||||
|
||||
const open = z.record(z.string(), z.unknown());
|
||||
|
||||
/**
|
||||
* A reservation endpoint (flight/train leg terminal) — row of the
|
||||
* reservation_endpoints table (server/src/services/reservationService.ts).
|
||||
*/
|
||||
export const reservationEndpointSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
reservation_id: z.number().optional(),
|
||||
role: z.enum(['from', 'to', 'stop']),
|
||||
sequence: z.number(),
|
||||
name: z.string(),
|
||||
code: z.string().nullable(),
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
timezone: z.string().nullable(),
|
||||
local_time: z.string().nullable(),
|
||||
local_date: z.string().nullable(),
|
||||
});
|
||||
export type ReservationEndpoint = z.infer<typeof reservationEndpointSchema>;
|
||||
|
||||
/**
|
||||
* Reservation entity as returned by the reservation list endpoint
|
||||
* (server/src/services/reservationService.ts -> listReservations). Columns of
|
||||
* the `reservations` table plus the joined day_number / place_name / linked
|
||||
* accommodation fields and the computed `day_positions` + `endpoints`.
|
||||
* `accommodation_id` is stored as TEXT in the DB.
|
||||
*/
|
||||
export const reservationSchema = z.object({
|
||||
id: z.number(),
|
||||
trip_id: z.number(),
|
||||
day_id: z.number().nullable().optional(),
|
||||
end_day_id: z.number().nullable().optional(),
|
||||
place_id: z.number().nullable().optional(),
|
||||
assignment_id: z.number().nullable().optional(),
|
||||
title: z.string(),
|
||||
reservation_time: z.string().nullable().optional(),
|
||||
reservation_end_time: z.string().nullable().optional(),
|
||||
location: z.string().nullable().optional(),
|
||||
confirmation_number: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
status: z.string(),
|
||||
type: z.string(),
|
||||
accommodation_id: z.union([z.number(), z.string()]).nullable().optional(),
|
||||
metadata: z.string().nullable().optional(),
|
||||
needs_review: z.number().optional(),
|
||||
day_plan_position: z.number().nullable().optional(),
|
||||
created_at: z.string().optional(),
|
||||
// joined / computed in listReservations
|
||||
day_number: z.number().nullable().optional(),
|
||||
place_name: z.string().nullable().optional(),
|
||||
accommodation_place_id: z.number().nullable().optional(),
|
||||
accommodation_name: z.string().nullable().optional(),
|
||||
accommodation_start_day_id: z.number().nullable().optional(),
|
||||
accommodation_end_day_id: z.number().nullable().optional(),
|
||||
day_positions: z.record(z.string(), z.number()).nullable().optional(),
|
||||
endpoints: z.array(reservationEndpointSchema).optional(),
|
||||
});
|
||||
export type Reservation = z.infer<typeof reservationSchema>;
|
||||
|
||||
/**
|
||||
* Accommodation entity as returned by listAccommodations / getAccommodationWithPlace
|
||||
* (server/src/services/dayService.ts). Columns of the day_accommodations table
|
||||
* plus the joined place fields and (on list) the linked reservation_title.
|
||||
*/
|
||||
export const accommodationSchema = z.object({
|
||||
id: z.number(),
|
||||
trip_id: z.number(),
|
||||
place_id: z.number().nullable().optional(),
|
||||
start_day_id: z.number(),
|
||||
end_day_id: z.number(),
|
||||
check_in: z.string().nullable().optional(),
|
||||
check_in_end: z.string().nullable().optional(),
|
||||
check_out: z.string().nullable().optional(),
|
||||
confirmation: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
created_at: z.string().optional(),
|
||||
// joined in listAccommodations / getAccommodationWithPlace
|
||||
place_name: z.string().nullable().optional(),
|
||||
place_address: z.string().nullable().optional(),
|
||||
place_image: z.string().nullable().optional(),
|
||||
place_lat: z.number().nullable().optional(),
|
||||
place_lng: z.number().nullable().optional(),
|
||||
reservation_title: z.string().nullable().optional(),
|
||||
});
|
||||
export type Accommodation = z.infer<typeof accommodationSchema>;
|
||||
|
||||
/** Reservation create: title is required; the many optional fields stay open. */
|
||||
export const reservationCreateRequestSchema = open.and(z.object({ title: z.string().min(1) }));
|
||||
export type ReservationCreateRequest = z.infer<typeof reservationCreateRequestSchema>;
|
||||
|
||||
@@ -13,6 +13,51 @@ import { z } from 'zod';
|
||||
* permission checks + audit logging. Trip rows are wide, so responses stay open.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Trip entity as returned by the trip list / get / create / update endpoints
|
||||
* (server/src/services/tripService.ts -> TRIP_SELECT). Columns of the `trips`
|
||||
* table plus the computed list fields (day_count, place_count, is_owner as 0/1,
|
||||
* owner_username, shared_count). `is_archived` is the raw SQLite INTEGER.
|
||||
*/
|
||||
export const tripSchema = z.object({
|
||||
id: z.number(),
|
||||
user_id: z.number(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
start_date: z.string().nullable().optional(),
|
||||
end_date: z.string().nullable().optional(),
|
||||
currency: z.string(),
|
||||
cover_image: z.string().nullable().optional(),
|
||||
is_archived: z.number(),
|
||||
reminder_days: z.number(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
// computed in TRIP_SELECT (list/get)
|
||||
day_count: z.number().optional(),
|
||||
place_count: z.number().optional(),
|
||||
is_owner: z.number().optional(),
|
||||
owner_username: z.string().optional(),
|
||||
shared_count: z.number().optional(),
|
||||
});
|
||||
export type Trip = z.infer<typeof tripSchema>;
|
||||
|
||||
/**
|
||||
* Trip member as returned by the members endpoint
|
||||
* (server/src/services/tripService.ts -> listMembers). Owner + collaborators
|
||||
* share this shape; `avatar_url` is resolved from the stored avatar.
|
||||
*/
|
||||
export const tripMemberSchema = z.object({
|
||||
id: z.number(),
|
||||
username: z.string(),
|
||||
email: z.string().optional(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
avatar_url: z.string().nullable().optional(),
|
||||
role: z.string().optional(),
|
||||
added_at: z.string().nullable().optional(),
|
||||
invited_by_username: z.string().nullable().optional(),
|
||||
});
|
||||
export type TripMember = z.infer<typeof tripMemberSchema>;
|
||||
|
||||
export const tripCreateRequestSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().nullable().optional(),
|
||||
|
||||
Reference in New Issue
Block a user