mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-27 17:21:47 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0db7c491 | |||
| 407bacf66e | |||
| 3372ed4ce8 | |||
| f2f598ada8 | |||
| b76a69dfbd | |||
| eec6e0b53e | |||
| 9bb16ad307 | |||
| 915bb0d0ca | |||
| f3077ce4f0 | |||
| 3149c2960e | |||
| 1ab427000a | |||
| 7ece89ac5c | |||
| 13f342e446 | |||
| 51e8524d5c | |||
| b86bdce490 | |||
| 7f6920241c | |||
| 801bf0539f | |||
| 6f21eba216 | |||
| 50eb88511c | |||
| ca3ffea3ea | |||
| e934fe43f1 | |||
| b175ef4626 | |||
| 9aaf313d59 | |||
| c5fb76da7b | |||
| 628830011d | |||
| c92c6bc07c | |||
| ccf0703f23 | |||
| 7291d9c52f | |||
| 156b8da37e | |||
| cee4b87cc9 | |||
| 223f5ce9bc | |||
| 5fa79bba52 | |||
| 23d5a5bd9c | |||
| a5d05cb92e | |||
| ac03b7ca13 | |||
| 22813f8d81 | |||
| 186625591a | |||
| 49fb2fded2 | |||
| 4cd4c9c8d8 | |||
| 6cc8908f87 | |||
| 68f48bc070 | |||
| 76d8abb44d | |||
| 91c350c946 | |||
| 1e4a9a95c2 | |||
| fe54f45d62 | |||
| b36c9931b3 | |||
| c1fe1d2d6a | |||
| ebbbf91d60 | |||
| 328d1c9468 | |||
| 48ebdff2d5 | |||
| 457a42b229 | |||
| 7df5956920 | |||
| 0d50d5d7c3 | |||
| 4a3aa478c6 | |||
| abee2fc088 | |||
| e40465ba1f | |||
| 8dab26fe7b | |||
| 7459067b2e | |||
| a2c552f04d | |||
| 27762458e6 | |||
| adbe15abc4 | |||
| 982b99f0f6 | |||
| 6a797a39ae | |||
| d2cd317070 | |||
| 6ab6d79494 | |||
| d35972db39 | |||
| 438d4fc400 | |||
| d152f9d02b | |||
| f6af1d67a2 | |||
| ad893eb1cc |
@@ -32,6 +32,7 @@ server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
+7
-15
@@ -46,23 +46,11 @@ COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
# better-sqlite3 native addon requires build tools (purged after compile).
|
||||
# kitinerary-extractor for booking-confirmation import:
|
||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
|
||||
libkitinerary-bin && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then \
|
||||
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
|
||||
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
|
||||
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
|
||||
rm /tmp/ki.tgz; \
|
||||
else \
|
||||
apt-get install -y --no-install-recommends libkitinerary-bin && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
fi && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
@@ -85,6 +73,10 @@ COPY --from=server-builder /app/server/dist ./server/dist
|
||||
COPY --from=server-builder /app/server/assets ./server/assets
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
COPY server/tsconfig.json ./server/
|
||||
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
|
||||
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0"?>
|
||||
<CommunityApplications>
|
||||
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
|
||||
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
|
||||
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
|
||||
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||
<DonateText>Support TREK development</DonateText>
|
||||
</CommunityApplications>
|
||||
+1
-1
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
|
||||
|
||||
## Notes
|
||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||
- PVCs require a default StorageClass or specify one as needed.
|
||||
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.22
|
||||
version: 3.1.2
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.22"
|
||||
appVersion: "3.1.2"
|
||||
|
||||
@@ -5,9 +5,16 @@ metadata:
|
||||
name: {{ include "trek.fullname" . }}-data
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.persistence.data.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .Values.persistence.data.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.data.size }}
|
||||
@@ -18,9 +25,16 @@ metadata:
|
||||
name: {{ include "trek.fullname" . }}-uploads
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.persistence.uploads.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .Values.persistence.uploads.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.uploads.size }}
|
||||
|
||||
@@ -98,8 +98,13 @@ persistence:
|
||||
enabled: true
|
||||
data:
|
||||
size: 1Gi
|
||||
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
|
||||
storageClassName: ""
|
||||
annotations: {}
|
||||
uploads:
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
annotations: {}
|
||||
|
||||
resources:
|
||||
requests:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.0.22",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -20,6 +20,7 @@ import SharedTripPage from './pages/SharedTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
|
||||
import BottomNav from './components/Layout/BottomNav'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
@@ -208,6 +209,7 @@ export default function App() {
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
{!isAuthPage && <BackgroundTasksWidget />}
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type BookingImportPreviewItem,
|
||||
type BookingImportPreviewResponse,
|
||||
type BookingImportConfirmResponse,
|
||||
type BookingImportMode,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
@@ -441,6 +442,41 @@ export const adminApi = {
|
||||
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
// Local LLM (Ollama) management for the AI-parsing addon.
|
||||
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
|
||||
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
|
||||
/** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */
|
||||
llmLocalPull: async (
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void,
|
||||
): Promise<void> => {
|
||||
const res = await fetch('/api/admin/llm/local/pull', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseUrl, model }),
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = `Pull failed (${res.status})`
|
||||
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const dec = new TextDecoder()
|
||||
let buf = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
|
||||
}
|
||||
}
|
||||
},
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
@@ -489,7 +525,7 @@ export const addonsApi = {
|
||||
|
||||
export const airtrailApi = {
|
||||
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
|
||||
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
|
||||
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
||||
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||
@@ -595,6 +631,7 @@ export const budgetApi = {
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
@@ -623,17 +660,31 @@ export const reservationsApi = {
|
||||
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||
importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
|
||||
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('files', f)
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
fd.append('mode', mode)
|
||||
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
|
||||
// global 8s default (a cold local model alone can take ~45s).
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
|
||||
},
|
||||
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
|
||||
// Start a background parse: returns a job id at once; progress + result arrive
|
||||
// over the WebSocket (import:progress / import:done / import:error).
|
||||
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('files', f)
|
||||
fd.append('mode', mode)
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
|
||||
},
|
||||
// Poll a background job — recovery path when a WebSocket push was missed.
|
||||
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
|
||||
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const healthApi = {
|
||||
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
||||
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
|
||||
@@ -298,7 +299,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
</span>
|
||||
</div>
|
||||
{integrationAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'llm_parsing' && addon.enabled && (
|
||||
<LlmParsingConfig addon={addon} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -309,6 +315,225 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
}
|
||||
|
||||
const MASKED = '••••••••'
|
||||
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
|
||||
|
||||
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
|
||||
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
|
||||
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
|
||||
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
|
||||
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
|
||||
]
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
||||
* overrides per-user config (see server llmConfig.ts). The API key is masked on
|
||||
* read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
||||
*/
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const toast = useToast()
|
||||
const cfg = (addon.config ?? {}) as Record<string, unknown>
|
||||
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
|
||||
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
|
||||
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Local-provider model management.
|
||||
const [installed, setInstalled] = useState<string[]>([])
|
||||
const [modelsErr, setModelsErr] = useState('')
|
||||
const [loadingModels, setLoadingModels] = useState(false)
|
||||
const [pulling, setPulling] = useState<string | null>(null)
|
||||
const [pullPct, setPullPct] = useState(0)
|
||||
const [pullStatus, setPullStatus] = useState('')
|
||||
|
||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
|
||||
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
|
||||
|
||||
const loadModels = async () => {
|
||||
if (provider !== 'local') return
|
||||
setLoadingModels(true)
|
||||
setModelsErr('')
|
||||
try {
|
||||
const res = await adminApi.llmLocalModels(effectiveUrl)
|
||||
setInstalled(res.models.map(m => m.name))
|
||||
} catch (e: unknown) {
|
||||
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
|
||||
setInstalled([])
|
||||
} finally {
|
||||
setLoadingModels(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load installed models when the local provider is active.
|
||||
useEffect(() => {
|
||||
if (provider === 'local') loadModels()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provider])
|
||||
|
||||
const pull = async (id: string) => {
|
||||
if (pulling) return
|
||||
setPulling(id)
|
||||
setPullPct(0)
|
||||
setPullStatus('starting…')
|
||||
try {
|
||||
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
||||
if (p.error) throw new Error(p.error)
|
||||
if (p.status) setPullStatus(p.status)
|
||||
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
|
||||
})
|
||||
toast.success('Model pulled')
|
||||
setModel(id)
|
||||
await loadModels()
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Pull failed')
|
||||
} finally {
|
||||
setPulling(null)
|
||||
setPullPct(0)
|
||||
setPullStatus('')
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// Send the masked sentinel unchanged so the server keeps the stored key.
|
||||
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
|
||||
toast.success('Saved')
|
||||
} catch {
|
||||
toast.error('Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
||||
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
|
||||
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<p className="text-xs text-content-faint">
|
||||
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
|
||||
{/* Connection */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Connection</div>
|
||||
<div>
|
||||
<span className={labelCls}>Provider</span>
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</div>
|
||||
{provider !== 'anthropic' && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Base URL</span>
|
||||
<input className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>API key</span>
|
||||
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</label>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Model */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Model</div>
|
||||
<label className="block">
|
||||
<input className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
|
||||
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="text-xs text-content-faint">No models installed yet — pull one below.</p>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-edge-secondary pt-3">
|
||||
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-content">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-content-faint">{m.note}</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1.5">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{installedHere ? (
|
||||
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addon: Addon) => void
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
@@ -20,6 +21,7 @@ type Defaults = {
|
||||
temperature_unit?: string
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
default_currency?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
map_provider?: string
|
||||
@@ -226,6 +228,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Default Currency */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.currency')} <ResetButton field="default_currency" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
|
||||
placeholder={t('settings.currency')}
|
||||
searchable
|
||||
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 240 }}
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { reservationsApi } from '../../api/client'
|
||||
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
|
||||
|
||||
/**
|
||||
* Global, route-independent widget (bottom-right) that tracks background booking
|
||||
* imports. Mounted once at the app root so it survives navigation. It listens to the
|
||||
* user's WebSocket for import:progress / import:done / import:error and reflects each
|
||||
* job; a finished job offers a "review" action that takes the user to the trip, where
|
||||
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
|
||||
*/
|
||||
export default function BackgroundTasksWidget() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const tasks = useBackgroundTasksStore((s) => s.tasks)
|
||||
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
|
||||
const setDone = useBackgroundTasksStore((s) => s.setDone)
|
||||
const setError = useBackgroundTasksStore((s) => s.setError)
|
||||
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
|
||||
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
|
||||
|
||||
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
|
||||
// that was still running when the page reloaded must keep its widget, so re-fetch each
|
||||
// job's real status (and its parsed items) once. A job the server has since dropped
|
||||
// (404, expired) is removed so no stale card lingers.
|
||||
const didRehydrate = useRef(false)
|
||||
useEffect(() => {
|
||||
if (didRehydrate.current) return
|
||||
didRehydrate.current = true
|
||||
const restored = useBackgroundTasksStore.getState().tasks
|
||||
for (const task of restored) {
|
||||
reservationsApi
|
||||
.importJobStatus(task.tripId, task.id)
|
||||
.then((s) => {
|
||||
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
|
||||
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
|
||||
else setProgress(task.id, task.tripId, s.done, s.total)
|
||||
})
|
||||
.catch((err: { response?: { status?: number } }) => {
|
||||
if (err?.response?.status === 404) dismiss(task.id)
|
||||
})
|
||||
}
|
||||
// run once on mount against whatever was rehydrated from storage
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Server pushes import:* to the user on whatever page they're on.
|
||||
useEffect(() => {
|
||||
const handler = (e: Record<string, unknown>) => {
|
||||
const type = typeof e.type === 'string' ? e.type : ''
|
||||
if (!type.startsWith('import:')) return
|
||||
const id = String(e.jobId ?? '')
|
||||
const tripId = String(e.tripId ?? '')
|
||||
if (!id) return
|
||||
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
|
||||
else if (type === 'import:done') {
|
||||
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
|
||||
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
|
||||
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [setProgress, setDone, setError])
|
||||
|
||||
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
|
||||
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
|
||||
// failed one-shot rehydrate self-heals instead of getting stuck on "preview empty").
|
||||
useEffect(() => {
|
||||
const pending = tasks.filter((task) => task.status === 'running' || (task.status === 'done' && task.items === undefined))
|
||||
if (pending.length === 0) return
|
||||
const iv = setInterval(() => {
|
||||
for (const task of pending) {
|
||||
reservationsApi
|
||||
.importJobStatus(task.tripId, task.id)
|
||||
.then((s) => {
|
||||
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
|
||||
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
|
||||
else setProgress(task.id, task.tripId, s.done, s.total)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(iv)
|
||||
}, [tasks, setProgress, setDone, setError])
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
const review = (task: BackgroundImportTask) => {
|
||||
requestReview(task.id)
|
||||
navigate(`/trips/${task.tripId}`)
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 1 }}>
|
||||
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
|
||||
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
|
||||
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.label}
|
||||
</div>
|
||||
|
||||
{task.status === 'running' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
{t('reservations.import.parsing')}
|
||||
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.status === 'done' && (
|
||||
task.items === undefined ? (
|
||||
// Restored from a reload; items are being re-fetched (see the poll backstop).
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
|
||||
) : task.items.length > 0 ? (
|
||||
<button
|
||||
onClick={() => review(task)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{task.status === 'error' && (
|
||||
<div style={{ fontSize: 11, color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.status !== 'running' && (
|
||||
<button
|
||||
onClick={() => dismiss(task.id)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
|
||||
import CostsPanel from './CostsPanel'
|
||||
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
|
||||
})
|
||||
|
||||
describe('CostsPanel — settlements in the ledger', () => {
|
||||
it('renders a settle-up payment as a ledger row with an undo action', async () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [],
|
||||
flows: [],
|
||||
settlements: [
|
||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
// The expense and the settlement (payment) both appear in the unified ledger.
|
||||
await screen.findByText('Dinner')
|
||||
await screen.findByText('Payment')
|
||||
// The payment row exposes an inline undo (no need to open a separate History modal).
|
||||
expect(screen.getByTitle('Undo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('records a manual payment via the Add payment button', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 1, ...posted } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
// The footer submit is the second "Add payment" control once the modal is open.
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
const submit = addButtons[addButtons.length - 1]
|
||||
await user.click(submit)
|
||||
await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
|
||||
})
|
||||
|
||||
it('hides payment rows while a text search is active', async () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [],
|
||||
flows: [],
|
||||
settlements: [
|
||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Payment')
|
||||
await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
|
||||
// Payment rows have no name, so a search hides them while the matching expense stays.
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
|
||||
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
||||
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
||||
expect(nums()[2].value).toBe('50')
|
||||
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
|
||||
await user.clear(nums()[1]); await user.type(nums()[1], '30')
|
||||
await waitFor(() => expect(nums()[2].value).toBe('70'))
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(100)
|
||||
expect(posted!.payers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ user_id: 1, amount: 30 }),
|
||||
expect.objectContaining({ user_id: 2, amount: 70 }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
|
||||
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(39.99)
|
||||
})
|
||||
|
||||
it('marks an expense with no payer as Unfinished', async () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
await screen.findByText('Hotel')
|
||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
|
||||
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
|
||||
|
||||
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
|
||||
// The participant toggles are buttons; the same names also appear as plain text in
|
||||
// the Balances sidebar, so target the buttons specifically.
|
||||
await user.click(screen.getByRole('button', { name: /alice/i }))
|
||||
await user.click(screen.getByRole('button', { name: /bob/i }))
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
const submit = addBtns[addBtns.length - 1] // footer submit
|
||||
expect(submit).not.toBeDisabled()
|
||||
await user.click(submit)
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(120)
|
||||
expect(posted!.member_ids).toEqual([])
|
||||
expect(posted!.payers).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -39,6 +39,12 @@ interface SettlementData {
|
||||
settlements: Settlement[]
|
||||
}
|
||||
|
||||
// One row in the unified Costs ledger — either an expense or a settle-up payment,
|
||||
// carrying the date used to group it by day.
|
||||
type LedgerEntry =
|
||||
| { kind: 'expense'; date: string; e: BudgetItem }
|
||||
| { kind: 'payment'; date: string; s: Settlement }
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
||||
|
||||
@@ -62,9 +68,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [histOpen, setHistOpen] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
|
||||
const [addingPayment, setAddingPayment] = useState(false)
|
||||
|
||||
const people = tripMembers
|
||||
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
||||
@@ -122,21 +129,37 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return list
|
||||
}, [budgetItems, filter, search, me])
|
||||
|
||||
// Settlements ("payments") shown inline in the ledger. They have no name, so a
|
||||
// text search hides them; they're excluded from the "owed" expense filter and,
|
||||
// under "mine", only show transfers I'm part of.
|
||||
const filteredSettlements = useMemo(() => {
|
||||
if (search.trim()) return []
|
||||
if (filter === 'owed') return []
|
||||
let list = settlement?.settlements || []
|
||||
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
|
||||
return list
|
||||
}, [settlement, filter, search, me])
|
||||
|
||||
const dayGroups = useMemo(() => {
|
||||
const groups: { day: string; items: BudgetItem[] }[] = []
|
||||
const labelOf = (e: BudgetItem) => {
|
||||
if (!e.expense_date) return t('costs.noDate')
|
||||
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
|
||||
const entries: LedgerEntry[] = [
|
||||
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
|
||||
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
|
||||
]
|
||||
const labelOf = (date: string) => {
|
||||
if (!date) return t('costs.noDate')
|
||||
try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date }
|
||||
}
|
||||
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
|
||||
for (const e of sorted) {
|
||||
const day = labelOf(e)
|
||||
// Newest day first; within a day, expenses before payments (insertion order).
|
||||
const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))
|
||||
const groups: { day: string; entries: LedgerEntry[] }[] = []
|
||||
for (const en of sorted) {
|
||||
const day = labelOf(en.date)
|
||||
let g = groups.find(x => x.day === day)
|
||||
if (!g) { g = { day, items: [] }; groups.push(g) }
|
||||
g.items.push(e)
|
||||
if (!g) { g = { day, entries: [] }; groups.push(g) }
|
||||
g.entries.push(en)
|
||||
}
|
||||
return groups
|
||||
}, [filtered, locale, t])
|
||||
}, [filtered, filteredSettlements, locale, t])
|
||||
|
||||
// ── settle actions ──────────────────────────────────────────────────────
|
||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||
@@ -280,14 +303,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||
</div>
|
||||
) : dayGroups.map(g => {
|
||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||
return (
|
||||
<div key={g.day} style={{ marginBottom: 22 }}>
|
||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
||||
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
|
||||
{g.entries.map(en => en.kind === 'expense'
|
||||
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -300,11 +325,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
|
||||
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => setAddingPayment(true)}
|
||||
className="text-content-muted bg-surface-secondary border border-edge"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={13} /> {t('costs.addPayment')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<SettleFlows />
|
||||
</div>
|
||||
@@ -330,9 +357,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
|
||||
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
|
||||
</Modal>
|
||||
{(editingSettlement || addingPayment) && (
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
|
||||
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.costs-root {
|
||||
@@ -438,7 +467,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
|
||||
)}
|
||||
</div>
|
||||
<SettleFlows />
|
||||
</div>
|
||||
@@ -458,11 +489,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{dayGroups.length === 0
|
||||
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
||||
: dayGroups.map(g => {
|
||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||
return (
|
||||
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
|
||||
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -490,11 +523,27 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const cur = curOf(e)
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in.
|
||||
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
||||
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
|
||||
<Icon size={21} />
|
||||
{isMobile && isUnfinished && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
|
||||
)}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
|
||||
{isUnfinished && !isMobile && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
|
||||
{t('costs.unfinished')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{payers.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
||||
{payers.map(p => (
|
||||
@@ -514,7 +563,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
||||
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||
</div>
|
||||
@@ -531,6 +580,32 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)
|
||||
}
|
||||
|
||||
// A settle-up payment as a ledger row — visually distinct from an expense, with
|
||||
// inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal.
|
||||
function SettlementRow({ s }: { s: Settlement }) {
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
||||
<span className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
|
||||
{canEdit && (
|
||||
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
||||
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
||||
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
||||
@@ -562,14 +637,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
|
||||
function CategoryBreakdown() {
|
||||
const tot: Record<string, number> = {}
|
||||
let grand = 0
|
||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
|
||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
|
||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
||||
// Bars are scaled relative to the most expensive category (the top row fills the
|
||||
// bar), not to the trip grand total — makes the relative ranking readable.
|
||||
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rows.map(c => {
|
||||
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
|
||||
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
|
||||
return (
|
||||
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
||||
@@ -633,37 +710,75 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
||||
)
|
||||
}
|
||||
|
||||
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
||||
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
|
||||
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
|
||||
// ledger row and from a manual "Add payment" button, so recording "I sent money to
|
||||
// X" works the same whether or not there's an outstanding expense behind it.
|
||||
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
|
||||
const total = settlements.reduce((a, s) => a + s.amount, 0)
|
||||
const toast = useToast()
|
||||
const otherDefault = people.find(p => p.id !== me)?.id ?? me
|
||||
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
|
||||
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
|
||||
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const amt = parseFloat(amount) || 0
|
||||
const valid = amt > 0 && fromId !== toId
|
||||
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
|
||||
try {
|
||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||
else await budgetApi.createSettlement(tripId, data)
|
||||
onSaved()
|
||||
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
|
||||
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
|
||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.from')}</label>
|
||||
<CustomSelect value={fromId} onChange={v => setFromId(String(v))} options={opts} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.to')}</label>
|
||||
<CustomSelect value={toId} onChange={v => setToId(String(v))} options={opts} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.amount')}</label>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
|
||||
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{settlements.map(s => (
|
||||
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)} → ${name(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
|
||||
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Add / edit expense modal ───────────────────────────────────────────────
|
||||
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
|
||||
export interface ExpensePrefill {
|
||||
name?: string
|
||||
category?: string
|
||||
amount?: number
|
||||
reservationId?: number
|
||||
}
|
||||
|
||||
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
|
||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -671,34 +786,99 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
|
||||
const [name, setName] = useState(editing?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
||||
// One participant list: a person is "in" the split and may have paid an amount.
|
||||
// Entering the total auto-distributes it equally across the non-pinned participants;
|
||||
// touching an amount pins it and the rest rebalance so the paid amounts always sum
|
||||
// back to the total. Leaving every amount blank = an unfinished expense (counts
|
||||
// toward the trip total only, never settlements, until who-paid is filled in).
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
||||
if (prefill?.amount != null) return String(prefill.amount)
|
||||
return ''
|
||||
})
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
const [paid, setPaid] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
||||
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
|
||||
return m
|
||||
})
|
||||
const [split, setSplit] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
|
||||
// payer amounts load as pinned so opening an expense never reshuffles them.
|
||||
const [dirty, setDirty] = useState<Set<number>>(() =>
|
||||
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
||||
const each = split.size > 0 ? payersTotal / split.size : 0
|
||||
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
|
||||
const totalNum = parseFloat(total) || 0
|
||||
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
|
||||
const paidEntered = paidSum > 0
|
||||
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
// No participants = a recorded total with nobody to split with (e.g. a booking
|
||||
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
|
||||
// people only adds the who-owes-whom split on top.
|
||||
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
|
||||
|
||||
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
||||
const splitCents = (amount: number, n: number): number[] => {
|
||||
if (n <= 0) return []
|
||||
const cents = Math.max(0, Math.round(amount * 100))
|
||||
const base = Math.floor(cents / n), rem = cents - base * n
|
||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||
}
|
||||
// Recompute the non-pinned participants so every paid amount sums to the total.
|
||||
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
|
||||
const ids = [...parts]
|
||||
const free = ids.filter(id => !dirtySet.has(id))
|
||||
if (free.length === 0) return paidMap
|
||||
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
|
||||
const shares = splitCents(totalVal - pinnedSum, free.length)
|
||||
const next = { ...paidMap }
|
||||
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
|
||||
return next
|
||||
}
|
||||
|
||||
const onTotalChange = (v: string) => {
|
||||
v = v.replace(',', '.')
|
||||
setTotal(v)
|
||||
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
||||
}
|
||||
const onPaidChange = (id: number, v: string) => {
|
||||
v = v.replace(',', '.')
|
||||
const nextDirty = new Set(dirty); nextDirty.add(id)
|
||||
setDirty(nextDirty)
|
||||
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
||||
}
|
||||
const toggleParticipant = (id: number) => {
|
||||
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
|
||||
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
|
||||
else nextParts.add(id)
|
||||
setParticipants(nextParts); setDirty(nextDirty)
|
||||
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
|
||||
const payerList = [...participants]
|
||||
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
|
||||
.filter(p => p.amount > 0)
|
||||
const data = {
|
||||
name: name.trim(), category: cat,
|
||||
// Store the actual currency the amounts were entered in; conversion to the
|
||||
// viewer's display currency happens live (real rates), no manual rate.
|
||||
currency,
|
||||
payers: payerList, member_ids: [...split],
|
||||
payers: payerList, member_ids: [...participants],
|
||||
expense_date: day || null,
|
||||
// Always record the entered total: the server keeps it as-is for an unfinished
|
||||
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
|
||||
total_price: totalNum,
|
||||
// Link a freshly-created expense to its booking (create-from-booking flow).
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
@@ -728,7 +908,9 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
|
||||
onChange={e => onTotalChange(e.target.value)}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
@@ -744,11 +926,11 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && payersTotal > 0 && (
|
||||
{currency !== base && totalNum > 0 && (
|
||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{formatMoney(payersTotal, currency, locale)}</span>
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -773,39 +955,37 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{people.map(p => (
|
||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
|
||||
onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.splitBetween')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
||||
{people.map(p => {
|
||||
const on = split.has(p.id)
|
||||
{people.map((p, idx) => {
|
||||
const on = participants.has(p.id)
|
||||
return (
|
||||
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
|
||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||
{p.id === me ? t('costs.you') : p.username}
|
||||
</button>
|
||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
|
||||
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
</button>
|
||||
{on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
|
||||
onChange={e => onPaidChange(p.id, e.target.value)}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
|
||||
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span className="text-content-faint">
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
</span>
|
||||
{paidEntered
|
||||
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
||||
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,8 +32,32 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
|
||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||
|
||||
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
return COST_CAT_META.other
|
||||
/**
|
||||
* Legacy / English free-text categories (and reservation type labels) mapped to
|
||||
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
||||
* which never matched the lowercase keys and fell through to `other`.
|
||||
*/
|
||||
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
||||
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
||||
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
|
||||
transport: 'transport', transportation: 'transport',
|
||||
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
||||
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
||||
grocery: 'groceries', groceries: 'groceries',
|
||||
activity: 'activities', activities: 'activities',
|
||||
sightseeing: 'sightseeing', sights: 'sightseeing',
|
||||
shop: 'shopping', shopping: 'shopping',
|
||||
fee: 'fees', fees: 'fees',
|
||||
health: 'health', medical: 'health',
|
||||
tip: 'tips', tips: 'tips',
|
||||
other: 'other', misc: 'other',
|
||||
}
|
||||
|
||||
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (!cat) return COST_CAT_META.other
|
||||
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
||||
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
calculateSegments,
|
||||
optimizeRoute,
|
||||
generateGoogleMapsUrl,
|
||||
withHotelBookends,
|
||||
} from './RouteCalculator'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
@@ -241,3 +242,46 @@ describe('generateGoogleMapsUrl', () => {
|
||||
expect(result).toContain('48.86,2.36')
|
||||
})
|
||||
})
|
||||
|
||||
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
|
||||
|
||||
describe('withHotelBookends', () => {
|
||||
const hotel = { lat: 1, lng: 1 }
|
||||
const a = { lat: 2, lng: 2 }
|
||||
const b = { lat: 3, lng: 3 }
|
||||
const evening = { lat: 4, lng: 4 }
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
|
||||
const runs = [[a, b]]
|
||||
expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => {
|
||||
const runs = [[a, b]]
|
||||
expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([
|
||||
[hotel, a],
|
||||
[a, b],
|
||||
[b, evening],
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => {
|
||||
expect(withHotelBookends([], a, a, hotel, evening)).toEqual([
|
||||
[hotel, a],
|
||||
[a, evening],
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => {
|
||||
const runs = [[a, b]]
|
||||
expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => {
|
||||
const runs = [[a, b]]
|
||||
expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([
|
||||
[hotel, a],
|
||||
[a, b],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,6 +67,27 @@ export async function calculateRoute(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepends a hotel→first-waypoint run and appends a last-waypoint→hotel run to the
|
||||
* day's activity runs, so the drawn route starts and ends at the day's accommodation
|
||||
* (matching the sidebar's hotel connectors). A bookend is only added when both its
|
||||
* hotel and the first/last located waypoint exist; passing nulls leaves `runs`
|
||||
* untouched. The shared first/last waypoint is repeated so the polylines join.
|
||||
*/
|
||||
export function withHotelBookends(
|
||||
runs: Waypoint[][],
|
||||
firstWay: Waypoint | undefined,
|
||||
lastWay: Waypoint | undefined,
|
||||
startHotel: Waypoint | null,
|
||||
endHotel: Waypoint | null,
|
||||
): Waypoint[][] {
|
||||
const out: Waypoint[][] = []
|
||||
if (startHotel && firstWay) out.push([startHotel, firstWay])
|
||||
out.push(...runs)
|
||||
if (endHotel && lastWay) out.push([lastWay, endHotel])
|
||||
return out
|
||||
}
|
||||
|
||||
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
||||
const valid = places.filter((p) => p.lat && p.lng)
|
||||
if (valid.length === 0) return null
|
||||
|
||||
@@ -84,6 +84,22 @@ const transportReservation = {
|
||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||
} as any
|
||||
|
||||
const multiLegFlight = {
|
||||
id: 401,
|
||||
title: 'Flight to Tokyo',
|
||||
type: 'flight',
|
||||
day_id: 10,
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
confirmation_number: 'XYZ789',
|
||||
metadata: JSON.stringify({
|
||||
legs: [
|
||||
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1' },
|
||||
{ from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2' },
|
||||
],
|
||||
departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1',
|
||||
}),
|
||||
} as any
|
||||
|
||||
const richArgs = {
|
||||
trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any,
|
||||
days: [dayWithPlaces],
|
||||
@@ -196,6 +212,16 @@ describe('downloadTripPDF', () => {
|
||||
const iframe = getIframe()
|
||||
expect(iframe!.srcdoc).toContain('Flight to Rome')
|
||||
expect(iframe!.srcdoc).toContain('ABC123')
|
||||
// Single-leg flight keeps its full-route subtitle.
|
||||
expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-013b: renders every flight number for a multi-leg flight', async () => {
|
||||
await downloadTripPDF({ ...richArgs, reservations: [multiLegFlight] })
|
||||
const iframe = getIframe()
|
||||
// One subtitle line per leg, each with its own flight number and segment route.
|
||||
expect(iframe!.srcdoc).toContain('Lufthansa · LH1 · FRA → BER')
|
||||
expect(iframe!.srcdoc).toContain('Lufthansa · LH2 · BER → HND')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => {
|
||||
@@ -297,6 +323,28 @@ describe('downloadTripPDF', () => {
|
||||
expect(photoCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
|
||||
let fetchedId: string | null = null
|
||||
server.use(
|
||||
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
|
||||
fetchedId = params.placeId as string
|
||||
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
|
||||
}),
|
||||
)
|
||||
// The assignment projection drops osm_id; the full place in `places` carries it.
|
||||
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
|
||||
const args = {
|
||||
...richArgs,
|
||||
places: [osmPlace],
|
||||
assignments: {
|
||||
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
|
||||
} as any,
|
||||
}
|
||||
await downloadTripPDF(args)
|
||||
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
|
||||
expect(fetchedId).toBe('node/240109189')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
|
||||
const args = {
|
||||
...minimalArgs,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
import { getFlightLegs } from '../../utils/flightLegs'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
@@ -96,21 +97,29 @@ function dayCost(assignments, dayId, locale) {
|
||||
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
|
||||
}
|
||||
|
||||
// Pre-fetch Google Place photos for all assigned places
|
||||
async function fetchPlacePhotos(assignments: AssignmentsMap) {
|
||||
// Pre-fetch place photos for all assigned places.
|
||||
// Assignment places are a server-side projection that drops osm_id, so we recover
|
||||
// the full place from the trip's places pool and key the photo off the same id the
|
||||
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
|
||||
// places fell back to category icons in the PDF even though they show photos in-app.
|
||||
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
|
||||
const photoMap = {} // placeId → photoUrl
|
||||
// The assignment projection drops osm_id, so recover it from the full places pool.
|
||||
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
|
||||
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
||||
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
||||
|
||||
// Assignment places are a server-side projection that omits osm_id, so photo
|
||||
// pre-fetch keys off the google_place_id that the projection does carry.
|
||||
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
|
||||
const toFetch = unique
|
||||
.map(p => ({ p, osm_id: osmById.get(p.id) }))
|
||||
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null)))
|
||||
|
||||
await Promise.allSettled(
|
||||
toFetch.map(async (place) => {
|
||||
toFetch.map(async ({ p, osm_id }) => {
|
||||
// Same key the app UI uses: google_place_id || osm_id || coords.
|
||||
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
|
||||
try {
|
||||
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
|
||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
||||
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name)
|
||||
if (data.photoUrl) photoMap[p.id] = data.photoUrl
|
||||
} catch {}
|
||||
})
|
||||
)
|
||||
@@ -140,8 +149,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
||||
const accommodations = await accommodationsApi.list(trip.id);
|
||||
|
||||
// Pre-fetch place photos from Google
|
||||
const photoMap = await fetchPlacePhotos(assignments)
|
||||
// Pre-fetch place photos (Google, OSM and coords-only places)
|
||||
const photoMap = await fetchPlacePhotos(assignments, places)
|
||||
|
||||
const totalAssigned = new Set(
|
||||
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
|
||||
@@ -215,17 +224,30 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const icon = reservationIconSvg(r.type)
|
||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||
let subtitle = ''
|
||||
// Flights render one subtitle line per leg (see below); everything else is a single line.
|
||||
let subtitleLines: string[] = []
|
||||
if (r.type === 'flight') {
|
||||
// Full route over all waypoints (FRA → BER → HND), falling back to the
|
||||
// flat metadata pair for legacy single-leg flights without endpoints.
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '')
|
||||
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||
const legs = getFlightLegs(r)
|
||||
if (legs.length > 1) {
|
||||
// Multi-leg: one line per leg so every flight number + segment route is shown.
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.airline, l.flight_number,
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
|
||||
.filter(Boolean).join(' · '))
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
// Single-leg: full route over all waypoints (FRA → BER → HND), falling back to the
|
||||
// flat metadata pair for legacy single-leg flights without endpoints.
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '')
|
||||
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
if (subtitleLines.length === 0 && subtitle) subtitleLines = [subtitle]
|
||||
const locationLine = r.location || meta.location || ''
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
@@ -238,7 +260,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
<span class="note-icon">${icon}</span>
|
||||
<div class="note-body">
|
||||
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||
${subtitleLines.filter(Boolean).map(s => `<div class="note-time">${escHtml(s)}</div>`).join('')}
|
||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
@@ -174,7 +174,9 @@ describe('PackingListPanel', () => {
|
||||
|
||||
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' });
|
||||
// Uncategorized item: deleting it is a plain DELETE (a custom category's last
|
||||
// item is instead converted to a placeholder — see FE-COMP-PACKING-070).
|
||||
const item = buildPackingItem({ id: 99, name: 'To Remove', category: null });
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.delete('/api/trips/1/packing/99', () => {
|
||||
@@ -1415,4 +1417,83 @@ describe('PackingListPanel', () => {
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' });
|
||||
// handleDeleteItem decides "last in category" from the rendered list.
|
||||
seedStore(useTripStore, { packingItems: [item] });
|
||||
let deleted = false;
|
||||
let putBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.delete('/api/trips/1/packing/99', () => {
|
||||
deleted = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.put('/api/trips/1/packing/99', async ({ request }) => {
|
||||
putBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
|
||||
})
|
||||
);
|
||||
render(<PackingListPanel tripId={1} items={[item]} />);
|
||||
|
||||
await user.click(screen.getByTitle('Delete'));
|
||||
|
||||
// The row is updated in place (same id) rather than deleted, so colour/position hold.
|
||||
await waitFor(() => expect(putBody).toMatchObject({ name: '...' }));
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
|
||||
seedStore(useTripStore, { packingItems: [placeholder] });
|
||||
let deleted = false;
|
||||
let converted = false;
|
||||
server.use(
|
||||
http.delete('/api/trips/1/packing/5', () => {
|
||||
deleted = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.put('/api/trips/1/packing/5', () => {
|
||||
converted = true;
|
||||
return HttpResponse.json({ item: placeholder });
|
||||
})
|
||||
);
|
||||
render(<PackingListPanel tripId={1} items={[placeholder]} />);
|
||||
|
||||
await user.click(screen.getByTitle('Delete'));
|
||||
|
||||
await waitFor(() => expect(deleted).toBe(true));
|
||||
// It is the placeholder itself — it must be removed, not re-converted.
|
||||
expect(converted).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
|
||||
seedStore(useTripStore, { packingItems: [placeholder] });
|
||||
let posted = false;
|
||||
let putBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/packing', () => {
|
||||
posted = true;
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 6 }) });
|
||||
}),
|
||||
http.put('/api/trips/1/packing/5', async ({ request }) => {
|
||||
putBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
|
||||
})
|
||||
);
|
||||
render(<PackingListPanel tripId={1} items={[placeholder]} />);
|
||||
|
||||
// Open the category's inline "Add item" and add a real entry.
|
||||
await user.click(screen.getByText('Add item'));
|
||||
const input = await screen.findByPlaceholderText('Item name...');
|
||||
await user.type(input, 'Tent');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
|
||||
expect(posted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ interface KategorieGruppeProps {
|
||||
allCategories: string[]
|
||||
onRename: (oldName: string, newName: string) => Promise<void>
|
||||
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
||||
onDeleteItem: (item: PackingItem) => Promise<void>
|
||||
onAddItem: (category: string, name: string) => Promise<void>
|
||||
assignees: CategoryAssignee[]
|
||||
tripMembers: TripMember[]
|
||||
@@ -28,7 +29,7 @@ interface KategorieGruppeProps {
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [editKatName, setEditKatName] = useState(kategorie)
|
||||
@@ -231,7 +232,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
{offen && (
|
||||
<div style={{ padding: '4px 4px 6px' }}>
|
||||
{items.map(item => (
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||
))}
|
||||
{/* Inline add item */}
|
||||
{canEdit && (showAddItem ? (
|
||||
|
||||
@@ -15,13 +15,14 @@ interface ArtikelZeileProps {
|
||||
tripId: number
|
||||
categories: string[]
|
||||
onCategoryChange: () => void
|
||||
onDelete?: (item: PackingItem) => Promise<void>
|
||||
bagTrackingEnabled?: boolean
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||
@@ -43,6 +44,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTr
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
// The panel routes deletion through onDelete so an emptied custom category
|
||||
// keeps its placeholder; fall back to a plain delete when used standalone.
|
||||
if (onDelete) { await onDelete(item); return }
|
||||
try { await deletePackingItem(tripId, item.id) }
|
||||
catch { toast.error(t('packing.toast.deleteError')) }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
||||
|
||||
export function PackingList(S: PackingState) {
|
||||
const {
|
||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory,
|
||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
|
||||
} = S
|
||||
@@ -31,6 +31,7 @@ export function PackingList(S: PackingState) {
|
||||
allCategories={allCategories}
|
||||
onRename={handleRenameCategory}
|
||||
onDeleteAll={handleDeleteCategory}
|
||||
onDeleteItem={handleDeleteItem}
|
||||
onAddItem={handleAddItemToCategory}
|
||||
assignees={categoryAssignees[kat] || []}
|
||||
tripMembers={tripMembers}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { packingApi, tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { BAG_COLORS } from './packingListPanel.constants'
|
||||
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
||||
import { parseImportLines } from './packingListPanel.helpers'
|
||||
|
||||
export interface TripMember {
|
||||
@@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('packing_edit', trip)
|
||||
@@ -106,10 +106,45 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
|
||||
const handleAddItemToCategory = async (category: string, name: string) => {
|
||||
try {
|
||||
await addPackingItem(tripId, { name, category })
|
||||
// Reuse the '...' placeholder slot when the category already has one, so a
|
||||
// freshly-emptied category keeps its position (and therefore its colour)
|
||||
// instead of the new item being appended to the end of the list.
|
||||
const placeholder = useTripStore.getState().packingItems.find(
|
||||
i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME
|
||||
)
|
||||
if (placeholder) {
|
||||
await updatePackingItem(tripId, placeholder.id, { name })
|
||||
} else {
|
||||
await addPackingItem(tripId, { name, category })
|
||||
}
|
||||
} catch { toast.error(t('packing.toast.addError')) }
|
||||
}
|
||||
|
||||
// Deleting an item from a row. When it is the last item of a user-created
|
||||
// category, turn that row back into the '...' placeholder in place rather than
|
||||
// deleting it (#1289). Updating the row keeps its id, list position and colour,
|
||||
// so the category neither disappears nor jumps to the end. The default
|
||||
// (uncategorized) group and the placeholder row itself are deleted normally —
|
||||
// removing the placeholder is how an empty category is dismissed.
|
||||
const handleDeleteItem = async (item: PackingItem) => {
|
||||
const category = item.category
|
||||
const isLastInCategory = !!category
|
||||
&& item.name !== PACKING_PLACEHOLDER_NAME
|
||||
&& !items.some(i => i.id !== item.id && i.category === category)
|
||||
try {
|
||||
if (isLastInCategory) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||
await updatePackingItem(tripId, item.id, {
|
||||
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
|
||||
})
|
||||
} else {
|
||||
await deletePackingItem(tripId, item.id)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('packing.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddNewCategory = async () => {
|
||||
if (!newCatName.trim()) return
|
||||
let catName = newCatName.trim()
|
||||
@@ -308,7 +343,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
import { catMeta } from '../Budget/costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
|
||||
/**
|
||||
* The Costs block inside a booking modal. Replaces the old inline price + budget
|
||||
* category fields: when no expense is linked yet it offers a "create expense"
|
||||
* button (the modal saves the booking first, then opens the full Costs editor);
|
||||
* once linked it shows the expense with edit / remove actions.
|
||||
*/
|
||||
export function BookingCostsSection({ reservationId, pendingExpense, onCreate, onEdit, onRemove }: {
|
||||
reservationId: number | null
|
||||
/** A cost parsed from an import that will be linked on save — previewed before the booking exists. */
|
||||
pendingExpense?: { total_price: number; currency?: string | null; category: string } | null
|
||||
onCreate: () => void
|
||||
onEdit: (item: BudgetItem) => void
|
||||
onRemove: (item: BudgetItem) => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
|
||||
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
|
||||
// Import review (booking not saved yet): preview the parsed cost that will be linked on save.
|
||||
if (!linked && pendingExpense && pendingExpense.total_price > 0) {
|
||||
const meta = catMeta(pendingExpense.category)
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
||||
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{t(meta.labelKey)}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 12 }}>{t('reservations.createExpenseHint')}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (linked) {
|
||||
const meta = catMeta(linked.category)
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
||||
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 12 }}>{t(meta.labelKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
|
||||
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.costsLabel')}</label>
|
||||
<button type="button" onClick={onCreate}
|
||||
className="bg-surface-secondary border border-edge text-content"
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 13.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={15} /> {t('reservations.createExpense')}
|
||||
</button>
|
||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { BudgetItem } from '../../types'
|
||||
|
||||
/**
|
||||
* A request from a booking modal to open the Costs expense editor — either to
|
||||
* edit the already-linked expense, or to create a new one prefilled from the
|
||||
* booking (the modal saves the booking first so `reservationId` is known).
|
||||
*/
|
||||
export interface BookingExpenseRequest {
|
||||
editItem?: BudgetItem
|
||||
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
|
||||
}
|
||||
@@ -1,81 +1,44 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Upload, Plane, Train, Hotel, UtensilsCrossed, Car, Anchor, Calendar, ArrowLeft, X } from 'lucide-react'
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
import { Upload, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { reservationsApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { reservationsApi, healthApi } from '../../api/client'
|
||||
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
||||
import { saveImportFiles } from '../../db/offlineDb'
|
||||
|
||||
interface BookingImportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
tripId: number
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
|
||||
const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_FILES = 5
|
||||
|
||||
const TYPE_ICONS: Record<string, React.FC<{ size: number; color?: string }>> = {
|
||||
flight: Plane,
|
||||
train: Train,
|
||||
hotel: Hotel,
|
||||
restaurant: UtensilsCrossed,
|
||||
car: Car,
|
||||
cruise: Anchor,
|
||||
event: Calendar,
|
||||
}
|
||||
|
||||
function typeColor(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
flight: '#3b82f6',
|
||||
train: '#10b981',
|
||||
hotel: '#8b5cf6',
|
||||
restaurant: '#f59e0b',
|
||||
car: '#6b7280',
|
||||
cruise: '#06b6d4',
|
||||
event: '#ec4899',
|
||||
}
|
||||
return map[type] ?? 'var(--text-faint)'
|
||||
}
|
||||
|
||||
function formatDateTime(iso: unknown): string {
|
||||
if (!iso) return ''
|
||||
const str = typeof iso === 'string' ? iso : typeof iso === 'object' ? JSON.stringify(iso) : String(iso)
|
||||
const date = str.slice(0, 10)
|
||||
const time = str.length > 10 ? str.slice(11, 16) : ''
|
||||
return [date, time].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }: BookingImportModalProps) {
|
||||
/**
|
||||
* Upload booking files and kick off a BACKGROUND parse. The modal closes at once;
|
||||
* the parse runs server-side and is tracked by the global BackgroundTasksWidget
|
||||
* (progress over the WebSocket). When it finishes, the trip page opens the per-item
|
||||
* review flow — so the user can navigate and keep editing while it works.
|
||||
*/
|
||||
export default function BookingImportModal({ isOpen, onClose, tripId }: BookingImportModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const addTask = useBackgroundTasksStore((s) => s.addTask)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
type Phase = 'upload' | 'preview' | 'confirming'
|
||||
const [phase, setPhase] = useState<Phase>('upload')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [previewItems, setPreviewItems] = useState<BookingImportPreviewItem[]>([])
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
const [excluded, setExcluded] = useState<Set<number>>(() => new Set())
|
||||
const [aiParsing, setAiParsing] = useState(false)
|
||||
|
||||
const reset = () => {
|
||||
setPhase('upload')
|
||||
setFiles([])
|
||||
setIsDragOver(false)
|
||||
setLoading(false)
|
||||
setError('')
|
||||
setPreviewItems([])
|
||||
setWarnings([])
|
||||
setExcluded(new Set())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,6 +47,11 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
|
||||
}, [isOpen])
|
||||
|
||||
const handleClose = () => { reset(); onClose() }
|
||||
|
||||
const validateFile = (f: File): string | null => {
|
||||
@@ -121,88 +89,44 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
|
||||
if (list.length) selectFiles(list)
|
||||
}
|
||||
|
||||
// Start the parse in the background and close — the widget takes it from here.
|
||||
const handleParse = async () => {
|
||||
if (files.length === 0 || loading) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await reservationsApi.importBookingPreview(tripId, files)
|
||||
setPreviewItems(result.items ?? [])
|
||||
setWarnings(result.warnings ?? [])
|
||||
setExcluded(new Set())
|
||||
setPhase('preview')
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error ?? t('reservations.import.error')
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const toImport = previewItems.filter((_, i) => !excluded.has(i))
|
||||
if (toImport.length === 0) return
|
||||
setPhase('confirming')
|
||||
setError('')
|
||||
try {
|
||||
const result = await reservationsApi.importBookingConfirm(tripId, toImport)
|
||||
const created = result.created ?? []
|
||||
await loadTrip(tripId)
|
||||
|
||||
if (created.length > 0) {
|
||||
pushUndo?.(t('undo.importBooking'), async () => {
|
||||
try {
|
||||
const { reservationsApi: rApi } = await import('../../api/client')
|
||||
await Promise.all(created.map((r) => rApi.delete(tripId, r.id).catch(() => {})))
|
||||
} catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
toast.success(t('reservations.import.success', { count: created.length }))
|
||||
} else {
|
||||
toast.warning(t('reservations.import.previewEmpty'))
|
||||
}
|
||||
|
||||
const mode = aiParsing ? 'fallback-on-empty' : 'no-ai'
|
||||
const { jobId } = await reservationsApi.importBookingAsync(tripId, files, mode)
|
||||
// Keep the uploaded files so the review can attach each source document to its booking —
|
||||
// in memory for the immediate path, and in IndexedDB so it survives a reload mid-parse.
|
||||
await saveImportFiles(jobId, files)
|
||||
addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length, files })
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error ?? t('reservations.import.error'))
|
||||
setPhase('preview')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExclude = (idx: number) => {
|
||||
setExcluded(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(idx)) next.delete(idx); else next.add(idx)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const activeCount = previewItems.filter((_, i) => !excluded.has(i)).length
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className="bg-[rgba(0,0,0,0.4)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
onMouseDown={(e) => { mouseDownTarget.current = e.target }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
||||
mouseDownTarget.current = null
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
{phase === 'preview' && (
|
||||
<button onClick={() => setPhase('upload')} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('reservations.import.title')}
|
||||
</div>
|
||||
@@ -212,131 +136,45 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{/* Upload phase */}
|
||||
{phase === 'upload' && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('reservations.import.acceptedFormats')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('reservations.import.acceptedFormats')}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTS.join(',')}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTS.join(',')}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
|
||||
style={{
|
||||
width: '100%', minHeight: 100, borderRadius: 12,
|
||||
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
|
||||
marginBottom: 12, padding: 16, boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||
{isDragOver ? (
|
||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
|
||||
) : files.length > 0 ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
|
||||
style={{
|
||||
width: '100%', minHeight: 100, borderRadius: 12,
|
||||
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
|
||||
marginBottom: 12, padding: 16, boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||
{isDragOver ? (
|
||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
|
||||
) : files.length > 0 ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map((f) => f.name).join(', ')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview phase */}
|
||||
{(phase === 'preview' || phase === 'confirming') && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 10 }}>
|
||||
{t('reservations.import.previewHeading', { count: previewItems.length })}
|
||||
</div>
|
||||
|
||||
{previewItems.length === 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||
{t('reservations.import.previewEmpty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewItems.map((item, idx) => {
|
||||
const Icon = TYPE_ICONS[item.type] ?? Calendar
|
||||
const isExcluded = excluded.has(idx)
|
||||
const fromEp = item.endpoints?.find(e => e.role === 'from')
|
||||
const toEp = item.endpoints?.find(e => e.role === 'to')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.source.fileName}-${idx}`}
|
||||
className={isExcluded ? 'bg-surface-tertiary' : 'bg-surface-secondary'}
|
||||
style={{
|
||||
borderRadius: 10, padding: '10px 12px', marginBottom: 8,
|
||||
border: `1px solid ${isExcluded ? 'var(--border-faint)' : 'var(--border-primary)'}`,
|
||||
opacity: isExcluded ? 0.5 : 1, transition: 'opacity 0.15s',
|
||||
display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<Icon size={15} color={typeColor(item.type)} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.title}
|
||||
</div>
|
||||
{fromEp && toEp && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
|
||||
{fromEp.code ?? fromEp.name} → {toEp.code ?? toEp.name}
|
||||
</div>
|
||||
)}
|
||||
{item.reservation_time && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{formatDateTime(item.reservation_time)}
|
||||
{item.reservation_end_time && ` – ${formatDateTime(item.reservation_end_time)}`}
|
||||
</div>
|
||||
)}
|
||||
{item._accommodation?.check_in && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{formatDateTime(item._accommodation.check_in)} – {formatDateTime(item._accommodation.check_out)}
|
||||
</div>
|
||||
)}
|
||||
{item.confirmation_number && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'monospace' }}>
|
||||
{item.confirmation_number}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleExclude(idx)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, flexShrink: 0, fontSize: 11, fontFamily: 'inherit', fontWeight: 500 }}
|
||||
title={t('reservations.import.removeItem')}
|
||||
>
|
||||
{isExcluded ? '+' : <X size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="bg-[rgba(245,158,11,0.08)] text-[#92400e]" style={{ border: '1px solid rgba(245,158,11,0.3)', borderRadius: 10, padding: '8px 10px', fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
|
||||
{warnings.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
{error}
|
||||
@@ -352,28 +190,14 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
|
||||
{phase === 'upload' && (
|
||||
<button
|
||||
onClick={handleParse}
|
||||
disabled={files.length === 0 || loading}
|
||||
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{loading ? t('reservations.import.parsing') : t('common.import')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(phase === 'preview' || phase === 'confirming') && (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={activeCount === 0 || phase === 'confirming'}
|
||||
className={activeCount > 0 && phase !== 'confirming' ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: activeCount > 0 && phase !== 'confirming' ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{phase === 'confirming' ? t('common.loading') : t('reservations.import.confirm', { count: activeCount })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleParse}
|
||||
disabled={files.length === 0 || loading}
|
||||
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{loading ? t('reservations.import.parsing') : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
@@ -2168,6 +2168,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<RouteIcon size={12} strokeWidth={2} />
|
||||
{t('dayplan.route')}
|
||||
</button>
|
||||
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openGoogleMaps')}
|
||||
title={t('planner.openGoogleMaps')}
|
||||
className="bg-transparent text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
|
||||
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
|
||||
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
|
||||
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
|
||||
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||
|
||||
@@ -399,17 +399,38 @@ describe('PlaceFormModal', () => {
|
||||
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-026: time section IS shown in edit mode', () => {
|
||||
it('FE-PLANNER-PLACEFORM-026: time section is hidden in edit mode when no assignment is in context', () => {
|
||||
// Times are per day-assignment; editing a pool place with no day in context
|
||||
// (assignmentId null) hides the fields, which otherwise would not persist (#1247)
|
||||
const place = buildPlace({ name: 'Test' });
|
||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
||||
// Time pickers are rendered when editing
|
||||
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-026b: time section IS shown when an assignment is in context', () => {
|
||||
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '10:00' });
|
||||
const assignment = buildAssignment({ id: 10, day_id: 5, place });
|
||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
|
||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => {
|
||||
// The pool Place carries no times — they live on the day-assignment. Opening the
|
||||
// editor with an assignmentId must hydrate the fields from assignment.place, not
|
||||
// the (timeless) pool place that the Places panel passes in.
|
||||
const poolPlace = buildPlace({ id: 7, name: 'Museum' });
|
||||
const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' });
|
||||
const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace });
|
||||
render(<PlaceFormModal {...defaultProps} place={poolPlace} assignmentId={42} dayAssignments={[assignment]} />);
|
||||
expect(screen.getByDisplayValue('20:20')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
||||
// Build a place with end_time before place_time
|
||||
// Build an assignment whose place has end_time before place_time
|
||||
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
||||
const assignment = buildAssignment({ id: 11, day_id: 5, place });
|
||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={11} dayAssignments={[assignment]} />);
|
||||
|
||||
// hasTimeError = true → submit button disabled
|
||||
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
||||
|
||||
@@ -92,6 +92,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (place) {
|
||||
// Times are stored per day-assignment, not on the pool place. When an
|
||||
// assignment is in context (itinerary edit, or a single-assignment pool
|
||||
// edit) read the times off its embedded place; fall back to the place prop.
|
||||
const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
|
||||
const timeSource = assignment?.place ?? place
|
||||
setForm({
|
||||
name: place.name || '',
|
||||
description: place.description || '',
|
||||
@@ -99,8 +104,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
lat: place.lat != null ? String(place.lat) : '',
|
||||
lng: place.lng != null ? String(place.lng) : '',
|
||||
category_id: place.category_id != null ? String(place.category_id) : '',
|
||||
place_time: place.place_time || '',
|
||||
end_time: place.end_time || '',
|
||||
place_time: timeSource.place_time || '',
|
||||
end_time: timeSource.end_time || '',
|
||||
notes: place.notes || '',
|
||||
transport_mode: place.transport_mode || 'walking',
|
||||
website: place.website || '',
|
||||
@@ -121,7 +126,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
setPendingFiles([])
|
||||
setDuplicateWarning(null)
|
||||
}, [place, prefillCoords, isOpen])
|
||||
// dayAssignments is a fresh array each render; read it at open-time only and
|
||||
// re-run on identity changes (place/assignmentId/open), not on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place, prefillCoords, isOpen, assignmentId])
|
||||
|
||||
// Derive location bias bounding box from the trip's existing places
|
||||
const places = useTripStore((s) => s.places)
|
||||
@@ -728,8 +736,11 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time — only shown when editing, not when creating */}
|
||||
{place && (
|
||||
{/* Time is per day-assignment: only shown when a single assignment is in
|
||||
context (itinerary edit, or a single-assignment pool edit). Hidden when
|
||||
creating, and for unassigned / multi-day pool edits where a single time
|
||||
is ambiguous and wouldn't persist. */}
|
||||
{place && assignmentId && (
|
||||
<TimeSection
|
||||
form={form}
|
||||
handleChange={handleChange}
|
||||
|
||||
@@ -343,56 +343,51 @@ describe('ReservationModal', () => {
|
||||
|
||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-024: budget section visible when budget addon is enabled', () => {
|
||||
it('FE-PLANNER-RESMODAL-024: costs section (create expense) visible when budget addon is enabled', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-025: budget price input accepts valid decimal', async () => {
|
||||
it('FE-PLANNER-RESMODAL-025: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const priceInput = screen.getByPlaceholderText('0.00');
|
||||
await userEvent.type(priceInput, '99.99');
|
||||
expect((priceInput as HTMLInputElement).value).toBe('99.99');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-026: budget hint shown when price > 0', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const priceInput = screen.getByPlaceholderText('0.00');
|
||||
await userEvent.type(priceInput, '50');
|
||||
expect(screen.getByText(/budget entry will be created/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-027: budget fields included in onSave when price is set', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 55 });
|
||||
const onOpenExpense = vi.fn();
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
|
||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '120');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) })
|
||||
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||
await waitFor(() =>
|
||||
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 55 }) })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-026: linked expense summary shown for a booking with a linked cost', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [
|
||||
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: 'EUR', category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
|
||||
],
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />);
|
||||
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── File upload ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
|
||||
@@ -599,22 +594,6 @@ describe('ReservationModal', () => {
|
||||
expect(filePickerItem).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-044: budget category dropdown options include existing categories', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [
|
||||
{ id: 1, trip_id: 1, name: 'Flight ticket', total_price: 300, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
||||
],
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
// Budget section is visible
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
||||
@@ -632,31 +611,6 @@ describe('ReservationModal', () => {
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [
|
||||
{ id: 1, trip_id: 1, name: 'Ticket', total_price: 100, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
||||
],
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
|
||||
// Open the budget category CustomSelect (shows placeholder "Auto (from booking type)")
|
||||
const budgetCategoryBtn = screen.getByText(/Auto \(from booking type\)/i).closest('button')!;
|
||||
await userEvent.click(budgetCategoryBtn);
|
||||
|
||||
// Click the "Transport" category option
|
||||
await waitFor(() => expect(screen.getByText('Transport')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('Transport'));
|
||||
|
||||
// The select should now show "Transport"
|
||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
||||
|
||||
@@ -11,7 +11,12 @@ import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||
import { resolveDayId } from '../../utils/formatters'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||
@@ -60,9 +65,13 @@ interface ReservationModalProps {
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
accommodations?: Accommodation[]
|
||||
defaultAssignmentId?: number | null
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
// Pre-fill a brand-new booking from a parsed import item (review-before-save).
|
||||
// Distinct from `reservation`: the form is populated but stays in create mode.
|
||||
prefill?: BookingReviewDraft | null
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense, prefill = null }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const toast = useToast()
|
||||
@@ -70,24 +79,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const budgetCategories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [budgetItems])
|
||||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||
// Set right before submit when the user clicked create/edit expense (see TransportModal).
|
||||
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
price: '', budget_category: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||
hotel_address: '',
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
|
||||
@@ -97,6 +103,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Match an existing place by name (exact, then loose contains) for hotels.
|
||||
const matchPlaceId = (name: string | undefined): string | number => {
|
||||
const n = (name || '').trim().toLowerCase()
|
||||
if (!n) return ''
|
||||
const exact = places.find(p => p.name?.trim().toLowerCase() === n)
|
||||
if (exact) return exact.id
|
||||
const loose = places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
|
||||
return loose?.id ?? ''
|
||||
}
|
||||
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||
const rawEnd = reservation.reservation_end_time || ''
|
||||
@@ -109,6 +125,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
endDate = rawEnd
|
||||
endTime = ''
|
||||
}
|
||||
const editAcc = accommodations.find(a => a.id == reservation.accommodation_id)
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
type: reservation.type || 'other',
|
||||
@@ -124,24 +141,53 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
meta_check_in_end_time: meta.check_in_end_time || '',
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||
price: meta.price || '',
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
hotel_place_id: editAcc?.place_id || '',
|
||||
hotel_start_day: editAcc?.start_day_id || '',
|
||||
hotel_end_day: editAcc?.end_day_id || '',
|
||||
hotel_address: places.find(p => p.id == editAcc?.place_id)?.address || '',
|
||||
})
|
||||
} else if (prefill) {
|
||||
// Review-before-save: populate from a parsed import item, stay in create mode.
|
||||
const meta = (prefill.metadata && typeof prefill.metadata === 'object' ? prefill.metadata : {}) as Record<string, string>
|
||||
const rawEnd = typeof prefill.reservation_end_time === 'string' ? prefill.reservation_end_time : ''
|
||||
let endDate = ''
|
||||
let endTime = rawEnd
|
||||
if (rawEnd.includes('T')) { endDate = rawEnd.split('T')[0]; endTime = rawEnd.split('T')[1]?.slice(0, 5) || '' }
|
||||
else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) { endDate = rawEnd; endTime = '' }
|
||||
setForm({
|
||||
title: prefill.title || '',
|
||||
type: prefill.type || 'other',
|
||||
status: prefill.status || 'pending',
|
||||
reservation_time: typeof prefill.reservation_time === 'string' ? prefill.reservation_time.slice(0, 16) : '',
|
||||
reservation_end_time: endTime,
|
||||
end_date: endDate,
|
||||
location: prefill.location || '',
|
||||
confirmation_number: prefill.confirmation_number || '',
|
||||
notes: prefill.notes || '',
|
||||
assignment_id: defaultAssignmentId ?? '',
|
||||
accommodation_id: '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
meta_check_in_end_time: meta.check_in_end_time || '',
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
|
||||
hotel_start_day: resolveDayId(days, prefill._accommodation?.check_in),
|
||||
hotel_end_day: resolveDayId(days, prefill._accommodation?.check_out),
|
||||
hotel_address: prefill._venue?.address || '',
|
||||
})
|
||||
// Seed the booking's Files with the document this item was parsed from.
|
||||
setPendingFiles(prefill._sourceFiles ?? [])
|
||||
} else {
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
|
||||
})
|
||||
setPendingFiles([])
|
||||
}
|
||||
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations])
|
||||
|
||||
// Re-hydrate hotel day range when the accommodations prop arrives after the modal opens
|
||||
// (race: tripAccommodations fetch may complete after isOpen fires, leaving hotel fields empty)
|
||||
@@ -167,8 +213,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
return endFull <= startFull
|
||||
})()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
||||
e?.preventDefault?.()
|
||||
if (!form.title.trim()) return
|
||||
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
||||
setIsSaving(true)
|
||||
@@ -185,11 +231,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
} else if (form.reservation_end_time && form.reservation_time) {
|
||||
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
|
||||
const saveData: Record<string, any> & { title: string } = {
|
||||
title: form.title, type: form.type, status: form.status,
|
||||
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
||||
@@ -202,22 +243,33 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
endpoints: [],
|
||||
needs_review: false,
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
||||
if (form.type === 'hotel' && (form.hotel_start_day || form.hotel_end_day)) {
|
||||
saveData.create_accommodation = {
|
||||
place_id: form.hotel_place_id || null,
|
||||
start_day_id: form.hotel_start_day,
|
||||
end_day_id: form.hotel_end_day,
|
||||
// No existing place picked but we have an address/name (e.g. a reviewed
|
||||
// import) → the save handler geocodes it and creates the place.
|
||||
venue: (!form.hotel_place_id && (form.hotel_address || form.title))
|
||||
? { name: form.title, address: form.hotel_address || null }
|
||||
: null,
|
||||
// Tolerate a single resolved end of the range (a one-night stay or a date
|
||||
// that only matched one trip day) so the accommodation is still created.
|
||||
start_day_id: form.hotel_start_day || form.hotel_end_day,
|
||||
end_day_id: form.hotel_end_day || form.hotel_start_day,
|
||||
check_in: form.meta_check_in_time || null,
|
||||
check_in_end: form.meta_check_in_end_time || null,
|
||||
check_out: form.meta_check_out_time || null,
|
||||
confirmation: form.confirmation_number || null,
|
||||
}
|
||||
}
|
||||
// Imported booking → auto-create the linked cost from the parsed price (what the
|
||||
// old direct import did). Only on create (not edit) and only when there's a price.
|
||||
if (!reservation && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
}
|
||||
const saved = await onSave(saveData)
|
||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
||||
for (const file of pendingFiles) {
|
||||
@@ -228,11 +280,32 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
}
|
||||
// Open the Costs editor for the saved booking when the user asked to
|
||||
// create/edit its linked expense (gated on saved?.id).
|
||||
const intent = expenseIntentRef.current
|
||||
expenseIntentRef.current = null
|
||||
if (intent && onOpenExpense && saved?.id) {
|
||||
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
||||
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
||||
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
|
||||
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
|
||||
const prefillPrice = Number(prefillMeta?.price)
|
||||
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
|
||||
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
|
||||
: null
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
@@ -496,6 +569,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.locationAddress')}</label>
|
||||
<input type="text" value={form.hotel_address} onChange={e => set('hotel_address', e.target.value)}
|
||||
placeholder={t('reservations.locationPlaceholder')} className={inputClass} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.checkIn')}</label>
|
||||
@@ -610,38 +688,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category */}
|
||||
{/* Costs — create / view the expense linked to this booking */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
placeholder="0.00"
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
||||
<CustomSelect
|
||||
value={form.budget_category}
|
||||
onChange={v => set('budget_category', v)}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||
]}
|
||||
placeholder={t('reservations.budgetCategoryAuto')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.price && parseFloat(form.price) > 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
||||
{t('reservations.budgetHint')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<BookingCostsSection
|
||||
reservationId={reservation?.id ?? null}
|
||||
pendingExpense={pendingExpense}
|
||||
onCreate={handleCreateExpense}
|
||||
onEdit={handleEditExpense}
|
||||
onRemove={handleRemoveExpense}
|
||||
/>
|
||||
)}
|
||||
|
||||
</form>
|
||||
|
||||
@@ -312,7 +312,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
if (!hasEndpoints && meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') })
|
||||
if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${meta.price}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (cells.length === 0) return null
|
||||
|
||||
@@ -132,34 +132,37 @@ describe('TransportModal', () => {
|
||||
|
||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-011: costs section (create expense) visible when budget addon is enabled', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
|
||||
it('FE-PLANNER-TRANSMODAL-013: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 42 });
|
||||
const onOpenExpense = vi.fn();
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
|
||||
// The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
|
||||
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||
await waitFor(() =>
|
||||
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
@@ -10,11 +10,15 @@ import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
|
||||
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
@@ -105,8 +109,6 @@ const defaultForm = {
|
||||
arrival_time: '',
|
||||
confirmation_number: '',
|
||||
notes: '',
|
||||
price: '',
|
||||
budget_category: '',
|
||||
meta_airline: '',
|
||||
meta_flight_number: '',
|
||||
meta_train_number: '',
|
||||
@@ -124,20 +126,23 @@ interface TransportModalProps {
|
||||
files?: TripFile[]
|
||||
onFileUpload?: (fd: FormData) => Promise<unknown>
|
||||
onFileDelete?: (fileId: number) => Promise<void>
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
// Pre-fill a brand-new transport booking from a parsed import item (review-
|
||||
// before-save); like `reservation` for the form but stays in create mode.
|
||||
prefill?: BookingReviewDraft | null
|
||||
}
|
||||
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const budgetCategories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [budgetItems])
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
// Set right before submitting when the user clicked "create/edit expense", so
|
||||
// the post-save handler knows to open the Costs editor for the saved booking.
|
||||
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||
const [form, setForm] = useState({ ...defaultForm })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
@@ -152,36 +157,42 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string'
|
||||
? JSON.parse(reservation.metadata || '{}')
|
||||
: (reservation.metadata || {})
|
||||
const eps = reservation.endpoints || []
|
||||
// Edit uses the saved `reservation`; a review-import populates from `prefill`.
|
||||
// Either way the init reads the same fields — `reservation` still decides
|
||||
// edit-vs-create at submit time.
|
||||
const src = (reservation ?? prefill) as Reservation | null
|
||||
// On a review-import, seed the booking's Files with the parsed source document.
|
||||
setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : [])
|
||||
if (src) {
|
||||
const meta = typeof src.metadata === 'string'
|
||||
? JSON.parse(src.metadata || '{}')
|
||||
: (src.metadata || {})
|
||||
const eps = src.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
const type = (TRANSPORT_TYPES as readonly string[]).includes(reservation.type)
|
||||
? reservation.type as TransportType
|
||||
const type = (TRANSPORT_TYPES as readonly string[]).includes(src.type)
|
||||
? src.type as TransportType
|
||||
: 'flight'
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
title: src.title || '',
|
||||
type,
|
||||
status: reservation.status === 'confirmed' ? 'confirmed' : 'pending',
|
||||
start_day_id: reservation.day_id ?? '',
|
||||
end_day_id: reservation.end_day_id ?? '',
|
||||
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
|
||||
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
status: src.status === 'confirmed' ? 'confirmed' : 'pending',
|
||||
// For an edit, keep the saved day; for an imported prefill (no day_id), resolve it
|
||||
// from the parsed pick-up/return date so the date isn't lost on save.
|
||||
start_day_id: src.day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_time).date),
|
||||
end_day_id: src.end_day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_end_time).date),
|
||||
departure_time: splitReservationDateTime(src.reservation_time).time ?? '',
|
||||
arrival_time: splitReservationDateTime(src.reservation_end_time).time ?? '',
|
||||
confirmation_number: src.confirmation_number || '',
|
||||
notes: src.notes || '',
|
||||
meta_airline: meta.airline || '',
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
price: meta.price || '',
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
const orderedEps = orderedEndpoints(reservation)
|
||||
const orderedEps = orderedEndpoints(src)
|
||||
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
||||
let wps: WaypointForm[]
|
||||
if (orderedEps.length >= 2) {
|
||||
@@ -192,9 +203,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const isLast = i === orderedEps.length - 1
|
||||
return {
|
||||
airport: airportFromEndpoint(ep),
|
||||
arrDayId: legInto?.arr_day_id ?? (isLast ? (reservation.end_day_id ?? '') : ''),
|
||||
arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''),
|
||||
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
||||
depDayId: legOut?.dep_day_id ?? (isFirst ? (reservation.day_id ?? '') : ''),
|
||||
depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''),
|
||||
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
||||
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
||||
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
||||
@@ -203,15 +214,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
})
|
||||
} else {
|
||||
// Legacy flight with no (or partial) endpoints — seed two waypoints.
|
||||
const dep = emptyWaypoint(reservation.day_id ?? '')
|
||||
const dep = emptyWaypoint(src.day_id ?? '')
|
||||
dep.airport = airportFromEndpoint(from)
|
||||
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
|
||||
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
|
||||
dep.airline = meta.airline ?? ''
|
||||
dep.flight_number = meta.flight_number ?? ''
|
||||
dep.seat = meta.seat ?? ''
|
||||
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
|
||||
const arr = emptyWaypoint(src.end_day_id ?? src.day_id ?? '')
|
||||
arr.airport = airportFromEndpoint(to)
|
||||
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
|
||||
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
|
||||
wps = [dep, arr]
|
||||
}
|
||||
setWaypoints(wps)
|
||||
@@ -225,12 +236,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||
}
|
||||
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
||||
|
||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!form.title.trim()) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
@@ -289,11 +300,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
|
||||
const startDate = startDay?.date ?? null
|
||||
const endDate = (endDay ?? startDay)?.date ?? null
|
||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||
@@ -334,10 +340,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
// Imported booking → auto-create the linked cost from the parsed price (what the
|
||||
// old direct import did). Only on create (not edit) and only when there's a price.
|
||||
if (!reservation && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
;(payload as Record<string, unknown>).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
}
|
||||
const saved = await onSave(payload)
|
||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||
@@ -349,6 +359,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
}
|
||||
// The user asked to create/edit the linked expense — open the Costs editor
|
||||
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
|
||||
const intent = expenseIntentRef.current
|
||||
expenseIntentRef.current = null
|
||||
if (intent && onOpenExpense && saved?.id) {
|
||||
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
} finally {
|
||||
@@ -356,6 +374,19 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
||||
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
||||
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
|
||||
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
|
||||
const prefillPrice = Number(prefillMeta?.price)
|
||||
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
|
||||
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
|
||||
: null
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
@@ -712,38 +743,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category */}
|
||||
{/* Costs — create / view the expense linked to this booking */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
placeholder="0.00"
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
||||
<CustomSelect
|
||||
value={form.budget_category}
|
||||
onChange={v => set('budget_category', v)}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||
]}
|
||||
placeholder={t('reservations.budgetCategoryAuto')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.price && parseFloat(form.price) > 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
||||
{t('reservations.budgetHint')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<BookingCostsSection
|
||||
reservationId={reservation?.id ?? null}
|
||||
pendingExpense={pendingExpense}
|
||||
onCreate={handleCreateExpense}
|
||||
onEdit={handleEditExpense}
|
||||
onRemove={handleRemoveExpense}
|
||||
/>
|
||||
)}
|
||||
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { BookingImportPreviewItem, Reservation, ReservationEndpoint } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* A pre-fill draft for the reservation/transport edit modals built from a parsed
|
||||
* booking-import item. Carries the normal reservation fields the modals read for
|
||||
* their form, plus the import-only `_venue`/`_accommodation` the hotel path needs
|
||||
* to suggest a place and a day range. It has no `id` — the modal stays in
|
||||
* "create" mode and the user reviews/edits before it is ever persisted.
|
||||
*/
|
||||
export interface BookingReviewDraft extends Omit<Partial<Reservation>, 'metadata' | 'endpoints'> {
|
||||
/** Type-specific extras (airline, flight_number, check_in_time, price, …) as an object. */
|
||||
metadata?: Record<string, unknown> | null
|
||||
endpoints?: ReservationEndpoint[]
|
||||
/** Parsed venue (auto-created place candidate) — hotel/restaurant/event. */
|
||||
_venue?: BookingImportPreviewItem['_venue']
|
||||
/** Parsed check-in/out + confirmation — hotels only. */
|
||||
_accommodation?: BookingImportPreviewItem['_accommodation']
|
||||
/** The uploaded source file(s) the item was parsed from — attached to the booking on save. */
|
||||
_sourceFiles?: File[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a parsed booking item onto the shape the edit modals pre-fill from. Pure
|
||||
* (no I/O). Transport items keep their geocoded endpoints; venue/accommodation
|
||||
* ride along untouched so the hotel modal can match a place by name (or create
|
||||
* one from the reviewed address on save).
|
||||
*/
|
||||
export function parsedItemToDraft(item: BookingImportPreviewItem): BookingReviewDraft {
|
||||
return {
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
status: 'pending',
|
||||
reservation_time: item.reservation_time ?? null,
|
||||
reservation_end_time: item.reservation_end_time ?? null,
|
||||
location: item.location ?? item._venue?.address ?? item._venue?.name ?? null,
|
||||
confirmation_number: item.confirmation_number ?? null,
|
||||
notes: null,
|
||||
metadata: (item.metadata as Record<string, unknown> | undefined) ?? null,
|
||||
endpoints: (item.endpoints ?? []) as ReservationEndpoint[],
|
||||
_venue: item._venue,
|
||||
_accommodation: item._accommodation,
|
||||
}
|
||||
}
|
||||
|
||||
/** Transport types route to the TransportModal; everything else to the ReservationModal. */
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
||||
export function isTransportItem(item: BookingImportPreviewItem): boolean {
|
||||
return TRANSPORT_TYPES.has(item.type)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
const [url, setUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
||||
const [writeEnabled, setWriteEnabled] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -30,6 +31,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
.then(d => {
|
||||
setUrl(d.url || '')
|
||||
setAllowInsecureTls(!!d.allowInsecureTls)
|
||||
setWriteEnabled(!!d.writeEnabled)
|
||||
setConnected(!!d.connected)
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -46,7 +48,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
||||
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() })
|
||||
const status = await airtrailApi.status().catch(() => ({ connected: false }))
|
||||
setConnected(!!status.connected)
|
||||
setApiKey('')
|
||||
@@ -107,6 +109,14 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} />
|
||||
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.writeBack')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.writeBackHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { authApi, oauthApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||
import LlmConnectionSection from './LlmConnectionSection'
|
||||
import { ALL_SCOPES } from '../../api/oauthScopes'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
|
||||
@@ -99,6 +100,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
<>
|
||||
<PhotoProvidersSection />
|
||||
{S.airtrailEnabled && <AirTrailConnectionSection />}
|
||||
{S.llmEnabled && <LlmConnectionSection />}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
||||
<McpTokenModals {...S} />
|
||||
<OAuthClientModals {...S} />
|
||||
@@ -112,6 +114,7 @@ function useIntegrations() {
|
||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
const llmEnabled = addonEnabled('llm_parsing')
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
@@ -292,7 +295,7 @@ function useIntegrations() {
|
||||
|
||||
|
||||
return {
|
||||
t, locale, toast, mcpEnabled, airtrailEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||
t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Sparkles, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Settings } from '../../types'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
type Provider = NonNullable<Settings['llm_provider']>
|
||||
|
||||
/**
|
||||
* Settings → Integrations → AI parsing. Per-user model used to extract bookings
|
||||
* from uploaded files. It only takes effect when the admin has not configured an
|
||||
* instance-wide model on the addon — the server resolves the admin config first.
|
||||
* The API key is stored encrypted and never prefilled: a blank field keeps the
|
||||
* stored key (mirrors the AirTrail connection layout).
|
||||
*/
|
||||
export default function LlmConnectionSection(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
const isLoaded = useSettingsStore(s => s.isLoaded)
|
||||
const updateSettings = useSettingsStore(s => s.updateSettings)
|
||||
|
||||
const [provider, setProvider] = useState<Provider>('local')
|
||||
const [model, setModel] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [multimodal, setMultimodal] = useState(false)
|
||||
const [hasStoredKey, setHasStoredKey] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Hydrate from the loaded settings. llm_api_key arrives masked, so we only use
|
||||
// its presence to drive the placeholder — never the value itself.
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return
|
||||
setProvider(settings.llm_provider || 'local')
|
||||
setModel(settings.llm_model || '')
|
||||
setBaseUrl(settings.llm_base_url || '')
|
||||
setMultimodal(settings.llm_multimodal === true)
|
||||
setHasStoredKey(!!settings.llm_api_key)
|
||||
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_base_url, settings.llm_multimodal, settings.llm_api_key])
|
||||
|
||||
const needsKey = provider !== 'local'
|
||||
const showBaseUrl = provider === 'local' || provider === 'openai'
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: Partial<Settings> = {
|
||||
llm_provider: provider,
|
||||
llm_model: model.trim(),
|
||||
llm_base_url: showBaseUrl ? baseUrl.trim() : '',
|
||||
llm_multimodal: multimodal,
|
||||
}
|
||||
// Send the key only when the user typed a new one — a blank field means
|
||||
// "keep the stored key".
|
||||
const key = apiKey.trim()
|
||||
if (key) payload.llm_api_key = key
|
||||
await updateSettings(payload)
|
||||
setApiKey('')
|
||||
if (key) setHasStoredKey(true)
|
||||
toast.success(t('settings.aiParsing.toast.saved'))
|
||||
} catch {
|
||||
toast.error(t('settings.aiParsing.toast.saveError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.aiParsing.title')} icon={Sparkles}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-content-secondary">{t('settings.aiParsing.hint')}</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.provider')}</label>
|
||||
<CustomSelect
|
||||
value={provider}
|
||||
onChange={v => setProvider(v as Provider)}
|
||||
options={[
|
||||
{ value: 'local', label: t('settings.aiParsing.providerLocal') },
|
||||
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
|
||||
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.model')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder="qwen3:8b"
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showBaseUrl && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.baseUrl')}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={e => setBaseUrl(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.baseUrlHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsKey && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.apiKey')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.apiKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={multimodal} onToggle={() => setMultimodal(v => !v)} />
|
||||
<span className="text-sm font-medium text-content-secondary">{t('settings.aiParsing.multimodal')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.multimodalHint')}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !isLoaded}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export default function VacayCalendar() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<VacayMonthCard
|
||||
key={i}
|
||||
@@ -89,8 +89,8 @@ export default function VacayCalendar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Floating toolbar */}
|
||||
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
|
||||
{/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
|
||||
<div className="sticky mt-3 sm:mt-4 flex items-center justify-center px-2" style={{ bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', zIndex: 61 }}>
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||
<button
|
||||
onClick={() => setCompanyMode(false)}
|
||||
|
||||
@@ -102,7 +102,9 @@ export function ToastContainer() {
|
||||
`}</style>
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
||||
// Above modal overlays (which sit around z-index 10000 with a backdrop-filter
|
||||
// blur) so error toasts paint on top and stay legible instead of blurred behind.
|
||||
zIndex: 100000, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
||||
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
|
||||
}}>
|
||||
{toasts.map(toast => (
|
||||
|
||||
@@ -60,6 +60,15 @@ export interface BlobCacheEntry {
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
/** An uploaded booking-import source file, kept so the review flow can attach it to the
|
||||
* created bookings even after a page reload during the (background) parse. Keyed by job. */
|
||||
export interface ImportSourceFile {
|
||||
jobId: string;
|
||||
fileName: string;
|
||||
blob: Blob;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// ── Dexie class ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -105,6 +114,7 @@ class TrekOfflineDb extends Dexie {
|
||||
mutationQueue!: Table<QueuedMutation, string>;
|
||||
syncMeta!: Table<SyncMeta, number>;
|
||||
blobCache!: Table<BlobCacheEntry, string>;
|
||||
importFiles!: Table<ImportSourceFile, [string, string]>;
|
||||
|
||||
constructor(name: string = ANON_DB_NAME) {
|
||||
super(name);
|
||||
@@ -140,6 +150,11 @@ class TrekOfflineDb extends Dexie {
|
||||
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
|
||||
});
|
||||
});
|
||||
|
||||
// v4: durable store for booking-import source files (survives a reload mid-parse).
|
||||
this.version(4).stores({
|
||||
importFiles: '[jobId+fileName], jobId, createdAt',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +279,39 @@ export async function getCachedBlob(url: string): Promise<Blob | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Booking-import source files ─────────────────────────────────────────────
|
||||
|
||||
/** Abandoned import files (never reviewed) are pruned after this long. */
|
||||
const IMPORT_FILE_TTL_MS = 60 * 60_000;
|
||||
|
||||
/**
|
||||
* Persist the uploaded source files for a background import job so the per-item review can
|
||||
* attach each document to its booking even if the page reloads during the parse. Best-effort.
|
||||
*/
|
||||
export async function saveImportFiles(jobId: string, files: File[]): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
await offlineDb.importFiles.bulkPut(files.map(f => ({ jobId, fileName: f.name, blob: f, createdAt: now })));
|
||||
// Prune leftovers from imports that were never reviewed.
|
||||
await offlineDb.importFiles.where('createdAt').below(now - IMPORT_FILE_TTL_MS).delete();
|
||||
} catch { /* the in-memory copy still serves the no-reload path */ }
|
||||
}
|
||||
|
||||
/** A job's stored source files, rebuilt as File objects (name + type preserved for upload). */
|
||||
export async function getImportFiles(jobId: string): Promise<File[]> {
|
||||
try {
|
||||
const rows = await offlineDb.importFiles.where('jobId').equals(jobId).toArray();
|
||||
return rows.map(r => new File([r.blob], r.fileName, { type: r.blob.type || 'application/octet-stream' }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a job's stored source files once they've been handed to the review flow. */
|
||||
export async function deleteImportFiles(jobId: string): Promise<void> {
|
||||
try { await offlineDb.importFiles.where('jobId').equals(jobId).delete(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Blob-cache budget ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator'
|
||||
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
||||
import { getDayBookendHotels } from '../utils/dayOrder'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult } from '../types'
|
||||
import type { RouteSegment, RouteResult, Accommodation } from '../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
|
||||
|
||||
const NO_ACCOMMODATIONS: Accommodation[] = []
|
||||
|
||||
/**
|
||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
||||
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
||||
*/
|
||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
|
||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
|
||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
|
||||
// hotel) unless the user turned the setting off — same gate as the sidebar.
|
||||
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||
@@ -93,10 +100,26 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
}
|
||||
if (currentRun.length >= 2) runs.push(currentRun)
|
||||
|
||||
const straightLines = (): [number, number][][] =>
|
||||
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
||||
// Bookend the route with the day's accommodation: a hotel → first-stop run and
|
||||
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
|
||||
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
|
||||
// transfer day) and already filters to accommodations that have coordinates.
|
||||
const day = allDays.find(d => d.id === dayId)
|
||||
const { morning: startHotel, evening: endHotel } =
|
||||
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, allDays, accommodations) : {}
|
||||
const flatPts: { lat: number; lng: number }[] = []
|
||||
for (const e of entries) {
|
||||
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng })
|
||||
else { if (e.from) flatPts.push(e.from); if (e.to) flatPts.push(e.to) }
|
||||
}
|
||||
const hotelPt = (a?: Accommodation) =>
|
||||
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
|
||||
const runsWithHotel = withHotelBookends(runs, flatPts[0], flatPts[flatPts.length - 1], hotelPt(startHotel), hotelPt(endHotel))
|
||||
|
||||
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||
const straightLines = (): [number, number][][] =>
|
||||
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
||||
|
||||
if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||
|
||||
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||
// OSRM road geometry.
|
||||
@@ -107,7 +130,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
try {
|
||||
const polylines: [number, number][][] = []
|
||||
const allLegs: RouteSegment[] = []
|
||||
for (const run of runs) {
|
||||
for (const run of runsWithHotel) {
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
||||
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
||||
@@ -123,7 +146,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
|
||||
}
|
||||
}, [enabled, profile])
|
||||
}, [enabled, profile, accommodations, optimizeFromAccommodation])
|
||||
|
||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||
@@ -147,7 +170,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation])
|
||||
|
||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import CustomSelect from '../components/shared/CustomSelect'
|
||||
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
||||
import type { TranslationFn } from '../types'
|
||||
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
||||
import { continentForCountry } from '@trek/shared'
|
||||
import { useAtlas } from './atlas/useAtlas'
|
||||
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
@@ -212,7 +213,8 @@ export default function AtlasPage(): React.ReactElement {
|
||||
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
|
||||
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||
const cont = continentForCountry(confirmAction.code)
|
||||
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -260,7 +262,8 @@ export default function AtlasPage(): React.ReactElement {
|
||||
})
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||
const cont = continentForCountry(countryCode)
|
||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -339,10 +342,12 @@ export default function AtlasPage(): React.ReactElement {
|
||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||
if (remainingRegions.length > 0) return prev
|
||||
const cont = continentForCountry(countryCode)
|
||||
return {
|
||||
...prev,
|
||||
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||
LayoutGrid, List, Ticket, X,
|
||||
} from 'lucide-react'
|
||||
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import '../styles/dashboard.css'
|
||||
|
||||
const GRADIENTS = [
|
||||
@@ -36,6 +38,7 @@ function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.leng
|
||||
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null {
|
||||
if (!dateStr) return null
|
||||
const date = new Date(dateStr + 'T00:00:00Z')
|
||||
if (isNaN(date.getTime())) return null // malformed date — render a dash, never crash
|
||||
return {
|
||||
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
|
||||
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
|
||||
@@ -81,6 +84,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const {
|
||||
demoMode, locale, t, navigate,
|
||||
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
||||
loadError, retryLoad,
|
||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
||||
@@ -99,6 +103,15 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<MobileTopBar />
|
||||
<main className="page">
|
||||
<div className="page-main">
|
||||
{loadError && (
|
||||
<div className="dash-error" role="alert">
|
||||
<span className="dash-error-txt">{t('dashboard.loadErrorBanner')}</span>
|
||||
<button className="dash-error-retry" onClick={retryLoad}>
|
||||
<RefreshCw size={15} />
|
||||
{t('dashboard.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{spotlight && (
|
||||
<BoardingPassHero
|
||||
trip={spotlight}
|
||||
@@ -129,6 +142,13 @@ export default function DashboardPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
|
||||
<div className="trips-empty">
|
||||
<h4>{t('dashboard.emptyTitle')}</h4>
|
||||
<p>{t('dashboard.emptyText')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
|
||||
{gridTrips.map(trip => (
|
||||
<TripCard
|
||||
@@ -602,6 +622,7 @@ function UpcomingTool({ items, locale, onOpen }: {
|
||||
items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format)
|
||||
return (
|
||||
<div className="tool">
|
||||
<div className="tool-head">
|
||||
@@ -612,10 +633,13 @@ function UpcomingTool({ items, locale, onOpen }: {
|
||||
) : (
|
||||
<div className="upc-list">
|
||||
{items.map(r => {
|
||||
const when = r.reservation_time || (r.day_date ? r.day_date + 'T00:00:00' : null)
|
||||
const d = when ? new Date(when) : null
|
||||
const dateStr = d ? splitDate(d.toISOString().slice(0, 10), locale) : null
|
||||
const timeStr = r.reservation_time ? new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : null
|
||||
// Read the date/time straight from the stored string parts. Going through
|
||||
// new Date(...).toISOString() reinterprets the naive local time as UTC and
|
||||
// can roll the displayed day forward/back in non-UTC timezones.
|
||||
const parsed = splitReservationDateTime(r.reservation_time)
|
||||
const datePart = parsed.date || r.day_date || null
|
||||
const dateStr = datePart ? splitDate(datePart, locale) : null
|
||||
const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
|
||||
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
|
||||
return (
|
||||
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}>
|
||||
|
||||
@@ -405,4 +405,79 @@ describe('SharedTripPage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-SHARED-017: Multi-leg flight shows each leg in the Day Plan', () => {
|
||||
const day = { id: 101, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One', notes: null };
|
||||
const multiLegFlight = {
|
||||
id: 9, trip_id: 1, title: 'Flight', type: 'flight', status: 'confirmed',
|
||||
day_id: 101, end_day_id: 101,
|
||||
reservation_time: '2026-07-01T08:00:00', reservation_end_time: '2026-07-01T20:00:00',
|
||||
metadata: JSON.stringify({
|
||||
legs: [
|
||||
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1', dep_day_id: 101, dep_time: '08:00', arr_day_id: 101, arr_time: '09:00' },
|
||||
{ from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2', dep_day_id: 101, dep_time: '10:00', arr_day_id: 101, arr_time: '20:00' },
|
||||
],
|
||||
departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1',
|
||||
}),
|
||||
};
|
||||
|
||||
function serveMultiLeg(token: string) {
|
||||
server.use(
|
||||
http.get('/api/shared/:token', ({ params }) => {
|
||||
if (params.token !== token) return;
|
||||
return HttpResponse.json({
|
||||
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
|
||||
days: [day],
|
||||
assignments: {},
|
||||
dayNotes: {},
|
||||
places: [],
|
||||
reservations: [multiLegFlight],
|
||||
accommodations: [],
|
||||
packing: [],
|
||||
budget: [],
|
||||
categories: [],
|
||||
permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false },
|
||||
collab: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('renders each leg with its own route, not the overall start/end', async () => {
|
||||
serveMultiLeg('multileg-token');
|
||||
renderSharedTrip('multileg-token');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand the day to reveal the timeline
|
||||
fireEvent.click(screen.getByText('Day One'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/FRA → BER/)).toBeInTheDocument();
|
||||
});
|
||||
// Second leg shows its OWN route + flight number (the bug showed the overall route here)
|
||||
expect(screen.getByText(/BER → HND/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/LH2/)).toBeInTheDocument();
|
||||
// The overall start→end must NOT appear on any leg
|
||||
expect(screen.queryByText(/FRA → HND/)).toBeNull();
|
||||
});
|
||||
|
||||
it('lists each leg flight number in the Bookings tab', async () => {
|
||||
serveMultiLeg('multileg-bookings-token');
|
||||
renderSharedTrip('multileg-bookings-token');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /bookings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/LH1/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/LH2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||
import { getFlightLegs } from '../utils/flightLegs'
|
||||
import { splitReservationDateTime } from '../utils/formatters'
|
||||
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
@@ -214,16 +215,24 @@ export default function SharedTripPage() {
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
||||
const endTime = splitReservationDateTime(r.reservation_end_time).time ?? ''
|
||||
let sub = ''
|
||||
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
if (r.type === 'flight') {
|
||||
if (r.__leg) {
|
||||
// One leg of a multi-leg flight — show this segment's own route/flight number.
|
||||
sub = [r.__leg.airline, r.__leg.flight_number, (r.__leg.from || r.__leg.to) ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' · ')
|
||||
} else {
|
||||
sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div key={`t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||
<div key={r.__leg ? `t-${r.id}-leg${r.__leg.index}` : `t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<TIcon size={12} color="#3b82f6" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-[#111827]" style={{ fontSize: 12, fontWeight: 500 }}>{r.title}{time ? ` · ${time}` : ''}</div>
|
||||
<div className="text-[#111827]" style={{ fontSize: 12, fontWeight: 500 }}>{r.title}{time ? ` · ${time}${endTime ? `–${endTime}` : ''}` : ''}</div>
|
||||
{sub && <div className="text-[#6b7280]" style={{ fontSize: 10 }}>{sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,7 +293,11 @@ export default function SharedTripPage() {
|
||||
{date && <span>{date}</span>}
|
||||
{time && <span>{time}</span>}
|
||||
{r.location && <span>{r.location}</span>}
|
||||
{meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
{r.type === 'flight'
|
||||
? getFlightLegs(r).map((leg, i) => (
|
||||
<span key={i}>{[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
|
||||
))
|
||||
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
{meta.train_number && <span>{meta.train_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1160,10 +1160,13 @@ describe('TripPlannerPage', () => {
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-041: handleSaveReservation edit path covers update reservation', () => {
|
||||
it('calls onEdit then onSave on ReservationModal to exercise the edit-reservation handler', async () => {
|
||||
it('does not force a day_id on edit so the server keeps/derives it (#1237)', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
seedTripStore({ id: 42 });
|
||||
// Capture the update payload — tripActions is a snapshot of the store at mount.
|
||||
const updateReservationSpy = vi.fn().mockResolvedValue({ id: 1, day_id: 7 });
|
||||
seedStore(useTripStore, { updateReservation: updateReservationSpy } as any);
|
||||
|
||||
renderPlannerPage(42);
|
||||
|
||||
@@ -1179,20 +1182,24 @@ describe('TripPlannerPage', () => {
|
||||
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Set editingReservation via captured onEdit prop (inline lambda in JSX)
|
||||
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'restaurant', status: 'confirmed' };
|
||||
// Edit a reservation that lives on day 7 (no day is selected — Book tab).
|
||||
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'other', status: 'confirmed', day_id: 7 };
|
||||
await act(async () => {
|
||||
capturedReservationsPanelProps.current.onEdit?.(fakeReservation);
|
||||
});
|
||||
|
||||
// Call onSave — now takes edit path (editingReservation is set)
|
||||
await act(async () => {
|
||||
await capturedReservationModalProps.current.onSave?.({
|
||||
name: 'Updated Booking',
|
||||
type: 'restaurant',
|
||||
type: 'tour',
|
||||
status: 'confirmed',
|
||||
});
|
||||
});
|
||||
|
||||
// The client must NOT send a day_id (no forcing to the selected day, no
|
||||
// stale value) — the server keeps/derives it from the booking's date.
|
||||
expect(updateReservationSpy).toHaveBeenCalled();
|
||||
expect(updateReservationSpy.mock.calls[0][2]).not.toHaveProperty('day_id');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
@@ -25,7 +25,9 @@ import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
||||
import TodoListPanel from '../components/Todo/TodoListPanel'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
import CostsPanel from '../components/Budget/CostsPanel'
|
||||
import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Budget/CostsPanel'
|
||||
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
|
||||
import type { BudgetItem } from '../types'
|
||||
import CollabPanel from '../components/Collab/CollabPanel'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
@@ -33,7 +35,6 @@ import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen,
|
||||
import { useTranslation } from '../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
|
||||
import { accommodationRepo } from '../repo/accommodationRepo'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useResizablePanels } from '../hooks/useResizablePanels'
|
||||
@@ -193,6 +194,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
reservationPrefill, transportPrefill, importReviewActive, advanceImportReview,
|
||||
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
|
||||
@@ -201,7 +203,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
@@ -212,6 +214,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
|
||||
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
|
||||
// page level so it has tripMembers / base currency / current user available.
|
||||
const meId = useAuthStore(s => s.user?.id ?? -1)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
|
||||
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null)
|
||||
const openBookingExpense = (req: BookingExpenseRequest) => {
|
||||
if (req.editItem) setBookingExpense({ editing: req.editItem })
|
||||
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill })
|
||||
}
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
return (
|
||||
<div className="bg-surface" style={{
|
||||
@@ -451,7 +465,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||
onEditPlace={(place) => openPlaceEditor(place)}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||
onCategoryFilterChange={setMapCategoryFilter}
|
||||
@@ -517,17 +531,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => {
|
||||
if (selectedAssignmentId) {
|
||||
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||
setEditingPlace(placeWithAssignmentTimes)
|
||||
} else {
|
||||
setEditingPlace(selectedPlace)
|
||||
}
|
||||
setEditingAssignmentId(selectedAssignmentId || null)
|
||||
setShowPlaceForm(true)
|
||||
}}
|
||||
onEdit={() => openPlaceEditor(selectedPlace, selectedAssignmentId)}
|
||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
@@ -565,18 +569,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => {
|
||||
if (selectedAssignmentId) {
|
||||
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||
setEditingPlace(placeWithAssignmentTimes)
|
||||
} else {
|
||||
setEditingPlace(selectedPlace)
|
||||
}
|
||||
setEditingAssignmentId(selectedAssignmentId || null)
|
||||
setShowPlaceForm(true)
|
||||
setSelectedPlaceId(null)
|
||||
}}
|
||||
onEdit={() => { openPlaceEditor(selectedPlace, selectedAssignmentId); setSelectedPlaceId(null) }}
|
||||
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
@@ -617,7 +610,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -703,12 +696,24 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
|
||||
{bookingExpense && (
|
||||
<ExpenseModal
|
||||
tripId={tripId}
|
||||
base={costsBase}
|
||||
people={tripMembers}
|
||||
me={meId}
|
||||
editing={bookingExpense.editing}
|
||||
prefill={bookingExpense.prefill}
|
||||
onClose={() => setBookingExpense(null)}
|
||||
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
|
||||
/>
|
||||
)}
|
||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} />
|
||||
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
|
||||
@@ -229,12 +229,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||
{(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||
>
|
||||
{updateInfo?.is_docker === false ? (
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki/Updating"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.5, display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none' }}
|
||||
className="bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||
>
|
||||
{`docker pull mauriceboe/trek:latest
|
||||
docker stop trek && docker rm trek
|
||||
docker run -d --name trek \\
|
||||
@@ -243,7 +255,8 @@ docker run -d --name trek \\
|
||||
-v /opt/trek/uploads:/app/uploads \\
|
||||
--restart unless-stopped \\
|
||||
mauriceboe/trek:latest`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||
|
||||
@@ -6,6 +6,7 @@ import apiClient, { mapsApi } from '../../api/client'
|
||||
import L from 'leaflet'
|
||||
import type { GeoJsonFeatureCollection } from '../../types'
|
||||
import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
|
||||
import { continentForCountry } from '@trek/shared'
|
||||
|
||||
function useCountryNames(language: string): (code: string) => string {
|
||||
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
|
||||
@@ -133,9 +134,12 @@ export function useAtlas() {
|
||||
}, [])
|
||||
|
||||
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
|
||||
// no third-party fetch from the browser).
|
||||
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
|
||||
// it gets a longer timeout than the global 8s default to survive slow links and
|
||||
// reverse-proxy / Cloudflare-Tunnel setups instead of aborting and leaving the map
|
||||
// with no countries (#1254).
|
||||
useEffect(() => {
|
||||
apiClient.get('/addons/atlas/countries/geo')
|
||||
apiClient.get('/addons/atlas/countries/geo', { timeout: 30000 })
|
||||
.then(res => {
|
||||
const geo = res.data
|
||||
// Dynamically build A2→A3 mapping from GeoJSON
|
||||
@@ -340,7 +344,10 @@ export function useAtlas() {
|
||||
</div>
|
||||
</div>`
|
||||
layer.bindTooltip(tooltipHtml, {
|
||||
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
// sticky so the tooltip tracks the cursor; non-sticky anchors it at the feature's
|
||||
// bounds centre, which for countries with overseas territories (e.g. France) lands
|
||||
// far out in the ocean instead of over the area being hovered.
|
||||
sticky: true, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
})
|
||||
layer.on('click', () => {
|
||||
if (c.placeCount === 0 && c.tripCount === 0) {
|
||||
@@ -363,7 +370,7 @@ export function useAtlas() {
|
||||
country_layer_by_a2_ref.current[countryCode] = layer
|
||||
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
||||
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
||||
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
sticky: true, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
})
|
||||
layer.on('click', () => handleMarkCountry(countryCode, name))
|
||||
layer.on('mouseover', (e) => {
|
||||
@@ -552,6 +559,20 @@ export function useAtlas() {
|
||||
} catch (e ) {
|
||||
console.error('Error fitting bounds', e)
|
||||
}
|
||||
|
||||
// Mirror the map-click behaviour so an already-visited country can be removed
|
||||
// straight from search. Tiny countries (Vatican City, Singapore) are hard to
|
||||
// hit on the map, so search was the only way in — but it always opened the
|
||||
// "Mark / Bucket" dialog with no Remove option.
|
||||
const visited = data?.countries.find(c => c.code === country_code)
|
||||
if (visited) {
|
||||
if (visited.placeCount === 0 && visited.tripCount === 0) {
|
||||
handleUnmarkCountry(country_code)
|
||||
} else {
|
||||
loadCountryDetailRef.current(country_code)
|
||||
}
|
||||
return
|
||||
}
|
||||
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
|
||||
}
|
||||
|
||||
@@ -565,10 +586,12 @@ export function useAtlas() {
|
||||
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === code)) return prev
|
||||
const cont = continentForCountry(code)
|
||||
return {
|
||||
...prev,
|
||||
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
|
||||
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
|
||||
continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 },
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -579,10 +602,12 @@ export function useAtlas() {
|
||||
if (!prev) return prev
|
||||
const c = prev.countries.find(c => c.code === code)
|
||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||
const cont = continentForCountry(code)
|
||||
return {
|
||||
...prev,
|
||||
countries: prev.countries.filter(c => c.code !== code),
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
|
||||
}
|
||||
})
|
||||
setVisitedRegions(prev => {
|
||||
|
||||
@@ -33,6 +33,7 @@ export function useDashboard() {
|
||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
|
||||
const [loadError, setLoadError] = useState<boolean>(false)
|
||||
|
||||
const [stats, setStats] = useState<TravelStats | null>(null)
|
||||
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
|
||||
@@ -42,7 +43,7 @@ export function useDashboard() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const { demoMode } = useAuthStore()
|
||||
const { demoMode, authCheckFailed, loadUser } = useAuthStore()
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setViewMode(prev => {
|
||||
@@ -74,13 +75,22 @@ export function useDashboard() {
|
||||
const { trips, archivedTrips } = await tripRepo.list()
|
||||
setTrips(sortTrips(trips))
|
||||
setArchivedTrips(sortTrips(archivedTrips))
|
||||
setLoadError(false)
|
||||
} catch {
|
||||
setLoadError(true)
|
||||
toast.error(t('dashboard.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run both the trip fetch and the auth check so a recovered backend clears
|
||||
// the error banner (loadUser resets authCheckFailed on success). #1283
|
||||
const retryLoad = () => {
|
||||
loadUser({ silent: true })
|
||||
loadTrips()
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|
||||
|| trips.find(t => t.start_date && t.start_date >= today)
|
||||
@@ -177,6 +187,7 @@ export function useDashboard() {
|
||||
demoMode, locale, t, navigate,
|
||||
// data + derived
|
||||
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
||||
loadError: loadError || authCheckFailed, retryLoad,
|
||||
// ui state
|
||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||
|
||||
@@ -17,7 +17,8 @@ export function useSettings() {
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled
|
||||
const llmEnabled = addonEnabled('llm_parsing')
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled
|
||||
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('display')
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
||||
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'
|
||||
|
||||
describe('resolvePoolAssignmentId', () => {
|
||||
it('returns the lone assignment id when the place is assigned to exactly one day', () => {
|
||||
const place = buildPlace({ id: 7 })
|
||||
const assignment = buildAssignment({ id: 42, day_id: 3, place })
|
||||
const assignments = { 3: [assignment], 4: [buildAssignment({ id: 99, day_id: 4 })] }
|
||||
expect(resolvePoolAssignmentId(assignments, 7)).toBe(42)
|
||||
})
|
||||
|
||||
it('returns null when the place is not assigned to any day', () => {
|
||||
const assignments = { 3: [buildAssignment({ id: 99, day_id: 3 })] }
|
||||
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the place is assigned to multiple days (ambiguous time)', () => {
|
||||
const assignments = {
|
||||
3: [buildAssignment({ id: 1, day_id: 3, place: buildPlace({ id: 7 }) })],
|
||||
4: [buildAssignment({ id: 2, day_id: 4, place: buildPlace({ id: 7 }) })],
|
||||
}
|
||||
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Trip planner pure helpers — React/IO-free logic shared by the data hook
|
||||
* (useTripPlanner) and kept here so it can be unit-tested in isolation. Part of
|
||||
* the FE "page = wiring container + data hook" convention (see PATTERN.md).
|
||||
*/
|
||||
|
||||
import type { Assignment } from '../../types'
|
||||
|
||||
/**
|
||||
* Resolve the day-assignment to use when a place is edited from the Places pool,
|
||||
* where no day is in context. Times live per day-assignment (#1247), so we can
|
||||
* only hydrate/persist a place's time when it is assigned to exactly one day.
|
||||
* Returns that assignment's id, or null when the place has 0 or 2+ assignments
|
||||
* (ambiguous — the modal then hides the time fields).
|
||||
*/
|
||||
export function resolvePoolAssignmentId(
|
||||
assignments: Record<string | number, Assignment[]>,
|
||||
placeId: number,
|
||||
): number | null {
|
||||
const matches = Object.values(assignments)
|
||||
.flat()
|
||||
.filter((a) => a.place?.id === placeId)
|
||||
return matches.length === 1 ? matches[0].id : null
|
||||
}
|
||||
@@ -7,9 +7,12 @@ import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
|
||||
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
import { offlineDb, getImportFiles, deleteImportFiles } from '../../db/offlineDb'
|
||||
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useResizablePanels } from '../../hooks/useResizablePanels'
|
||||
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
||||
@@ -18,6 +21,7 @@ import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
||||
|
||||
/**
|
||||
* Trip planner page logic — the big one. Owns the trip store wiring, addon
|
||||
@@ -157,6 +161,14 @@ export function useTripPlanner() {
|
||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
||||
// Review-before-save import: each parsed item pre-fills the normal edit modal so
|
||||
// the user checks/fixes it, then saves. A ref drives the queue (no stale closures).
|
||||
const [reservationPrefill, setReservationPrefill] = useState<BookingReviewDraft | null>(null)
|
||||
const [transportPrefill, setTransportPrefill] = useState<BookingReviewDraft | null>(null)
|
||||
const [importReviewActive, setImportReviewActive] = useState(false)
|
||||
const importQueueRef = useRef<BookingImportPreviewItem[]>([])
|
||||
// The files this import was parsed from, so each reviewed booking can attach its source doc.
|
||||
const importSourceFilesRef = useRef<File[]>([])
|
||||
// Manual route planning: off by default, toggled from the day-plan footer. Mode
|
||||
// (driving/walking) is per-session and selects which travel time the connectors show.
|
||||
const [routeShown, setRouteShown] = useState(false)
|
||||
@@ -288,7 +300,7 @@ export function useTripPlanner() {
|
||||
})
|
||||
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
||||
|
||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
|
||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
|
||||
|
||||
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
|
||||
const changed = dayId !== selectedDayId
|
||||
@@ -423,6 +435,16 @@ export function useTripPlanner() {
|
||||
}
|
||||
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
|
||||
|
||||
// Open the place editor from any entry point (Places pool, inspector, map).
|
||||
// Times live per day-assignment, so when no day is in context resolve the
|
||||
// place's lone assignment to hydrate & persist its times; with 0 or 2+
|
||||
// assignments the time is ambiguous and the modal hides the fields (#1247).
|
||||
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
|
||||
setEditingPlace(place)
|
||||
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
|
||||
setShowPlaceForm(true)
|
||||
}, [assignments])
|
||||
|
||||
const handleDeletePlace = useCallback((placeId) => {
|
||||
setDeletePlaceId(placeId)
|
||||
}, [])
|
||||
@@ -567,8 +589,20 @@ export function useTripPlanner() {
|
||||
|
||||
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
||||
try {
|
||||
// Imported hotel with a reviewed address but no existing place picked: match
|
||||
// an existing place by name, else geocode the address and create one, then link it.
|
||||
const acc = (data as Record<string, any>).create_accommodation
|
||||
if (data.type === 'hotel' && acc && acc.venue && !acc.place_id) {
|
||||
acc.place_id = (await resolveImportedPlace(acc.venue)) ?? undefined
|
||||
delete acc.venue
|
||||
}
|
||||
if (editingReservation) {
|
||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
||||
// Don't force a day here. The old code pinned it to the (often empty)
|
||||
// selected day, which dropped the booking out of the Plan; preserving the
|
||||
// old day_id instead left it stale when the date changed. Omitting it lets
|
||||
// the server derive the day from the booking's date, or keep the current
|
||||
// one when there is no date.
|
||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
|
||||
toast.success(t('trip.toast.reservationUpdated'))
|
||||
setShowReservationModal(false)
|
||||
setEditingReservation(null)
|
||||
@@ -580,6 +614,9 @@ export function useTripPlanner() {
|
||||
const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('trip.toast.reservationAdded'))
|
||||
setShowReservationModal(false)
|
||||
// An imported booking auto-creates a linked cost server-side; the saving client gets
|
||||
// no budget:created echo, so refresh the budget items here to surface it without a reload.
|
||||
if ((data as Record<string, unknown>).create_budget_entry) await tripActions.loadBudgetItems?.(tripId)
|
||||
// Refresh accommodations if hotel was created
|
||||
if (data.type === 'hotel') {
|
||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||
@@ -604,6 +641,8 @@ export function useTripPlanner() {
|
||||
setShowTransportModal(false)
|
||||
setEditingTransport(null)
|
||||
setTransportModalDayId(null)
|
||||
// Surface the auto-created linked cost without a reload (no budget:created echo to us).
|
||||
if (data.create_budget_entry) await tripActions.loadBudgetItems?.(tripId)
|
||||
return r
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
@@ -619,6 +658,108 @@ export function useTripPlanner() {
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// ── Review-before-save booking import ───────────────────────────────────────
|
||||
// Match an existing trip place by name, else geocode the reviewed address and
|
||||
// create one. Returns the place id (or null if even creation failed).
|
||||
const resolveImportedPlace = async (venue: { name?: string; address?: string | null }): Promise<number | null> => {
|
||||
const name = (venue.name || '').trim()
|
||||
const n = name.toLowerCase()
|
||||
if (n) {
|
||||
const existing = places.find(p => p.name?.trim().toLowerCase() === n)
|
||||
?? places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
|
||||
if (existing) return existing.id
|
||||
}
|
||||
let lat: number | null = null
|
||||
let lng: number | null = null
|
||||
let address: string | null = venue.address ?? null
|
||||
try {
|
||||
const query = venue.address ? `${name} ${venue.address}`.trim() : name
|
||||
if (query) {
|
||||
const res = await mapsApi.search(query)
|
||||
const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined
|
||||
if (hit && hit.lat != null && hit.lng != null) {
|
||||
lat = hit.lat; lng = hit.lng
|
||||
if (!address && hit.address) address = hit.address
|
||||
}
|
||||
}
|
||||
} catch { /* geocode failure is non-fatal — create the place without coords */ }
|
||||
try {
|
||||
const place = await placesApi.create(tripId, { name: name || address || 'Accommodation', lat, lng, address } as never)
|
||||
return (place as { id?: number })?.id ?? null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// Open the right edit modal for a parsed item, pre-filled, in create mode.
|
||||
const openImportItem = (item: BookingImportPreviewItem) => {
|
||||
const draft = parsedItemToDraft(item)
|
||||
// Attach the file this item was parsed from so it lands in the booking's Files on save.
|
||||
const srcName = item.source?.fileName
|
||||
const srcFile = srcName ? importSourceFilesRef.current.find(f => f.name === srcName) : undefined
|
||||
if (srcFile) draft._sourceFiles = [srcFile]
|
||||
if (isTransportItem(item)) {
|
||||
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
|
||||
setEditingTransport(null); setTransportModalDayId(null)
|
||||
setTransportPrefill(draft); setShowTransportModal(true)
|
||||
} else {
|
||||
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
|
||||
setEditingReservation(null)
|
||||
setReservationPrefill(draft); setShowReservationModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
const startImportReview = (items: BookingImportPreviewItem[], sourceFiles: File[] = []) => {
|
||||
if (!items.length) return
|
||||
importSourceFilesRef.current = sourceFiles
|
||||
importQueueRef.current = items.slice(1)
|
||||
setImportReviewActive(true)
|
||||
openImportItem(items[0])
|
||||
}
|
||||
|
||||
// Bridge: when a finished background import is sent here for review (the user hit
|
||||
// "review" in the background widget, on this or any page), open the per-item flow.
|
||||
// Lives in the hook so the page stays a pure wiring container.
|
||||
const bgTasks = useBackgroundTasksStore((s) => s.tasks)
|
||||
const dismissBgTask = useBackgroundTasksStore((s) => s.dismiss)
|
||||
useEffect(() => {
|
||||
const task = bgTasks.find(
|
||||
(tk) => tk.tripId === String(tripId) && tk.status === 'done' && tk.reviewRequested && !tk.consumed,
|
||||
)
|
||||
if (task && task.items && task.items.length > 0) {
|
||||
// Hand the items (and the source files, to attach to each booking) to the review flow
|
||||
// and clear the widget entry — once the user hit "review", the background card is done.
|
||||
const items = task.items
|
||||
const jobId = task.id
|
||||
const inMemory = task.sourceFiles
|
||||
dismissBgTask(jobId)
|
||||
// Prefer the in-memory files (immediate path); after a reload they live in IndexedDB.
|
||||
void (async () => {
|
||||
const files = inMemory && inMemory.length ? inMemory : await getImportFiles(jobId)
|
||||
deleteImportFiles(jobId)
|
||||
startImportReview(items, files)
|
||||
})()
|
||||
}
|
||||
}, [bgTasks, tripId, startImportReview, dismissBgTask])
|
||||
|
||||
// Called when a reviewed item's modal closes (saved or skipped): open the next,
|
||||
// or finish the review session and refresh accommodations.
|
||||
const advanceImportReview = () => {
|
||||
const queue = importQueueRef.current
|
||||
if (queue.length > 0) {
|
||||
importQueueRef.current = queue.slice(1)
|
||||
openImportItem(queue[0])
|
||||
return
|
||||
}
|
||||
importQueueRef.current = []
|
||||
setImportReviewActive(false)
|
||||
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
|
||||
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
|
||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||
// Imported bookings auto-create their linked costs server-side, but the saving client
|
||||
// suppresses its own budget:created echo (X-Socket-Id) — so reload the budget items here
|
||||
// to surface those expenses without a manual page refresh.
|
||||
tripActions.loadBudgetItems?.(tripId)
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
// Build placeId → order-number map from the selected day's assignments
|
||||
@@ -677,6 +818,7 @@ export function useTripPlanner() {
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
|
||||
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
|
||||
@@ -685,7 +827,7 @@ export function useTripPlanner() {
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
|
||||
@@ -25,6 +25,11 @@ interface AuthState {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
/** The auth check (loadUser) failed for a non-401 reason while we were online —
|
||||
* the server was unreachable or erroring. Surfaced by the UI so a backend/IdP
|
||||
* outage doesn't render as a blank, error-free page that looks like lost data.
|
||||
* Transient, never persisted. #1283 */
|
||||
authCheckFailed: boolean
|
||||
error: string | null
|
||||
demoMode: boolean
|
||||
devMode: boolean
|
||||
@@ -86,6 +91,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
authCheckFailed: false,
|
||||
error: null,
|
||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||
devMode: false,
|
||||
@@ -200,6 +206,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
authCheckFailed: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
@@ -215,22 +222,33 @@ export const useAuthStore = create<AuthState>()(
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
authCheckFailed: false,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
} catch (err: unknown) {
|
||||
if (seq !== authSequence) return // stale response — ignore
|
||||
// Only clear auth state on 401 (invalid/expired token), not on network errors
|
||||
const isAuthError = err && typeof err === 'object' && 'response' in err &&
|
||||
(err as { response?: { status?: number } }).response?.status === 401
|
||||
if (isAuthError) {
|
||||
const status = err && typeof err === 'object' && 'response' in err
|
||||
? (err as { response?: { status?: number } }).response?.status
|
||||
: undefined
|
||||
if (status === 401) {
|
||||
// Invalid/expired token — clear auth so the guard redirects to login.
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
authCheckFailed: false,
|
||||
})
|
||||
} else {
|
||||
} else if (status === undefined && typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
// Genuinely offline — keep the persisted session so the PWA serves cached
|
||||
// data without a scary error. This is the offline-first happy path.
|
||||
set({ isLoading: false })
|
||||
} else {
|
||||
// Server erroring (5xx) or unreachable while we're online: keep the session
|
||||
// (don't eject the user over a transient outage), but flag it so the UI can
|
||||
// say "couldn't reach the server" instead of showing a blank, error-free
|
||||
// page that looks like the user's trips were lost. #1283
|
||||
set({ isLoading: false, authCheckFailed: true })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Tracks booking-import parses that run in the BACKGROUND (the async endpoint).
|
||||
* The upload modal closes the moment a parse starts and adds a task here; the
|
||||
* server pushes import:progress / import:done / import:error over the user's
|
||||
* WebSocket (which reaches every page), and the global BackgroundTasksWidget
|
||||
* renders the list. The trip page turns a finished task into the review flow.
|
||||
*
|
||||
* Persisted (minimal): the server keeps the job for ~10 min and exposes a status
|
||||
* endpoint, so a reload mid-parse must NOT drop the widget — we persist the running
|
||||
* (and finished-but-unreviewed) tasks by id and the widget re-fetches their status
|
||||
* on mount. We deliberately persist neither the parsed `items` (re-fetched) nor the
|
||||
* transient review flags (so a reload never auto-reopens the review flow).
|
||||
*/
|
||||
export interface BackgroundImportTask {
|
||||
id: string // server job id
|
||||
tripId: string
|
||||
label: string // file name(s) being parsed
|
||||
status: 'running' | 'done' | 'error'
|
||||
done: number
|
||||
total: number
|
||||
items?: BookingImportPreviewItem[]
|
||||
warnings?: string[]
|
||||
error?: string
|
||||
reviewRequested?: boolean // user clicked "review" — the trip page consumes it
|
||||
consumed?: boolean // review has been handed to the trip page
|
||||
/** The uploaded files this parse ran on — kept in memory so the review can attach the
|
||||
* source document to each created booking. Not persisted (a File can't survive a reload). */
|
||||
sourceFiles?: File[]
|
||||
}
|
||||
|
||||
interface BackgroundTasksState {
|
||||
tasks: BackgroundImportTask[]
|
||||
addTask: (task: { id: string; tripId: string; label: string; total: number; files?: File[] }) => void
|
||||
setProgress: (id: string, tripId: string, done: number, total: number) => void
|
||||
setDone: (id: string, tripId: string, items: BookingImportPreviewItem[], warnings: string[]) => void
|
||||
setError: (id: string, tripId: string, error: string) => void
|
||||
requestReview: (id: string) => void
|
||||
markConsumed: (id: string) => void
|
||||
dismiss: (id: string) => void
|
||||
}
|
||||
|
||||
export const useBackgroundTasksStore = create<BackgroundTasksState>()(
|
||||
persist(
|
||||
(set) => {
|
||||
/** Update an existing task by id, or insert a fresh one (events can arrive before addTask). */
|
||||
const upsert = (id: string, tripId: string, patch: Partial<BackgroundImportTask>) =>
|
||||
set((state) => {
|
||||
const idx = state.tasks.findIndex((t) => t.id === id)
|
||||
if (idx === -1) {
|
||||
const base: BackgroundImportTask = { id, tripId, label: 'Import', status: 'running', done: 0, total: 1 }
|
||||
return { tasks: [...state.tasks, { ...base, ...patch }] }
|
||||
}
|
||||
const tasks = state.tasks.slice()
|
||||
tasks[idx] = { ...tasks[idx], ...patch }
|
||||
return { tasks }
|
||||
})
|
||||
|
||||
return {
|
||||
tasks: [],
|
||||
addTask: ({ id, tripId, label, total, files }) => upsert(id, tripId, { label, total, status: 'running', done: 0, sourceFiles: files }),
|
||||
setProgress: (id, tripId, done, total) => upsert(id, tripId, { done, total, status: 'running' }),
|
||||
setDone: (id, tripId, items, warnings) => upsert(id, tripId, { status: 'done', items, warnings, done: items?.length ?? 0 }),
|
||||
setError: (id, tripId, error) => upsert(id, tripId, { status: 'error', error }),
|
||||
requestReview: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, reviewRequested: true } : t)) })),
|
||||
markConsumed: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, consumed: true, reviewRequested: false } : t)) })),
|
||||
dismiss: (id) => set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })),
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'trek.bg-import-tasks',
|
||||
// Persist only what survives a reload usefully: the job id/trip/label and a coarse
|
||||
// status. The widget re-fetches each job's real status (and parsed items) on mount,
|
||||
// so we keep neither the heavy `items`/`warnings` nor the transient review flags —
|
||||
// that also guarantees a reload never re-opens the review flow on its own.
|
||||
partialize: (state) => ({
|
||||
tasks: state.tasks
|
||||
.filter((t) => !t.consumed && t.status !== 'error')
|
||||
.map((t) => ({ id: t.id, tripId: t.tripId, label: t.label, status: t.status, done: t.done, total: t.total })),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -218,7 +218,7 @@
|
||||
opacity: .88; margin-bottom: 16px; font-weight: 500;
|
||||
}
|
||||
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
|
||||
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; }
|
||||
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); }
|
||||
|
||||
/* ----------------- boarding pass ----------------- */
|
||||
.trek-dash .hero-pass {
|
||||
@@ -422,7 +422,7 @@
|
||||
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
|
||||
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
|
||||
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
|
||||
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; }
|
||||
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
|
||||
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
|
||||
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
|
||||
.trek-dash .trip-body { padding: 18px 20px 20px; }
|
||||
@@ -456,6 +456,33 @@
|
||||
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
|
||||
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
|
||||
|
||||
/* Error banner — shown when the trip list or the auth check couldn't reach the
|
||||
server, so a backend/IdP outage no longer looks like an empty (lost-data)
|
||||
dashboard. Amber rather than red: it reassures (data is safe) more than it alarms. */
|
||||
.trek-dash .dash-error {
|
||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||
padding: 14px 18px; margin-bottom: 22px;
|
||||
background: oklch(0.74 0.14 75 / 0.13);
|
||||
border: 1px solid oklch(0.74 0.14 75 / 0.45);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .dash-error-txt { flex: 1; min-width: 200px; font-size: 14px; color: var(--ink); }
|
||||
.trek-dash .dash-error-retry {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 8px 14px; border: none; border-radius: var(--r-xs);
|
||||
background: var(--ink); color: var(--surface);
|
||||
font-size: 13px; font-weight: 500; cursor: pointer;
|
||||
transition: opacity .15s ease;
|
||||
}
|
||||
.trek-dash .dash-error-retry:hover { opacity: .88; }
|
||||
|
||||
/* Empty state — a genuine "you have no trips yet" message, visually distinct
|
||||
from the error banner above so an outage and a real empty list never look alike. */
|
||||
.trek-dash .trips-empty { margin-bottom: 18px; }
|
||||
.trek-dash .trips-empty h4 { font-size: 18px; font-weight: 600; color: var(--ink); margin: 0 0 6px; }
|
||||
.trek-dash .trips-empty p { font-size: 14px; color: var(--ink-3); margin: 0; }
|
||||
|
||||
/* ----------------- tools sidebar ----------------- */
|
||||
.trek-dash .tool {
|
||||
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
|
||||
|
||||
@@ -120,6 +120,13 @@ export interface Settings {
|
||||
mapbox_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
// AI booking-import fallback (per-user config; used when the admin has not set
|
||||
// instance-wide config on the llm_parsing addon). llm_api_key is masked on read.
|
||||
llm_provider?: 'local' | 'openai' | 'anthropic'
|
||||
llm_model?: string
|
||||
llm_base_url?: string
|
||||
llm_multimodal?: boolean
|
||||
llm_api_key?: string
|
||||
}
|
||||
|
||||
export interface AssignmentsMap {
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitReservationDateTime } from './formatters'
|
||||
import { splitReservationDateTime, resolveDayId } from './formatters'
|
||||
import type { Day } from '../types'
|
||||
|
||||
const days = [
|
||||
{ id: 10, date: '2026-05-03' },
|
||||
{ id: 11, date: '2026-05-04' },
|
||||
{ id: 12, date: '2026-05-22' },
|
||||
] as Day[]
|
||||
|
||||
describe('resolveDayId', () => {
|
||||
it('returns the exact-match day id', () => {
|
||||
expect(resolveDayId(days, '2026-05-04')).toBe(11)
|
||||
})
|
||||
it('accepts a full ISO timestamp', () => {
|
||||
expect(resolveDayId(days, '2026-05-22T13:30:00')).toBe(12)
|
||||
})
|
||||
it('falls back to the nearest day when there is no exact match', () => {
|
||||
expect(resolveDayId(days, '2026-05-05')).toBe(11)
|
||||
})
|
||||
it('returns "" for a missing/invalid date or no days', () => {
|
||||
expect(resolveDayId(days, null)).toBe('')
|
||||
expect(resolveDayId(days, 'not a date')).toBe('')
|
||||
expect(resolveDayId([], '2026-05-04')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitReservationDateTime', () => {
|
||||
it('parses full ISO datetime', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssignmentsMap } from '../types'
|
||||
import type { AssignmentsMap, Day } from '../types'
|
||||
|
||||
// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood,
|
||||
// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country".
|
||||
@@ -129,6 +129,27 @@ export function splitReservationDateTime(value?: string | null): { date: string
|
||||
return { date: null, time: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a date (YYYY-MM-DD or an ISO timestamp) to a trip day id: exact match, else the
|
||||
* nearest day so an out-of-range booking still lands on one. Returns '' when there is no
|
||||
* usable date or the trip has no days — callers read that as "no day selected".
|
||||
*/
|
||||
export function resolveDayId(days: Day[], value: string | null | undefined): Day['id'] | '' {
|
||||
const date = value ? String(value).slice(0, 10) : ''
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || days.length === 0) return ''
|
||||
const exact = days.find(d => d.date === date)
|
||||
if (exact) return exact.id
|
||||
const target = new Date(date).getTime()
|
||||
let best: Day['id'] | '' = ''
|
||||
let bestDiff = Infinity
|
||||
for (const d of days) {
|
||||
if (!d.date) continue
|
||||
const diff = Math.abs(new Date(d.date).getTime() - target)
|
||||
if (diff < bestDiff) { bestDiff = diff; best = d.id }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
||||
const da = assignments[String(dayId)] || []
|
||||
const total = da.reduce((s, a) => s + (parseFloat(String(a.place?.price ?? '')) || 0), 0)
|
||||
|
||||
@@ -6,13 +6,16 @@ import { buildAssignment, buildPlace } from '../../helpers/factories';
|
||||
import type { TripStoreState } from '../../../src/store/tripStore';
|
||||
import type { RouteSegment } from '../../../src/types';
|
||||
|
||||
// Mock the RouteCalculator module to avoid real OSRM fetch calls
|
||||
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
||||
calculateRouteWithLegs: vi.fn(),
|
||||
calculateRoute: vi.fn(),
|
||||
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
||||
generateGoogleMapsUrl: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/components/Map/RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../../../src/components/Map/RouteCalculator')>();
|
||||
return {
|
||||
...actual,
|
||||
calculateRouteWithLegs: vi.fn(),
|
||||
calculateRoute: vi.fn(),
|
||||
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
||||
generateGoogleMapsUrl: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock('../src/api/websocket', () => ({
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
// MSW lifecycle
|
||||
|
||||
Generated
+1471
-1456
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "3.0.22",
|
||||
"version": "3.1.2",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -30,7 +30,8 @@
|
||||
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
|
||||
"overrides": {
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
"react-dom": "19.2.6",
|
||||
"multer": "^2.2.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-musl": "4.62.0",
|
||||
@@ -38,4 +39,4 @@
|
||||
"@img/sharp-linuxmusl-x64": "0.35.1",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
|
||||
|
||||
DEMO_MODE=false # Demo mode - resets data hourly
|
||||
|
||||
# BACKUP_UPLOAD_LIMIT_MB=500 # Max size (MB) of a backup archive you can upload when restoring. Raise it if your backup exceeds 500 MB. If you sit behind a reverse proxy, raise its upload limit too (e.g. nginx client_max_body_size).
|
||||
|
||||
# MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
||||
# MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||
|
||||
|
||||
Binary file not shown.
+7
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "3.0.22",
|
||||
"version": "3.1.2",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --require tsconfig-paths/register dist/index.js",
|
||||
@@ -30,6 +30,7 @@
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"compression": "^1.8.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.1",
|
||||
@@ -38,10 +39,10 @@
|
||||
"helmet": "^8.1.0",
|
||||
"jimp": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.5",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
@@ -60,7 +61,7 @@
|
||||
"@hono/node-server": "^1.19.13",
|
||||
"picomatch": "^4.0.4",
|
||||
"ip-address": "^10.1.1",
|
||||
"multer": "^2.1.1",
|
||||
"multer": "^2.2.0",
|
||||
"ws": "^8.21.0",
|
||||
"qs": "^6.15.2",
|
||||
"file-type": "^21.3.4"
|
||||
@@ -73,6 +74,7 @@
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/compression": "^1.8.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.25",
|
||||
@@ -80,7 +82,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
|
||||
@@ -151,18 +151,37 @@ function normalizeAdm0Feature(f) {
|
||||
|
||||
function normalizeAdm1(geo, a3, countryName) {
|
||||
if (!geo?.features) return []
|
||||
const a2 = A3_TO_A2[a3] || null
|
||||
// Ensure every region in a country ends up with a distinct iso_3166_2 — the Atlas
|
||||
// marks/unmarks regions by this code, so duplicates make one mark light up the whole
|
||||
// country.
|
||||
const used = new Set()
|
||||
const uniq = (base) => {
|
||||
let code = base, n = 2
|
||||
while (used.has(code)) code = `${base}-${n++}`
|
||||
used.add(code)
|
||||
return code
|
||||
}
|
||||
return geo.features.map(f => {
|
||||
const name = f.properties?.shapeName || ''
|
||||
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
|
||||
if (!geometry) return null
|
||||
const a2 = A3_TO_A2[a3] || null
|
||||
// shapeISO is a real ISO 3166-2 code for ~90% of features; geoBoundaries leaves the
|
||||
// rest blank or uses an `XX_YYY` placeholder. Keep real/placeholder codes as-is
|
||||
// (stable per polygon → manual mark/unmark works, real ones match Nominatim). For
|
||||
// blank codes, synthesize a stable id mirroring the server's geocode fallback so
|
||||
// every region is still markable.
|
||||
let code = f.properties?.shapeISO || ''
|
||||
if (!code && a2) code = `${a2}-${name.replace(/[^A-Za-z0-9]/g, '').substring(0, 3).toUpperCase()}`
|
||||
// shapeISO is a real ISO 3166-2 code for most features, but geoBoundaries sometimes
|
||||
// fills it with the bare country code instead of a subdivision code — e.g. every
|
||||
// Spanish region gets "ESP", every Chinese "CHN" (also CL/OM). Keep it only when it
|
||||
// is a real `XX-…` subdivision code and not already taken; otherwise synthesize a
|
||||
// stable, unique-per-country id from the region name so each region is independently
|
||||
// markable.
|
||||
const raw = f.properties?.shapeISO || ''
|
||||
let code
|
||||
if (/^[A-Za-z]{2}-[A-Za-z0-9]+$/.test(raw) && !used.has(raw)) {
|
||||
code = raw
|
||||
used.add(code)
|
||||
} else if (a2) {
|
||||
code = uniq(`${a2}-${name.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || 'RGN'}`)
|
||||
} else {
|
||||
code = raw
|
||||
}
|
||||
return {
|
||||
type: 'Feature',
|
||||
// Property names the Atlas region layer + server getRegionGeo already read.
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ADDON_IDS = {
|
||||
COLLAB: 'collab',
|
||||
JOURNEY: 'journey',
|
||||
AIRTRAIL: 'airtrail',
|
||||
LLM_PARSING: 'llm_parsing',
|
||||
} as const;
|
||||
|
||||
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
|
||||
|
||||
+56
-36
@@ -1,39 +1,11 @@
|
||||
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
|
||||
|
||||
const dataDir = path.resolve(__dirname, '../data');
|
||||
|
||||
// JWT_SECRET is always managed by the server — auto-generated on first start and
|
||||
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
|
||||
// via environment variable (env var would override a rotation on next restart).
|
||||
const jwtSecretFile = path.join(dataDir, '.jwt_secret');
|
||||
let _jwtSecret: string;
|
||||
|
||||
try {
|
||||
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
|
||||
} catch {
|
||||
_jwtSecret = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
|
||||
console.log('Generated and saved JWT secret to', jwtSecretFile);
|
||||
} catch (writeErr: unknown) {
|
||||
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
|
||||
console.warn('Sessions will reset on server restart.');
|
||||
}
|
||||
}
|
||||
|
||||
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
|
||||
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
|
||||
export let JWT_SECRET = _jwtSecret;
|
||||
|
||||
// Called by the admin rotate-jwt-secret endpoint to update the in-process
|
||||
// binding that all middleware and route files reference.
|
||||
export function updateJwtSecret(newSecret: string): void {
|
||||
JWT_SECRET = newSecret;
|
||||
}
|
||||
|
||||
// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets
|
||||
// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.).
|
||||
@@ -93,18 +65,55 @@ if (_encryptionKey) {
|
||||
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
|
||||
console.log('Encryption key persisted to', encKeyFile);
|
||||
} catch (writeErr: unknown) {
|
||||
console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
|
||||
console.warn(
|
||||
'WARNING: Could not persist encryption key to disk:',
|
||||
writeErr instanceof Error ? writeErr.message : writeErr,
|
||||
);
|
||||
console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
|
||||
}
|
||||
}
|
||||
|
||||
export const ENCRYPTION_KEY = _encryptionKey;
|
||||
|
||||
// JWT_SECRET is always managed by the server — auto-generated on first start and
|
||||
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
|
||||
// via environment variable (env var would override a rotation on next restart).
|
||||
let _jwtSecret: string;
|
||||
|
||||
try {
|
||||
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
|
||||
} catch {
|
||||
_jwtSecret = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
|
||||
console.log('Generated and saved JWT secret to', jwtSecretFile);
|
||||
} catch (writeErr: unknown) {
|
||||
console.warn(
|
||||
'WARNING: Could not persist JWT secret to disk:',
|
||||
writeErr instanceof Error ? writeErr.message : writeErr,
|
||||
);
|
||||
console.warn('Sessions will reset on server restart.');
|
||||
}
|
||||
}
|
||||
|
||||
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
|
||||
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
|
||||
export let JWT_SECRET = _jwtSecret;
|
||||
|
||||
// Called by the admin rotate-jwt-secret endpoint to update the in-process
|
||||
// binding that all middleware and route files reference.
|
||||
export function updateJwtSecret(newSecret: string): void {
|
||||
JWT_SECRET = newSecret;
|
||||
}
|
||||
|
||||
// DEFAULT_LANGUAGE sets the language shown on the login page before the user
|
||||
// selects one. Only applies when the user has no saved language preference.
|
||||
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
|
||||
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
||||
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
||||
console.warn(
|
||||
`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
|
||||
|
||||
@@ -116,7 +125,13 @@ export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ?
|
||||
// challenge token or MCP OAuth tokens — those keep their own TTL.
|
||||
const DEFAULT_SESSION_DURATION = '24h';
|
||||
const DURATION_UNITS_MS: Record<string, number> = {
|
||||
ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000, y: 31_557_600_000,
|
||||
ms: 1,
|
||||
s: 1000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
w: 604_800_000,
|
||||
y: 31_557_600_000,
|
||||
};
|
||||
function parseDurationMs(value: string): number | null {
|
||||
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
|
||||
@@ -128,7 +143,9 @@ function parseDurationMs(value: string): number | null {
|
||||
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
|
||||
const parsedSessionMs = parseDurationMs(rawSessionDuration);
|
||||
if (parsedSessionMs == null) {
|
||||
console.warn(`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`);
|
||||
console.warn(
|
||||
`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`,
|
||||
);
|
||||
}
|
||||
/** Human-readable session length actually in effect (for logs/diagnostics). */
|
||||
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
|
||||
@@ -146,10 +163,13 @@ const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
|
||||
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
|
||||
const parsedRememberMs = parseDurationMs(rawRememberDuration);
|
||||
if (parsedRememberMs == null) {
|
||||
console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
|
||||
console.warn(
|
||||
`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`,
|
||||
);
|
||||
}
|
||||
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
|
||||
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
|
||||
export const SESSION_DURATION_REMEMBER =
|
||||
parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
|
||||
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
|
||||
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
|
||||
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
|
||||
|
||||
@@ -3045,6 +3045,15 @@ function runMigrations(db: Database.Database): void {
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)',
|
||||
);
|
||||
},
|
||||
() => {
|
||||
// Per-user opt-in for writing TREK edits back to AirTrail (#1240). Default
|
||||
// off: AirTrail is the source of truth and TREK never writes unless asked.
|
||||
try {
|
||||
db.exec('ALTER TABLE users ADD COLUMN airtrail_write_enabled INTEGER DEFAULT 0');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -18,6 +18,11 @@ function seedAdminAccount(db: Database.Database): void {
|
||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||
if (userCount > 0) return;
|
||||
|
||||
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
|
||||
// Creating a first-run admin here would grab username 'admin' first and make the demo
|
||||
// seeder fail on the UNIQUE(username) constraint, leaving the demo user uncreated.
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true') return;
|
||||
|
||||
if (isOidcOnlyConfigured()) {
|
||||
console.log('');
|
||||
console.log('╔══════════════════════════════════════════════╗');
|
||||
@@ -99,6 +104,7 @@ function seedAddons(db: Database.Database): void {
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
||||
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your self-hosted AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
|
||||
{ id: 'llm_parsing', name: 'AI Parsing', description: 'LLM fallback for booking imports kitinerary cannot read', type: 'integration', icon: 'Sparkles', enabled: 0, sort_order: 15 },
|
||||
];
|
||||
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||
|
||||
@@ -66,6 +66,17 @@ export function hasTripPermission(action: string, tripId: number | string, userI
|
||||
return checkPermission(action, userRow?.role ?? 'user', tripOwnerId, userId, tripOwnerId !== userId);
|
||||
}
|
||||
|
||||
/** True when the user has the global admin role (mirrors REST `user.role === 'admin'` gates). */
|
||||
export function isAdminUser(userId: number): boolean {
|
||||
const userRow = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
|
||||
return userRow?.role === 'admin';
|
||||
}
|
||||
|
||||
/** Error response for admin-only tools, reproducing the REST `{ error: 'Admin access required' }` string. */
|
||||
export function adminRequired() {
|
||||
return { content: [{ type: 'text' as const, text: 'Admin access required' }], isError: true };
|
||||
}
|
||||
|
||||
export function ok(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
@@ -136,7 +136,11 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
|
||||
async ({ regionCode, regionName, countryCode }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
markRegionVisited(userId, regionCode, regionName, countryCode);
|
||||
const region = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
|
||||
const row = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
|
||||
// Echo in the client-facing shape ({ code, name, ... }) rather than raw DB columns.
|
||||
const region = row
|
||||
? { code: row.region_code, name: row.region_name, country_code: row.country_code, manuallyMarked: true }
|
||||
: undefined;
|
||||
return ok({ region });
|
||||
}
|
||||
);
|
||||
|
||||
+160
-25
@@ -5,18 +5,42 @@ import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
||||
updateMembers as updateBudgetMembers,
|
||||
toggleMemberPaid,
|
||||
toggleMemberPaid, getBudgetItem,
|
||||
calculateSettlement, listSettlements, createSettlement, updateSettlement, deleteSettlement,
|
||||
} from '../../services/budgetService';
|
||||
import { getRates } from '../../services/exchangeRateService';
|
||||
import { getTripOwner, listMembers } from '../../services/tripService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
/** Reusable Zod shape for the per-payer amounts on a budget item. */
|
||||
const payersSchema = z.array(z.object({
|
||||
user_id: z.number().int().positive(),
|
||||
amount: z.number().nonnegative(),
|
||||
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
/**
|
||||
* Resolve the equal-split participants for a new budget item. When member_ids is
|
||||
* omitted, default to the whole trip (owner + all members), deduped — reproducing
|
||||
* the client's own create flow (CostsPanel seeds participants from all members).
|
||||
* An explicit empty array means "planning-only, no split" and is passed through.
|
||||
*/
|
||||
function resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
|
||||
if (member_ids !== undefined) return member_ids;
|
||||
const owner = getTripOwner(tripId);
|
||||
if (!owner) return undefined;
|
||||
const { members } = listMembers(tripId, owner.user_id);
|
||||
return Array.from(new Set([owner.user_id, ...members.map(m => m.id)]));
|
||||
}
|
||||
|
||||
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'budget');
|
||||
const W = canWrite(scopes, 'budget');
|
||||
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||
@@ -25,21 +49,26 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (W) server.registerTool(
|
||||
'create_budget_item',
|
||||
{
|
||||
description: 'Add a budget/expense item to a trip.',
|
||||
description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split). Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid — resolve user IDs with list_trip_members — rather than guessing.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
total_price: z.number().nonnegative(),
|
||||
currency: z.string().max(10).nullable().optional().describe('ISO currency code (e.g. "EUR"); defaults to the trip currency'),
|
||||
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense. Omit to split across all trip members (owner + members); pass [] for no split.'),
|
||||
payers: payersSchema.optional().describe('Who paid how much, in the expense currency. When given, total_price is derived from the sum. Ask the user; do not guess.'),
|
||||
expense_date: z.string().max(40).nullable().optional().describe('Date the expense occurred, YYYY-MM-DD'),
|
||||
note: z.string().max(500).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, category, total_price, note }) => {
|
||||
async ({ tripId, name, category, total_price, currency, member_ids, payers, expense_date, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||
const members = resolveMemberIds(tripId, member_ids);
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, currency, member_ids: members, payers, expense_date, note });
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
@@ -71,24 +100,26 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (W) server.registerTool(
|
||||
'update_budget_item',
|
||||
{
|
||||
description: 'Update an existing budget/expense item in a trip.',
|
||||
description: 'Update an existing budget/expense item in a trip. You can also re-split it via member_ids and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
total_price: z.number().nonnegative().optional(),
|
||||
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense; replaces the current split. Omit to leave unchanged, pass [] for no split.'),
|
||||
payers: payersSchema.optional().describe('Replaces who paid how much, in the expense currency. Omit to leave unchanged. Ask the user; do not guess.'),
|
||||
persons: z.number().int().positive().nullable().optional(),
|
||||
days: z.number().int().positive().nullable().optional(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
|
||||
async ({ tripId, itemId, name, category, total_price, member_ids, payers, persons, days, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
|
||||
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, member_ids, payers, persons, days, note });
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:updated', { item });
|
||||
return ok({ item });
|
||||
@@ -100,14 +131,14 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (W) server.registerTool(
|
||||
'create_budget_item_with_members',
|
||||
{
|
||||
description: 'Create a budget/expense item and optionally set the trip members splitting it in one atomic operation. If userIds is omitted or empty, behaves like create_budget_item. Only use when the place does not yet exist — if it already exists, use set_budget_item_members directly.',
|
||||
description: 'Create a budget/expense item and set the trip members splitting it in one atomic operation. If userIds is omitted, the cost is split across all trip members; pass an explicit list to split among a subset, or an empty array for a planning-only entry with no split. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist — if it already exists, use set_budget_item_members directly.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
total_price: z.number().nonnegative(),
|
||||
note: z.string().max(500).optional(),
|
||||
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit or pass empty array to skip member assignment'),
|
||||
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit to split across all trip members, or pass an empty array for no split'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
@@ -115,19 +146,16 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const hasMembers = userIds && userIds.length > 0;
|
||||
// Omitted userIds → default to the whole trip, matching create_budget_item.
|
||||
const members = (userIds && userIds.length > 0) ? userIds : resolveMemberIds(tripId, undefined);
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||
if (hasMembers) {
|
||||
return updateBudgetMembers(item.id, tripId, userIds!);
|
||||
}
|
||||
return { item };
|
||||
});
|
||||
const result = run();
|
||||
safeBroadcast(tripId, 'budget:created', { item: (result as any).item ?? result });
|
||||
if (hasMembers) safeBroadcast(tripId, 'budget:members-updated', { item: result });
|
||||
return ok({ item: result });
|
||||
const item = db.transaction(() => {
|
||||
const created = createBudgetItem(tripId, { category, name, total_price, note, member_ids: members });
|
||||
return getBudgetItem(created.id, tripId)!;
|
||||
})();
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
if (members && members.length > 0) safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||
return ok({ item });
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
|
||||
}
|
||||
@@ -137,7 +165,7 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (W) server.registerTool(
|
||||
'set_budget_item_members',
|
||||
{
|
||||
description: 'Set which trip members are splitting a budget item (replaces current member list).',
|
||||
description: 'Set which trip members are splitting a budget item (replaces current member list). Ask the user which members share the expense; resolve user IDs with list_trip_members.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
@@ -149,7 +177,9 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const item = updateBudgetMembers(itemId, tripId, userIds);
|
||||
const result = updateBudgetMembers(itemId, tripId, userIds);
|
||||
if (!result) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
const item = getBudgetItem(itemId, tripId);
|
||||
safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
@@ -176,5 +206,110 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
return ok({ member });
|
||||
}
|
||||
);
|
||||
|
||||
// --- SETTLEMENTS (settle-up payments between members) ---
|
||||
|
||||
if (R) server.registerTool(
|
||||
'get_settlement_summary',
|
||||
{
|
||||
description: "See each member's net balance and the suggested payments to settle shared expenses. Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId, base }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?').get(tripId) as { currency?: string } | undefined;
|
||||
const tripCurrency = trip?.currency || 'EUR';
|
||||
const effectiveBase = (base || tripCurrency).toUpperCase();
|
||||
const rates = await getRates(effectiveBase);
|
||||
const summary = calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
return ok({ summary });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_settlements',
|
||||
{
|
||||
description: 'List the recorded settle-up payments for a trip (who paid whom, how much, when).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
return ok({ settlements: listSettlements(tripId) });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_settlement',
|
||||
{
|
||||
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount (in the trip's base currency) to settle shared expenses. Use get_settlement_summary first to find who owes whom and how much.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
|
||||
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
|
||||
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, from_user_id, to_user_id, amount }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const settlement = createSettlement(tripId, { from_user_id, to_user_id, amount }, userId);
|
||||
safeBroadcast(tripId, 'budget:settlement-created', { settlement });
|
||||
return ok({ settlement });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_settlement',
|
||||
{
|
||||
description: 'Update a recorded settle-up payment (who paid, who received, and the amount).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
settlementId: z.number().int().positive(),
|
||||
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
|
||||
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
|
||||
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, settlementId, from_user_id, to_user_id, amount }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const settlement = updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount });
|
||||
if (!settlement) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
|
||||
return ok({ settlement });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_settlement',
|
||||
{
|
||||
description: 'Delete a recorded settle-up payment. This is the undo for create_settlement and restores the affected balances.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
settlementId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, settlementId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const deleted = deleteSettlement(settlementId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
} // isAddonEnabled(BUDGET)
|
||||
}
|
||||
|
||||
@@ -99,19 +99,20 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
||||
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
|
||||
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
|
||||
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
safeBroadcast(tripId, 'accommodation:created', { accommodation });
|
||||
return ok({ accommodation });
|
||||
}
|
||||
@@ -137,6 +138,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
||||
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
||||
@@ -145,7 +147,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
@@ -154,7 +156,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
|
||||
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
|
||||
return { place, accommodation };
|
||||
});
|
||||
const result = run();
|
||||
@@ -178,19 +180,20 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
start_day_id: z.number().int().positive().optional(),
|
||||
end_day_id: z.number().int().positive().optional(),
|
||||
check_in: z.string().max(10).optional(),
|
||||
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
|
||||
check_out: z.string().max(10).optional(),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
|
||||
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
|
||||
const existing = getAccommodation(accommodationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
safeBroadcast(tripId, 'accommodation:updated', { accommodation });
|
||||
return ok({ accommodation });
|
||||
}
|
||||
@@ -227,7 +230,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
text: z.string().min(1).max(500),
|
||||
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
||||
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
@@ -252,7 +255,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
dayId: z.number().int().positive(),
|
||||
noteId: z.number().int().positive(),
|
||||
text: z.string().min(1).max(500).optional(),
|
||||
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
||||
time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
|
||||
@@ -136,7 +136,9 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
||||
async ({ title, subtitle, trip_ids }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const journey = createJourney(userId, { title, subtitle, trip_ids });
|
||||
return ok({ journey });
|
||||
// Return the fully-hydrated journey (entries/contributors/trips/stats/my_role),
|
||||
// matching get_journey, rather than the bare row.
|
||||
return ok({ journey: getJourneyFull(journey.id, userId) ?? journey });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -233,7 +235,9 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
|
||||
if (!entry) return notFound('Journey not found or access denied.');
|
||||
return ok({ entry });
|
||||
// Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name).
|
||||
const enriched = listEntries(journeyId, userId)?.find(e => e.id === entry.id) ?? entry;
|
||||
return ok({ entry: enriched });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -255,7 +259,9 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
|
||||
if (!entry) return notFound('Entry not found or access denied.');
|
||||
return ok({ entry });
|
||||
// Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry.
|
||||
const enriched = listEntries(entry.journey_id, userId)?.find(e => e.id === entry.id) ?? entry;
|
||||
return ok({ entry: enriched });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -364,7 +370,8 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
|
||||
if (!result) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
// Return the service result ({ hide_skeletons }), matching the REST route.
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -9,15 +9,16 @@ import {
|
||||
listBags, createBag, updateBag, deleteBag, setBagMembers,
|
||||
getCategoryAssignees as getPackingCategoryAssignees,
|
||||
updateCategoryAssignees as updatePackingCategoryAssignees,
|
||||
applyTemplate, saveAsTemplate, bulkImport,
|
||||
applyTemplate, saveAsTemplate, listTemplates, bulkImport,
|
||||
} from '../../services/packingService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
isAdminUser, adminRequired,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled, deletePackingTemplate } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
@@ -171,7 +172,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const bag = createBag(tripId, { name, color });
|
||||
// createBag returns a bare row; hydrate with the empty members array that
|
||||
// listBags and the schema always carry, so the client/AI consumer matches.
|
||||
const bag = { ...(createBag(tripId, { name, color }) as object), members: [] };
|
||||
safeBroadcast(tripId, 'packing:bag-created', { bag });
|
||||
return ok({ bag });
|
||||
}
|
||||
@@ -197,7 +200,10 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
const bodyKeys: string[] = [];
|
||||
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
||||
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
|
||||
const bag = updateBag(tripId, bagId, fields, bodyKeys);
|
||||
const updated = updateBag(tripId, bagId, fields, bodyKeys);
|
||||
if (!updated) return { content: [{ type: 'text' as const, text: 'Bag not found.' }], isError: true };
|
||||
// Hydrate with the members array (matches create_packing_bag, listBags, and the schema).
|
||||
const bag = listBags(tripId).find(b => b.id === (updated as { id: number }).id) ?? { ...(updated as object), members: [] };
|
||||
safeBroadcast(tripId, 'packing:bag-updated', { bag });
|
||||
return ok({ bag });
|
||||
}
|
||||
@@ -238,9 +244,10 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
setBagMembers(tripId, bagId, userIds);
|
||||
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
|
||||
return ok({ success: true });
|
||||
const members = setBagMembers(tripId, bagId, userIds);
|
||||
if (!members) return { content: [{ type: 'text' as const, text: 'Bag not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members });
|
||||
return ok({ members });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -275,9 +282,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
||||
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
|
||||
return ok({ success: true });
|
||||
const assignees = updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
||||
safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees });
|
||||
return ok({ assignees });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -295,17 +302,32 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const applied = applyTemplate(tripId, templateId);
|
||||
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:template-applied', { templateId });
|
||||
return ok({ success: true });
|
||||
const items = applyTemplate(tripId, templateId);
|
||||
if (items === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:template-applied', { items });
|
||||
return ok({ items, count: items.length });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_packing_templates',
|
||||
{
|
||||
description: 'List the reusable packing templates (id, name, item count) so one can be applied with apply_packing_template.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
return ok({ templates: listTemplates() });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'save_packing_template',
|
||||
{
|
||||
description: 'Save the current packing list as a reusable template.',
|
||||
description: 'Save the current packing list as a reusable template. Returns the new template (id, name, category/item counts). Admin only.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
templateName: z.string().min(1).max(100),
|
||||
@@ -316,21 +338,46 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
saveAsTemplate(tripId, userId, templateName);
|
||||
return ok({ success: true });
|
||||
// Templates are global; the REST route restricts saving to admins. Match it.
|
||||
if (!isAdminUser(userId)) return adminRequired();
|
||||
const template = saveAsTemplate(tripId, userId, templateName);
|
||||
if (!template) return { content: [{ type: 'text' as const, text: 'Nothing to save — the packing list is empty.' }], isError: true };
|
||||
return ok({ template });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_packing_template',
|
||||
{
|
||||
description: 'Delete a reusable packing template. Templates are global, so deletion is admin only.',
|
||||
inputSchema: {
|
||||
templateId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ templateId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
// Templates are global; the REST route restricts management to admins. Match it.
|
||||
if (!isAdminUser(userId)) return adminRequired();
|
||||
const result = deletePackingTemplate(String(templateId));
|
||||
if ('error' in result) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
return ok({ success: true, name: result.name });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'bulk_import_packing',
|
||||
{
|
||||
description: 'Import multiple packing items at once from a list.',
|
||||
description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
items: z.array(z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().optional(),
|
||||
quantity: z.number().int().positive().optional(),
|
||||
bag: z.string().max(100).optional().describe('Bag name to assign the item to; created if it does not exist'),
|
||||
weight_grams: z.number().nonnegative().optional(),
|
||||
checked: z.boolean().optional(),
|
||||
})).min(1),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
@@ -339,9 +386,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
bulkImport(tripId, items);
|
||||
safeBroadcast(tripId, 'packing:updated', {});
|
||||
return ok({ success: true, count: items.length });
|
||||
const created = bulkImport(tripId, items);
|
||||
for (const item of created) safeBroadcast(tripId, 'packing:created', { item });
|
||||
return ok({ items: created, count: created.length });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
|
||||
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
|
||||
return ok({ reservation, accommodation_id: (reservation as any)?.accommodation_id ?? null });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createReservation, deleteReservation, getReservation, updateReservation,
|
||||
type EndpointInput,
|
||||
} from '../../services/reservationService';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { findByIata } from '../../services/airportService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
@@ -15,17 +17,56 @@ import { canWrite } from '../scopes';
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
|
||||
|
||||
const endpointSchema = z.array(z.object({
|
||||
const endpointObjectSchema = z.object({
|
||||
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
|
||||
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
|
||||
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
|
||||
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
lat: z.number().optional().describe('Latitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'),
|
||||
lng: z.number().optional().describe('Longitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'),
|
||||
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
|
||||
local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'),
|
||||
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
|
||||
})).optional();
|
||||
});
|
||||
const endpointSchema = z.array(endpointObjectSchema).optional();
|
||||
|
||||
type Endpoint = z.infer<typeof endpointObjectSchema>;
|
||||
|
||||
/**
|
||||
* Endpoint coordinates are stored NOT NULL. Callers may supply a flight endpoint
|
||||
* with only an IATA `code` (the tool description encourages this), so fill missing
|
||||
* lat/lng/timezone from the airport database. Returns an error string for the first
|
||||
* endpoint that can't be resolved rather than letting the NOT NULL bind throw.
|
||||
*
|
||||
* Normalizes to the service's EndpointInput shape (nullable fields coerced from the
|
||||
* schema's optionals), so lat/lng are guaranteed present before the insert.
|
||||
*/
|
||||
function resolveEndpointCoords(endpoints: Endpoint[] | undefined): { endpoints: EndpointInput[] } | { error: string } {
|
||||
if (!endpoints) return { endpoints: [] };
|
||||
const out: EndpointInput[] = [];
|
||||
for (const e of endpoints) {
|
||||
const base = {
|
||||
role: e.role,
|
||||
sequence: e.sequence,
|
||||
name: e.name,
|
||||
code: e.code ?? null,
|
||||
timezone: e.timezone ?? null,
|
||||
local_time: e.local_time ?? null,
|
||||
local_date: e.local_date ?? null,
|
||||
};
|
||||
if (e.lat != null && e.lng != null) { out.push({ ...base, lat: e.lat, lng: e.lng }); continue; }
|
||||
if (e.code) {
|
||||
const airport = findByIata(e.code);
|
||||
if (airport) {
|
||||
out.push({ ...base, lat: airport.lat, lng: airport.lng, timezone: e.timezone ?? airport.tz });
|
||||
continue;
|
||||
}
|
||||
return { error: `Could not resolve airport code "${e.code}". Use search_airports to find a valid IATA code, or supply lat/lng directly.` };
|
||||
}
|
||||
return { error: `Endpoint "${e.name}" is missing coordinates. For flights set "code" to the IATA airport code; for other transport types supply lat/lng.` };
|
||||
}
|
||||
return { endpoints: out };
|
||||
}
|
||||
|
||||
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
if (!canWrite(scopes, 'reservations')) return;
|
||||
@@ -63,6 +104,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const resolved = resolveEndpointCoords(endpoints);
|
||||
if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true };
|
||||
|
||||
const meta: Record<string, string> = { ...(metadata ?? {}) };
|
||||
if (price != null) meta.price = String(price);
|
||||
|
||||
@@ -78,7 +122,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
end_day_id: end_day_id ?? start_day_id,
|
||||
status: status ?? 'pending',
|
||||
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
||||
endpoints,
|
||||
endpoints: resolved.endpoints,
|
||||
needs_review,
|
||||
});
|
||||
|
||||
@@ -135,6 +179,14 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
// Only resolve when endpoints are explicitly provided; undefined leaves them untouched.
|
||||
let resolvedEndpoints: EndpointInput[] | undefined;
|
||||
if (endpoints !== undefined) {
|
||||
const resolved = resolveEndpointCoords(endpoints);
|
||||
if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true };
|
||||
resolvedEndpoints = resolved.endpoints;
|
||||
}
|
||||
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
title,
|
||||
type,
|
||||
@@ -146,7 +198,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
end_day_id,
|
||||
status,
|
||||
metadata,
|
||||
endpoints,
|
||||
endpoints: resolvedEndpoints,
|
||||
needs_review,
|
||||
}, existing);
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
|
||||
@@ -55,8 +55,10 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
|
||||
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
|
||||
return ok({ success: true });
|
||||
// updatePlan already returns the fully-hydrated { plan }; surface it so the
|
||||
// AI consumer sees the updated plan, matching get_vacay_plan.
|
||||
const result = await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -73,7 +75,8 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
setUserColor(userId, planId, color, undefined);
|
||||
return ok({ success: true });
|
||||
// Echo the persisted color (mirrors the service default) so the AI consumer sees what was set.
|
||||
return ok({ success: true, color: color || '#6366f1' });
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
@@ -28,6 +29,21 @@ export function applyGlobalMiddleware(
|
||||
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
||||
}
|
||||
|
||||
// Compress responses (gzip via Accept-Encoding). The Atlas admin-0 country
|
||||
// GeoJSON is ~30 MB uncompressed, which stalls/aborts (~8s → net::ERR_FAILED)
|
||||
// behind reverse proxies and Cloudflare Tunnel (#1254); gzip brings it to ~4 MB.
|
||||
// SSE responses (the /mcp StreamableHTTP transport) must NOT be buffered, so
|
||||
// they are excluded explicitly.
|
||||
app.use(
|
||||
compression({
|
||||
filter: (req, res) => {
|
||||
const type = res.getHeader('Content-Type');
|
||||
if (typeof type === 'string' && type.includes('text/event-stream')) return false;
|
||||
return compression.filter(req, res);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||
: null;
|
||||
@@ -103,7 +119,9 @@ export function applyGlobalMiddleware(
|
||||
workerSrc: ["'self'", "blob:"],
|
||||
childSrc: ["'self'", "blob:"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
objectSrc: ["'none'"],
|
||||
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
|
||||
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
|
||||
objectSrc: ["'self'"],
|
||||
frameSrc: ["'none'"],
|
||||
frameAncestors: ["'self'"],
|
||||
// Restrict <form> submission targets (form-action has no default-src
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Param,
|
||||
Headers,
|
||||
@@ -15,7 +16,9 @@ import type { User } from '../../types';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { BookingImportService } from './booking-import.service';
|
||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse } from '@trek/shared';
|
||||
import { ImportJobsService } from './import-jobs.service';
|
||||
import { bookingImportModeSchema } from '@trek/shared';
|
||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode } from '@trek/shared';
|
||||
|
||||
const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']);
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
@@ -29,7 +32,10 @@ const UPLOAD = {
|
||||
@Controller('api/trips/:tripId/reservations/import')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class BookingImportController {
|
||||
constructor(private readonly bookingImport: BookingImportService) {}
|
||||
constructor(
|
||||
private readonly bookingImport: BookingImportService,
|
||||
private readonly importJobs: ImportJobsService,
|
||||
) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.bookingImport.verifyTripAccess(tripId, user.id);
|
||||
@@ -43,6 +49,31 @@ export class BookingImportController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared validation for both the sync and async import endpoints; returns the parsed mode. */
|
||||
private validateImport(tripId: string, user: User, files: Express.Multer.File[] | undefined, rawMode?: string): BookingImportMode {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
|
||||
const modeResult = bookingImportModeSchema.safeParse(rawMode ?? 'no-ai');
|
||||
if (!modeResult.success) throw new HttpException({ error: 'Invalid mode' }, 400);
|
||||
const mode = modeResult.data;
|
||||
|
||||
if (mode === 'force-ai' && !this.bookingImport.aiAvailable(user.id)) {
|
||||
throw new HttpException({ error: 'AI parsing is not configured' }, 409);
|
||||
}
|
||||
if (mode === 'no-ai' && !this.bookingImport.isAvailable()) {
|
||||
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
|
||||
}
|
||||
if (!files || files.length === 0) throw new HttpException({ error: 'No files uploaded' }, 400);
|
||||
for (const f of files) {
|
||||
const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.'));
|
||||
if (!ACCEPTED_EXTS.has(ext)) {
|
||||
throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400);
|
||||
}
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/trips/:tripId/reservations/import/booking
|
||||
* Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT).
|
||||
@@ -54,28 +85,42 @@ export class BookingImportController {
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
@Body('mode') rawMode?: string,
|
||||
): Promise<BookingImportPreviewResponse> {
|
||||
const mode = this.validateImport(tripId, user, files, rawMode);
|
||||
return this.bookingImport.preview(files!, mode, user.id);
|
||||
}
|
||||
|
||||
if (!this.bookingImport.isAvailable()) {
|
||||
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
|
||||
}
|
||||
/**
|
||||
* POST /api/trips/:tripId/reservations/import/booking/async
|
||||
* Same input as /booking, but returns a job id immediately and parses in the
|
||||
* background. Progress + completion are pushed over the user's WebSocket
|
||||
* (import:progress / import:done / import:error). Lets the upload modal close at
|
||||
* once and a background widget track the work while the user keeps navigating.
|
||||
*/
|
||||
@Post('booking/async')
|
||||
@UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD))
|
||||
async previewAsync(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||
@Body('mode') rawMode?: string,
|
||||
): Promise<{ jobId: string }> {
|
||||
const mode = this.validateImport(tripId, user, files, rawMode);
|
||||
const jobId = this.importJobs.start(tripId, files!, mode, user.id);
|
||||
return { jobId };
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
throw new HttpException({ error: 'No files uploaded' }, 400);
|
||||
}
|
||||
|
||||
// Validate extensions
|
||||
for (const f of files) {
|
||||
const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.'));
|
||||
if (!ACCEPTED_EXTS.has(ext)) {
|
||||
throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const result: BookingImportPreviewResponse = await this.bookingImport.preview(files);
|
||||
return result;
|
||||
/**
|
||||
* GET /api/trips/:tripId/reservations/import/jobs/:jobId
|
||||
* Poll a background import job — recovery path for a client that missed the
|
||||
* WebSocket push (navigation, reconnect). 404 once the job has expired.
|
||||
*/
|
||||
@Get('jobs/:jobId')
|
||||
async jobStatus(@CurrentUser() user: User, @Param('jobId') jobId: string) {
|
||||
const job = this.importJobs.get(jobId, user.id);
|
||||
if (!job) throw new HttpException({ error: 'Job not found' }, 404);
|
||||
return { status: job.status, done: job.done, total: job.total, result: job.result, error: job.error };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BookingImportController } from './booking-import.controller';
|
||||
import { BookingImportService } from './booking-import.service';
|
||||
import { ImportJobsService } from './import-jobs.service';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
import { FeaturesController } from './features.controller';
|
||||
import { LlmParseModule } from '../llm-parse/llm-parse.module';
|
||||
|
||||
@Module({
|
||||
imports: [LlmParseModule],
|
||||
controllers: [BookingImportController, FeaturesController],
|
||||
providers: [BookingImportService, KitineraryExtractorService],
|
||||
providers: [BookingImportService, KitineraryExtractorService, ImportJobsService],
|
||||
})
|
||||
export class BookingImportModule {}
|
||||
|
||||
@@ -4,30 +4,47 @@ import { checkPermission } from '../../services/permissions';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { createReservation } from '../../services/reservationService';
|
||||
import { createPlace } from '../../services/placeService';
|
||||
import { createBudgetItem } from '../../services/budgetService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { searchNominatim } from '../../services/mapsService';
|
||||
import { db } from '../../db/database';
|
||||
import type { User } from '../../types';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
import { LlmParseService } from '../llm-parse/llm-parse.service';
|
||||
import { mapReservations } from './kitinerary-mapper';
|
||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, Reservation } from '@trek/shared';
|
||||
import type { ParsedBookingItem } from './kitinerary.types';
|
||||
import { typeToCostCategory } from '@trek/shared';
|
||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode, BookingImportFileReport, Reservation } from '@trek/shared';
|
||||
import type { ParsedBookingItem, KiReservation } from './kitinerary.types';
|
||||
|
||||
function resolveDayId(tripId: string, iso: string | null | undefined): number | null {
|
||||
if (!iso) return null;
|
||||
const date = iso.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
const row = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
return row?.id ?? null;
|
||||
const exact = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
if (exact) return exact.id;
|
||||
// Clamp to the nearest trip day so an out-of-range / unmatched check-in still
|
||||
// resolves and the accommodation row is inserted.
|
||||
const nearest = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
return nearest?.id ?? null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingImportService {
|
||||
constructor(private readonly extractor: KitineraryExtractorService) {}
|
||||
constructor(
|
||||
private readonly extractor: KitineraryExtractorService,
|
||||
private readonly llmParse: LlmParseService,
|
||||
) {}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.extractor.isAvailable();
|
||||
}
|
||||
|
||||
/** True when the LLM fallback is enabled and configured for this user. */
|
||||
aiAvailable(userId: number): boolean {
|
||||
return this.llmParse.isAvailable(userId);
|
||||
}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return verifyTripAccess(tripId, userId);
|
||||
}
|
||||
@@ -37,37 +54,69 @@ export class BookingImportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse uploaded files through kitinerary-extractor and return a preview list.
|
||||
* Does NOT persist anything.
|
||||
* Parse uploaded files and return a preview list. Does NOT persist anything.
|
||||
* Runs kitinerary first; depending on `mode`, falls back to the LLM:
|
||||
* - no-ai: kitinerary only
|
||||
* - fallback-on-empty: LLM for files kitinerary returns nothing for
|
||||
* - force-ai: LLM on every file (kitinerary skipped)
|
||||
* LLM-derived items are flagged needs_review. Per-file AI usage is reported.
|
||||
*/
|
||||
async preview(files: Express.Multer.File[]): Promise<BookingImportPreviewResponse> {
|
||||
if (!this.extractor.isAvailable()) {
|
||||
async preview(
|
||||
files: Express.Multer.File[],
|
||||
mode: BookingImportMode,
|
||||
userId: number,
|
||||
onProgress?: (done: number, total: number, fileName: string) => void,
|
||||
): Promise<BookingImportPreviewResponse> {
|
||||
const kitineraryAvailable = this.extractor.isAvailable();
|
||||
const aiAvailable = this.llmParse.isAvailable(userId);
|
||||
if (!kitineraryAvailable && !aiAvailable) {
|
||||
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
|
||||
}
|
||||
|
||||
const allItems: ParsedBookingItem[] = [];
|
||||
const allWarnings: string[] = [];
|
||||
const fileReports: BookingImportFileReport[] = [];
|
||||
|
||||
let processed = 0;
|
||||
for (const file of files) {
|
||||
let kiItems;
|
||||
try {
|
||||
kiItems = await this.extractor.extract(file.buffer, file.originalname);
|
||||
} catch (err) {
|
||||
allWarnings.push(`${file.originalname}: extraction failed — ${err instanceof Error ? err.message : String(err)}`);
|
||||
continue;
|
||||
let kiItems: KiReservation[] = [];
|
||||
let aiUsed = false;
|
||||
|
||||
// Stage 1: kitinerary (skipped entirely when forcing AI).
|
||||
if (mode !== 'force-ai' && kitineraryAvailable) {
|
||||
try {
|
||||
kiItems = await this.extractor.extract(file.buffer, file.originalname);
|
||||
} catch (err) {
|
||||
allWarnings.push(`${file.originalname}: extraction failed — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1b: LLM fallback.
|
||||
const runLlm = aiAvailable && (mode === 'force-ai' || (mode === 'fallback-on-empty' && kiItems.length === 0));
|
||||
if (runLlm) {
|
||||
aiUsed = true;
|
||||
const llm = await this.llmParse.parse({ buffer: file.buffer, originalName: file.originalname }, userId);
|
||||
kiItems = llm.kiItems;
|
||||
allWarnings.push(...llm.warnings);
|
||||
}
|
||||
|
||||
fileReports.push({ fileName: file.originalname, aiAvailable, aiUsed });
|
||||
|
||||
if (kiItems.length === 0) {
|
||||
allWarnings.push(`${file.originalname}: no reservations found`);
|
||||
continue;
|
||||
} else {
|
||||
const { items, warnings } = mapReservations(kiItems, file.originalname);
|
||||
// LLM extraction is less certain than kitinerary — always flag for review.
|
||||
if (aiUsed) for (const it of items) it.needs_review = true;
|
||||
allItems.push(...items);
|
||||
allWarnings.push(...warnings);
|
||||
}
|
||||
|
||||
const { items, warnings } = mapReservations(kiItems, file.originalname);
|
||||
allItems.push(...items);
|
||||
allWarnings.push(...warnings);
|
||||
// Report per-file progress so a background import can drive a live widget.
|
||||
onProgress?.(++processed, files.length, file.originalname);
|
||||
}
|
||||
|
||||
return { items: allItems, warnings: allWarnings };
|
||||
return { items: allItems, warnings: allWarnings, files: fileReports };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +175,28 @@ export class BookingImportService {
|
||||
broadcast(tripId, 'place:created', { place }, socketId);
|
||||
}
|
||||
|
||||
// Geocode transport endpoints (stations/stops/terminals/rental desks) that
|
||||
// arrived without coords, so the route draws and map pins appear. The LLM
|
||||
// and kitinerary rarely supply geo for non-airport endpoints.
|
||||
if (Array.isArray(reservationData.endpoints)) {
|
||||
for (const ep of reservationData.endpoints) {
|
||||
if ((ep.lat == null || ep.lng == null) && ep.name) {
|
||||
try {
|
||||
const hit = (await searchNominatim(ep.name))[0];
|
||||
if (hit?.lat != null && hit?.lng != null) {
|
||||
ep.lat = hit.lat;
|
||||
ep.lng = hit.lng;
|
||||
}
|
||||
} catch {
|
||||
// geocoding failure is non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
// Persist only coord'd endpoints (reservation_endpoints needs lat/lng);
|
||||
// ungeocodable ones still appeared in the preview's From→To.
|
||||
reservationData.endpoints = reservationData.endpoints.filter((ep) => ep.lat != null && ep.lng != null);
|
||||
}
|
||||
|
||||
// Build create_accommodation for hotel reservations.
|
||||
// start_day_id / end_day_id are resolved from check-in/out ISO dates so
|
||||
// the accommodation row is actually inserted (createReservation gates on them).
|
||||
@@ -154,6 +225,33 @@ export class BookingImportService {
|
||||
broadcast(tripId, 'accommodation:created', {}, socketId);
|
||||
}
|
||||
|
||||
// Turn an extracted price into a real linked cost (Costs addon), so the
|
||||
// booking shows up as an expense — not just a price in metadata.
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||
const meta =
|
||||
reservationData.metadata && typeof reservationData.metadata === 'object'
|
||||
? (reservationData.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
const price = meta && meta.price != null ? Number(meta.price) : NaN;
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
try {
|
||||
const budgetItem = createBudgetItem(tripId, {
|
||||
category: typeToCostCategory(item.type),
|
||||
name: item.title,
|
||||
total_price: price,
|
||||
currency: meta && typeof meta.priceCurrency === 'string' ? meta.priceCurrency : null,
|
||||
reservation_id: reservation.id,
|
||||
});
|
||||
broadcast(tripId, 'budget:created', { item: budgetItem }, socketId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[booking-import] Failed to create cost for "${item.title}":`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
created.push(reservation);
|
||||
} catch (err) {
|
||||
console.error(`[booking-import] Failed to create reservation "${item.title}":`, err instanceof Error ? err.message : err);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
/** Exposes server feature flags consumed by the frontend to show/hide optional UI. */
|
||||
@Controller('api/health')
|
||||
@@ -10,6 +12,9 @@ export class FeaturesController {
|
||||
features() {
|
||||
return {
|
||||
bookingImport: this.extractor.isAvailable(),
|
||||
// Addon-level flag (per-user config availability is reported per-file in
|
||||
// the preview response). Drives whether the client shows AI affordances.
|
||||
aiParsing: isAddonEnabled(ADDON_IDS.LLM_PARSING),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { broadcastToUser } from '../../websocket';
|
||||
import { BookingImportService } from './booking-import.service';
|
||||
import type { BookingImportMode, BookingImportPreviewResponse } from '@trek/shared';
|
||||
|
||||
type JobStatus = 'running' | 'done' | 'error';
|
||||
|
||||
interface ImportJob {
|
||||
id: string;
|
||||
tripId: string;
|
||||
userId: number;
|
||||
status: JobStatus;
|
||||
done: number;
|
||||
total: number;
|
||||
result?: BookingImportPreviewResponse;
|
||||
error?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// Keep a finished job around briefly so a client that missed the WebSocket push
|
||||
// (navigation, reconnect) can still GET its result.
|
||||
const JOB_TTL_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* Runs a booking-import parse OFF the request: the controller returns a job id
|
||||
* immediately, the parse continues here, and progress/completion are pushed to the
|
||||
* user's sockets via `broadcastToUser` (which reaches them on ANY page, not just the
|
||||
* trip room). This is what lets the upload modal close at once and a background widget
|
||||
* track the work while the user keeps navigating. The actual parsing is the same
|
||||
* `BookingImportService.preview` the synchronous endpoint uses.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ImportJobsService {
|
||||
private readonly jobs = new Map<string, ImportJob>();
|
||||
/** Tail of each user's job chain — parses run one at a time per user, not all at once. */
|
||||
private readonly chains = new Map<number, Promise<void>>();
|
||||
|
||||
constructor(private readonly bookingImport: BookingImportService) {}
|
||||
|
||||
/** Create a job and queue it behind the user's other parses; returns the job id at once. */
|
||||
start(tripId: string, files: Express.Multer.File[], mode: BookingImportMode, userId: number): string {
|
||||
const id = randomUUID();
|
||||
const job: ImportJob = { id, tripId, userId, status: 'running', done: 0, total: files.length, createdAt: Date.now() };
|
||||
this.jobs.set(id, job);
|
||||
// Chain onto the user's previous parse so they run sequentially (one CPU-heavy
|
||||
// inference at a time), while the request returns immediately.
|
||||
const prev = this.chains.get(userId) ?? Promise.resolve();
|
||||
const next = prev.then(() => this.run(job, files, mode)).catch(() => {});
|
||||
this.chains.set(userId, next);
|
||||
void next.finally(() => {
|
||||
if (this.chains.get(userId) === next) this.chains.delete(userId);
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
get(id: string, userId: number): ImportJob | undefined {
|
||||
const job = this.jobs.get(id);
|
||||
return job && job.userId === userId ? job : undefined;
|
||||
}
|
||||
|
||||
private async run(job: ImportJob, files: Express.Multer.File[], mode: BookingImportMode): Promise<void> {
|
||||
this.push(job, 'import:progress', { status: 'running', done: 0, total: job.total });
|
||||
try {
|
||||
const result = await this.bookingImport.preview(files, mode, job.userId, (done, total, fileName) => {
|
||||
job.done = done;
|
||||
this.push(job, 'import:progress', { status: 'running', done, total, fileName });
|
||||
});
|
||||
job.status = 'done';
|
||||
job.result = result;
|
||||
this.push(job, 'import:done', { result });
|
||||
} catch (err) {
|
||||
job.status = 'error';
|
||||
job.error = err instanceof Error ? err.message : String(err);
|
||||
this.push(job, 'import:error', { message: job.error });
|
||||
} finally {
|
||||
const id = job.id;
|
||||
setTimeout(() => this.jobs.delete(id), JOB_TTL_MS).unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
private push(job: ImportJob, type: string, payload: Record<string, unknown>): void {
|
||||
broadcastToUser(job.userId, { type, jobId: job.id, tripId: job.tripId, ...payload });
|
||||
}
|
||||
}
|
||||
@@ -189,8 +189,9 @@ function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): Parsed
|
||||
const endpoints: ParsedEndpoint[] = [];
|
||||
const dc = coords(t.departureStation?.geo);
|
||||
const ac = coords(t.arrivalStation?.geo);
|
||||
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
|
||||
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
|
||||
// Push named endpoints even without coords — confirm() geocodes them later.
|
||||
if (t.departureStation?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate });
|
||||
if (t.arrivalStation?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, timezone: null, local_time: arrTime, local_date: arrDate });
|
||||
|
||||
return {
|
||||
type: 'train',
|
||||
@@ -220,10 +221,10 @@ function mapBus(r: KiReservation, source: ParsedBookingItem['source']): ParsedBo
|
||||
const endpoints: ParsedEndpoint[] = [];
|
||||
const dc = coords(b.departureBusStop?.geo);
|
||||
const ac = coords(b.arrivalBusStop?.geo);
|
||||
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
|
||||
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
|
||||
if (b.departureBusStop?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate });
|
||||
if (b.arrivalBusStop?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, timezone: null, local_time: arrTime, local_date: arrDate });
|
||||
|
||||
return { type: 'train', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, needs_review: endpoints.length < 2, source };
|
||||
return { type: 'bus', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, metadata: busId ? { bus_number: busId } : undefined, endpoints, needs_review: endpoints.length < 2, source };
|
||||
}
|
||||
|
||||
function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||
@@ -240,10 +241,10 @@ function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedB
|
||||
const endpoints: ParsedEndpoint[] = [];
|
||||
const dc = coords(b.departureBoatTerminal?.geo);
|
||||
const ac = coords(b.arrivalBoatTerminal?.geo);
|
||||
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
|
||||
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
|
||||
if (b.departureBoatTerminal?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate });
|
||||
if (b.arrivalBoatTerminal?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, timezone: null, local_time: arrTime, local_date: arrDate });
|
||||
|
||||
return { type: 'cruise', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, source };
|
||||
return { type: 'cruise', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, needs_review: endpoints.length < 2, source };
|
||||
}
|
||||
|
||||
function mapLodging(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||
@@ -287,10 +288,31 @@ function mapRentalCar(r: KiReservation, source: ParsedBookingItem['source']): Pa
|
||||
const title = [company, carName].filter(Boolean).join(' — ') || 'Rental Car';
|
||||
|
||||
const pickup = r.pickupLocation as KiReservation['pickupLocation'];
|
||||
const dropoff = r.dropoffLocation as KiReservation['dropoffLocation'];
|
||||
const pc = coords(pickup?.geo);
|
||||
const drc = coords(dropoff?.geo);
|
||||
const venue: ParsedVenue | undefined = pickup?.name ? { name: pickup.name, ...(pc ?? {}), address: formatAddress(pickup.address) ?? undefined } : undefined;
|
||||
|
||||
return { type: 'car', title, reservation_time: toIsoString(r.pickupTime), reservation_end_time: toIsoString(r.dropoffTime), confirmation_number: r.reservationNumber ?? null, ...(venue ? { _venue: venue } : {}), source };
|
||||
// Pickup → return as from/to endpoints (coords optional; confirm() geocodes).
|
||||
const { date: puDate, time: puTime } = splitIso(r.pickupTime);
|
||||
const { date: doDate, time: doTime } = splitIso(r.dropoffTime);
|
||||
const endpoints: ParsedEndpoint[] = [];
|
||||
if (pickup?.name) endpoints.push({ role: 'from', sequence: 0, name: pickup.name, code: null, lat: pc?.lat ?? null, lng: pc?.lng ?? null, timezone: null, local_time: puTime, local_date: puDate });
|
||||
if (dropoff?.name) endpoints.push({ role: 'to', sequence: 1, name: dropoff.name, code: null, lat: drc?.lat ?? null, lng: drc?.lng ?? null, timezone: null, local_time: doTime, local_date: doDate });
|
||||
|
||||
return {
|
||||
type: 'car',
|
||||
title,
|
||||
reservation_time: toIsoString(r.pickupTime),
|
||||
reservation_end_time: toIsoString(r.dropoffTime),
|
||||
confirmation_number: r.reservationNumber ?? null,
|
||||
location: formatAddress(pickup?.address) ?? pickup?.name ?? null,
|
||||
...(company ? { metadata: { rental_company: company } } : {}),
|
||||
endpoints,
|
||||
needs_review: endpoints.length < 2,
|
||||
...(venue ? { _venue: venue } : {}),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||
@@ -299,15 +321,42 @@ function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): Parsed
|
||||
|
||||
const loc = e.location;
|
||||
const c = coords(loc?.geo);
|
||||
const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined } : undefined;
|
||||
const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined, website: loc.url ?? undefined, phone: loc.telephone ?? undefined } : undefined;
|
||||
|
||||
return { type: 'event', title: e.name, reservation_time: toIsoString(e.startDate), reservation_end_time: toIsoString(e.endDate), confirmation_number: r.reservationNumber ?? null, location: loc ? (formatAddress(loc.address) ?? loc.name ?? null) : null, ...(venue ? { _venue: venue } : {}), source };
|
||||
return { type: 'event', title: e.name, reservation_time: toIsoString(e.startDate ?? r.startTime), reservation_end_time: toIsoString(e.endDate ?? r.endTime), confirmation_number: r.reservationNumber ?? null, location: loc ? (formatAddress(loc.address) ?? loc.name ?? null) : null, ...(venue ? { _venue: venue } : {}), source };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Merge seat/class/platform/price into an item's metadata (type-agnostic).
|
||||
* Models name these inconsistently and sometimes nest them under reservationFor,
|
||||
* so check both levels and common aliases. The item's own metadata wins. */
|
||||
function applyCommonMeta(item: ParsedBookingItem, r: KiReservation): ParsedBookingItem {
|
||||
const rf = (r.reservationFor && typeof r.reservationFor === 'object' ? r.reservationFor : {}) as Record<string, unknown>;
|
||||
const pick = (...keys: string[]): unknown => {
|
||||
for (const k of keys) {
|
||||
const v = (r as Record<string, unknown>)[k] ?? rf[k];
|
||||
if (v != null && v !== '') return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const m: Record<string, unknown> = {};
|
||||
const seat = pick('seat', 'seatNumber');
|
||||
if (seat != null) m.seat = String(seat);
|
||||
const cls = pick('class', 'bookingClass', 'fareClass', 'serviceClass', 'seatingType');
|
||||
if (cls != null) m.class = String(cls);
|
||||
const platform = pick('platform', 'departurePlatform');
|
||||
if (platform != null) m.platform = String(platform);
|
||||
const price = pick('price', 'priceAmount', 'totalPrice', 'total');
|
||||
if (price != null) m.price = price;
|
||||
const cur = pick('priceCurrency', 'priceCurrencyISO4217Code', 'currency');
|
||||
if (cur != null) m.priceCurrency = String(cur);
|
||||
if (Object.keys(m).length) item.metadata = { ...m, ...(item.metadata ?? {}) };
|
||||
return item;
|
||||
}
|
||||
|
||||
export function mapReservations(kiItems: KiReservation[], fileName: string): { items: ParsedBookingItem[]; warnings: string[] } {
|
||||
const items: ParsedBookingItem[] = [];
|
||||
const warnings: string[] = [];
|
||||
@@ -331,7 +380,7 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i
|
||||
group.push(kiItems[++i]);
|
||||
}
|
||||
item = group.length > 1 ? mapFlightGroup(group, source) : mapFlight(r, source);
|
||||
if (item) items.push(item);
|
||||
if (item) items.push(applyCommonMeta(item, r));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -348,7 +397,7 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i
|
||||
warnings.push(`Unknown type "${r['@type']}" in ${fileName}[${i}] — skipped`);
|
||||
}
|
||||
|
||||
if (item) items.push(item);
|
||||
if (item) items.push(applyCommonMeta(item, r));
|
||||
}
|
||||
|
||||
return { items, warnings };
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface KiEventVenue {
|
||||
name?: string;
|
||||
address?: string | KiAddress;
|
||||
geo?: KiGeo;
|
||||
telephone?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface KiEvent {
|
||||
@@ -134,6 +136,12 @@ export interface KiReservation {
|
||||
endTime?: KiDateTimeish;
|
||||
reservationFor?: Record<string, unknown>;
|
||||
pickupLocation?: KiEventVenue;
|
||||
dropoffLocation?: KiEventVenue;
|
||||
seat?: string;
|
||||
class?: string;
|
||||
platform?: string;
|
||||
price?: number | string;
|
||||
priceCurrency?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -143,8 +151,8 @@ export interface ParsedEndpoint {
|
||||
sequence: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
timezone: string | null;
|
||||
local_time: string | null;
|
||||
local_date: string | null;
|
||||
|
||||
@@ -94,6 +94,31 @@ export class BudgetController {
|
||||
return { settlement };
|
||||
}
|
||||
|
||||
@Put('settlements/:settlementId')
|
||||
updateSettlement(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('settlementId') settlementId: string,
|
||||
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
|
||||
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
|
||||
}
|
||||
const settlement = this.budget.updateSettlement(settlementId, tripId, {
|
||||
from_user_id: body.from_user_id,
|
||||
to_user_id: body.to_user_id,
|
||||
amount: body.amount,
|
||||
});
|
||||
if (!settlement) {
|
||||
throw new HttpException({ error: 'Settlement not found' }, 404);
|
||||
}
|
||||
this.budget.broadcast(tripId, 'budget:settlement-updated', { settlement }, socketId);
|
||||
return { settlement };
|
||||
}
|
||||
|
||||
@Delete('settlements/:settlementId')
|
||||
deleteSettlement(
|
||||
@CurrentUser() user: User,
|
||||
@@ -114,7 +139,7 @@ export class BudgetController {
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
|
||||
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; reservation_id?: number },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
|
||||
@@ -73,6 +73,10 @@ export class BudgetService {
|
||||
return svc.createSettlement(tripId, data, userId);
|
||||
}
|
||||
|
||||
updateSettlement(id: string, tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }) {
|
||||
return svc.updateSettlement(id, tripId, data);
|
||||
}
|
||||
|
||||
deleteSettlement(id: string, tripId: string): boolean {
|
||||
return svc.deleteSettlement(id, tripId);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number };
|
||||
|
||||
// Mirrors the legacy validateStringLengths({ text: 500, time: 150 }) middleware,
|
||||
// which runs BEFORE the trip-access check — so an over-long field 400s first.
|
||||
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 150 };
|
||||
// Runs BEFORE the trip-access check, so an over-long field 400s first. The `time`
|
||||
// cap matches the shared dayNote schema (max 250) and the note dialog's counter;
|
||||
// it was 150 here, which rejected valid 151–250 char notes with a confusing error.
|
||||
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 250 };
|
||||
|
||||
function validateLengths(body: Record<string, unknown>): void {
|
||||
for (const [field, max] of Object.entries(MAX_LENGTHS)) {
|
||||
|
||||
@@ -43,6 +43,7 @@ export class AirtrailController {
|
||||
body.url,
|
||||
body.apiKey,
|
||||
!!body.allowInsecureTls,
|
||||
!!body.writeEnabled,
|
||||
getClientIp(req),
|
||||
);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
|
||||
|
||||
const TIMEOUT_MS = 120_000;
|
||||
const MAX_TOKENS = 8192;
|
||||
const ANTHROPIC_VERSION = '2023-06-01';
|
||||
const TOOL_NAME = 'emit_reservations';
|
||||
|
||||
/**
|
||||
* Anthropic Messages API client. Structured output via forced tool-use: a single
|
||||
* `emit_reservations` tool whose `input_schema` is the reservations schema, with
|
||||
* `tool_choice` forcing it — the documented, reliable way to get structured JSON.
|
||||
* PDFs go as native base64 `document` blocks (Anthropic reads scanned PDFs).
|
||||
* Raw fetch (no SDK) to match the codebase's HTTP style.
|
||||
*/
|
||||
export class AnthropicClient implements LlmExtractionClient {
|
||||
async extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]> {
|
||||
const base = (input.baseUrl ?? 'https://api.anthropic.com').replace(/\/+$/, '');
|
||||
const url = `${base}/v1/messages`;
|
||||
|
||||
const content: unknown[] = [];
|
||||
if (input.file) {
|
||||
content.push({
|
||||
type: 'document',
|
||||
source: { type: 'base64', media_type: input.file.mimeType, data: input.file.data.toString('base64') },
|
||||
});
|
||||
}
|
||||
content.push({
|
||||
type: 'text',
|
||||
text: input.text ? `${USER_TEXT}\n\n${input.text}` : USER_TEXT,
|
||||
});
|
||||
|
||||
const body = {
|
||||
model: input.model,
|
||||
max_tokens: MAX_TOKENS,
|
||||
system: input.prompt,
|
||||
tools: [
|
||||
{
|
||||
name: TOOL_NAME,
|
||||
description: 'Return the travel reservations extracted from the document.',
|
||||
input_schema: input.jsonSchema,
|
||||
},
|
||||
],
|
||||
tool_choice: { type: 'tool', name: TOOL_NAME },
|
||||
messages: [{ role: 'user', content }],
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': input.apiKey ?? '',
|
||||
'anthropic-version': ANTHROPIC_VERSION,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '');
|
||||
throw new Error(`Anthropic request failed (${res.status}): ${detail.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
stop_reason?: string;
|
||||
content?: { type: string; name?: string; input?: { reservations?: unknown } }[];
|
||||
};
|
||||
|
||||
if (data.stop_reason === 'refusal') {
|
||||
throw new Error('Anthropic declined to process this document');
|
||||
}
|
||||
|
||||
const toolUse = data.content?.find(b => b.type === 'tool_use' && b.name === TOOL_NAME);
|
||||
const reservations = toolUse?.input?.reservations;
|
||||
return Array.isArray(reservations) ? (reservations as Record<string, unknown>[]) : [];
|
||||
}
|
||||
}
|
||||
|
||||
const USER_TEXT = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* NuExtract adapter for the OpenAI-compatible client.
|
||||
*
|
||||
* NuExtract (NuMind) is not an instruct model — it is fine-tuned to fill a JSON
|
||||
* *template* whose leaf values are type tokens ("verbatim-string", "date-time",
|
||||
* …). Fed a generic chat instruction it just echoes the schema back, which is
|
||||
* why a plain prompt produces garbage. Run through Ollama/llama.cpp the template
|
||||
* has to be embedded INLINE in the user message under a `# Template:` header
|
||||
* (llama.cpp ignores vLLM's chat_template_kwargs), with temperature 0.
|
||||
*
|
||||
* Rather than ask NuExtract for the nested schema.org shape (its template format
|
||||
* can't express per-@type conditional fields), we give it ONE flat union template
|
||||
* — its sweet spot — and map the flat result back into the `KiReservation` shape
|
||||
* the kitinerary mapper consumes, so the whole downstream pipeline is unchanged.
|
||||
*/
|
||||
|
||||
/** Detect a NuExtract model id (e.g. `hf.co/numind/NuExtract-2.0-2B-GGUF`, `nuextract`). */
|
||||
export function isNuExtractModel(model: string | undefined): boolean {
|
||||
return !!model && /nuextract/i.test(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat union template covering every reservation type. NuExtract fills the
|
||||
* relevant fields and returns the rest as null, so one template serves all docs.
|
||||
*
|
||||
* Deliberately flat (a single reservation, not an array). A small NuExtract (the
|
||||
* 2B) returns an empty result when handed a nested `{ reservations: [ … ] }`
|
||||
* array-of-objects template, but extracts reliably from a single flat object —
|
||||
* so this path yields one reservation per document. Multi-segment itineraries
|
||||
* (round trips) are left to the generic instruct path (qwen/cloud), which the
|
||||
* system prompt already drives to emit every leg.
|
||||
*/
|
||||
export const NUEXTRACT_TEMPLATE = {
|
||||
type: ['flight', 'train', 'bus', 'ferry', 'car', 'hotel', 'restaurant', 'event'],
|
||||
name: 'verbatim-string',
|
||||
booking_reference: 'verbatim-string',
|
||||
operator: 'verbatim-string',
|
||||
vehicle_number: 'verbatim-string',
|
||||
// Departure/arrival double as a rental car's pick-up/return (place + time) — a
|
||||
// separate pickup_location field only tempted the model to grab a nearby form
|
||||
// label ("Location Terminal") instead of the actual depot.
|
||||
from_name: 'verbatim-string',
|
||||
from_code: 'verbatim-string',
|
||||
to_name: 'verbatim-string',
|
||||
to_code: 'verbatim-string',
|
||||
departure_time: 'date-time',
|
||||
arrival_time: 'date-time',
|
||||
address: 'verbatim-string',
|
||||
checkin_time: 'date-time',
|
||||
checkout_time: 'date-time',
|
||||
start_time: 'date-time',
|
||||
end_time: 'date-time',
|
||||
telephone: 'verbatim-string',
|
||||
website: 'verbatim-string',
|
||||
seat: 'verbatim-string',
|
||||
travel_class: 'verbatim-string',
|
||||
platform: 'verbatim-string',
|
||||
// Verbatim so we parse the localized number ourselves — asking the model for a
|
||||
// JSON number turns "1.580,22 €" (German thousands/decimal) into 1.49772.
|
||||
price: 'verbatim-string',
|
||||
currency: 'verbatim-string',
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the NuExtract user-turn text: the template (pretty-printed with the
|
||||
* indent the model cards use) followed by the document, under a `# Template:`
|
||||
* header. This is the exact inline format the GGUF model cards document.
|
||||
*/
|
||||
export function buildNuExtractUserText(documentText: string): string {
|
||||
return `# Template:\n${JSON.stringify(NUEXTRACT_TEMPLATE, null, 4)}\n${documentText}`;
|
||||
}
|
||||
|
||||
/** NuExtract `type` token → schema.org reservation `@type`. */
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
flight: 'FlightReservation',
|
||||
train: 'TrainReservation',
|
||||
bus: 'BusReservation',
|
||||
ferry: 'BoatReservation',
|
||||
boat: 'BoatReservation',
|
||||
cruise: 'BoatReservation',
|
||||
car: 'RentalCarReservation',
|
||||
hotel: 'LodgingReservation',
|
||||
lodging: 'LodgingReservation',
|
||||
restaurant: 'FoodEstablishmentReservation',
|
||||
event: 'EventReservation',
|
||||
};
|
||||
|
||||
/** Recursively drop null/undefined/blank leaves and the empty objects/arrays they leave behind. */
|
||||
function clean(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const arr = value.map(clean).filter((v) => v !== undefined);
|
||||
return arr.length ? arr : undefined;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
const c = clean(v);
|
||||
if (c !== undefined) out[k] = c;
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === 'string' && value.trim() === '') return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a localized money string into a plain number. Handles German
|
||||
* ("1.580,22 €" → 1580.22) and English ("1,580.22"/"$89.00" → 89) grouping by
|
||||
* treating the right-most separator as the decimal point. Returns null when there
|
||||
* is no parseable amount.
|
||||
*/
|
||||
function parseAmount(raw: unknown): number | null {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null;
|
||||
if (typeof raw !== 'string') return null;
|
||||
let s = raw.replace(/[^\d.,]/g, '');
|
||||
if (!s) return null;
|
||||
const lastComma = s.lastIndexOf(',');
|
||||
const lastDot = s.lastIndexOf('.');
|
||||
let decimal: ',' | '.' | null = null;
|
||||
if (lastComma > -1 && lastDot > -1) {
|
||||
decimal = lastComma > lastDot ? ',' : '.';
|
||||
} else if (lastComma > -1) {
|
||||
// A single comma with ≤2 trailing digits is a decimal point; otherwise grouping.
|
||||
const parts = s.split(',');
|
||||
decimal = parts.length === 2 && parts[1].length <= 2 ? ',' : null;
|
||||
} else if (lastDot > -1) {
|
||||
const parts = s.split('.');
|
||||
decimal = parts.length === 2 && parts[1].length <= 2 ? '.' : null;
|
||||
}
|
||||
if (decimal) {
|
||||
const grouping = decimal === ',' ? '.' : ',';
|
||||
s = s.split(grouping).join('').replace(decimal, '.');
|
||||
} else {
|
||||
s = s.replace(/[.,]/g, '');
|
||||
}
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Resolve an ISO 4217 currency from a symbol or code found in either field. */
|
||||
function parseCurrency(...candidates: unknown[]): string | undefined {
|
||||
for (const c of candidates) {
|
||||
if (typeof c !== 'string') continue;
|
||||
const s = c.toUpperCase();
|
||||
if (s.includes('€') || /\bEUR\b/.test(s)) return 'EUR';
|
||||
if (s.includes('£') || /\bGBP\b/.test(s)) return 'GBP';
|
||||
if (s.includes('$') || /\bUSD\b/.test(s)) return 'USD';
|
||||
if (s.includes('¥') || /\bJPY\b/.test(s)) return 'JPY';
|
||||
const iso = s.match(/\b([A-Z]{3})\b/);
|
||||
if (iso) return iso[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A venue's display name, falling back to the address (or a generic label) so a
|
||||
* lodging/restaurant/event is never silently dropped when the model misses the name. */
|
||||
function nameOrFallback(x: Record<string, unknown>, fallback: string): string {
|
||||
const name = typeof x.name === 'string' ? x.name.trim() : '';
|
||||
if (name) return name;
|
||||
const address = typeof x.address === 'string' ? x.address.trim() : '';
|
||||
if (address) return address.split(',')[0].trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Map one flat NuExtract reservation into a schema.org `KiReservation` node (or undefined). */
|
||||
function buildNode(x: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const atType = TYPE_MAP[String(x.type ?? '').toLowerCase().trim()];
|
||||
if (!atType) return undefined;
|
||||
|
||||
const node: Record<string, unknown> = {
|
||||
'@type': atType,
|
||||
reservationNumber: x.booking_reference,
|
||||
seat: x.seat,
|
||||
class: x.travel_class,
|
||||
platform: x.platform,
|
||||
price: parseAmount(x.price) ?? undefined,
|
||||
priceCurrency: parseCurrency(x.currency, x.price),
|
||||
};
|
||||
|
||||
switch (atType) {
|
||||
case 'FlightReservation':
|
||||
node.reservationFor = {
|
||||
flightNumber: x.vehicle_number,
|
||||
airline: x.operator ? { name: x.operator } : undefined,
|
||||
departureAirport: { iataCode: x.from_code, name: x.from_name },
|
||||
arrivalAirport: { iataCode: x.to_code, name: x.to_name },
|
||||
departureTime: x.departure_time,
|
||||
arrivalTime: x.arrival_time,
|
||||
};
|
||||
break;
|
||||
case 'TrainReservation':
|
||||
node.reservationFor = {
|
||||
trainNumber: x.vehicle_number,
|
||||
departureStation: { name: x.from_name },
|
||||
arrivalStation: { name: x.to_name },
|
||||
departureTime: x.departure_time,
|
||||
arrivalTime: x.arrival_time,
|
||||
};
|
||||
break;
|
||||
case 'BusReservation':
|
||||
node.reservationFor = {
|
||||
busNumber: x.vehicle_number,
|
||||
departureBusStop: { name: x.from_name },
|
||||
arrivalBusStop: { name: x.to_name },
|
||||
departureTime: x.departure_time,
|
||||
arrivalTime: x.arrival_time,
|
||||
};
|
||||
break;
|
||||
case 'BoatReservation':
|
||||
node.reservationFor = {
|
||||
name: x.name ?? x.operator,
|
||||
departureBoatTerminal: { name: x.from_name },
|
||||
arrivalBoatTerminal: { name: x.to_name },
|
||||
departureTime: x.departure_time,
|
||||
arrivalTime: x.arrival_time,
|
||||
};
|
||||
break;
|
||||
case 'LodgingReservation':
|
||||
node.reservationFor = { name: nameOrFallback(x, 'Accommodation'), address: x.address, telephone: x.telephone, url: x.website };
|
||||
node.checkinTime = x.checkin_time;
|
||||
node.checkoutTime = x.checkout_time;
|
||||
break;
|
||||
case 'FoodEstablishmentReservation':
|
||||
node.reservationFor = { name: nameOrFallback(x, 'Restaurant'), address: x.address, telephone: x.telephone, url: x.website };
|
||||
node.startTime = x.start_time;
|
||||
node.endTime = x.end_time;
|
||||
break;
|
||||
case 'RentalCarReservation':
|
||||
// Pick-up / return ride the transport from/to fields (see template comment).
|
||||
node.reservationFor = { name: x.name, rentalCompany: x.operator ? { name: x.operator } : undefined };
|
||||
node.pickupTime = x.departure_time;
|
||||
node.dropoffTime = x.arrival_time;
|
||||
node.pickupLocation = { name: x.from_name, address: x.address };
|
||||
node.dropoffLocation = { name: x.to_name };
|
||||
break;
|
||||
case 'EventReservation':
|
||||
node.reservationFor = {
|
||||
name: nameOrFallback(x, 'Event'),
|
||||
startDate: x.start_time,
|
||||
endDate: x.end_time,
|
||||
location: { address: x.address, telephone: x.telephone, url: x.website },
|
||||
};
|
||||
node.startTime = x.start_time;
|
||||
node.endTime = x.end_time;
|
||||
break;
|
||||
}
|
||||
|
||||
return clean(node) as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a parsed NuExtract response into schema.org `KiReservation` nodes.
|
||||
* Accepts the `{ reservations: [...] }` wrapper the template asks for, a bare
|
||||
* array, or a single object. Unrecognized/empty entries are dropped.
|
||||
*/
|
||||
export function nuExtractToKiReservations(parsed: unknown): Record<string, unknown>[] {
|
||||
const wrapped = (parsed as { reservations?: unknown })?.reservations;
|
||||
const list = Array.isArray(wrapped)
|
||||
? wrapped
|
||||
: Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && typeof parsed === 'object'
|
||||
? [parsed]
|
||||
: [];
|
||||
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (const entry of list) {
|
||||
if (entry && typeof entry === 'object') {
|
||||
const node = buildNode(entry as Record<string, unknown>);
|
||||
if (node) out.push(node);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
|
||||
import { isNuExtractModel, buildNuExtractUserText, nuExtractToKiReservations } from './nuextract';
|
||||
|
||||
// Generous: a local CPU model (Ollama, no GPU) may cold-load several GB and then
|
||||
// take a few minutes on a longer document before the first token.
|
||||
const TIMEOUT_MS = 300_000;
|
||||
const MAX_TOKENS = 4096;
|
||||
|
||||
/**
|
||||
* OpenAI-compatible chat-completions client. Covers both the "openai" cloud
|
||||
* provider and the "local" provider (Ollama / vLLM / llama.cpp / LM Studio),
|
||||
* which all expose `POST {baseUrl}/chat/completions`. Native binaries (PDF) are
|
||||
* sent as an OpenAI `file` content part; text goes as a text part. Uses the
|
||||
* global fetch (no SDK) to match the codebase's HTTP style.
|
||||
*
|
||||
* A NuExtract model (detected by id) takes a different request shape: the JSON
|
||||
* template inlined in a single user message, no system prompt and no
|
||||
* `response_format` (see ./nuextract.ts) — that's how the fine-tune expects to
|
||||
* be driven; the generic instruct path applies to every other model.
|
||||
*/
|
||||
export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
async extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]> {
|
||||
const base = (input.baseUrl ?? 'https://api.openai.com/v1').replace(/\/+$/, '');
|
||||
const url = `${base}/chat/completions`;
|
||||
const nuextract = isNuExtractModel(input.model);
|
||||
|
||||
const userContent: unknown[] = nuextract
|
||||
? [{ type: 'text', text: buildNuExtractUserText(input.text ?? '') }]
|
||||
: [{ type: 'text', text: input.text ? `${USER_TEXT}\n\n${input.text}` : USER_TEXT }];
|
||||
// Only genuine images go natively (as image_url) — OpenAI-compatible servers
|
||||
// (notably Ollama) reject `file`/PDF content parts. PDFs reach this client as
|
||||
// pre-extracted text (see llm-parse.service.ts), never as bytes.
|
||||
if (!nuextract && input.file && input.file.mimeType.startsWith('image/')) {
|
||||
const b64 = input.file.data.toString('base64');
|
||||
userContent.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${input.file.mimeType};base64,${b64}` },
|
||||
});
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: input.model,
|
||||
max_tokens: MAX_TOKENS,
|
||||
// Extraction is a deterministic task — Ollama defaults to 0.7, which makes
|
||||
// small models (NuExtract) drop fields or return empty. Pin to 0.
|
||||
temperature: 0,
|
||||
// NuExtract wants the template (in the user turn) to be the only instruction
|
||||
// — a system prompt or a json_schema grammar derails it.
|
||||
messages: nuextract
|
||||
? [{ role: 'user', content: userContent }]
|
||||
: [
|
||||
{ role: 'system', content: input.prompt },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
...(nuextract
|
||||
? {}
|
||||
: {
|
||||
response_format: {
|
||||
type: 'json_schema' as const,
|
||||
json_schema: { name: 'reservations', schema: input.jsonSchema, strict: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(input.apiKey ? { authorization: `Bearer ${input.apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '');
|
||||
throw new Error(`LLM request failed (${res.status}): ${detail.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
return nuextract ? parseNuExtract(content) : parseReservations(content);
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip code fences and JSON.parse; `null` on failure. */
|
||||
function parseJson(content: string | undefined | null): unknown {
|
||||
if (!content) return null;
|
||||
const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
|
||||
try {
|
||||
return JSON.parse(stripped);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a NuExtract response and map its flat template output to KiReservation nodes. */
|
||||
function parseNuExtract(content: string | undefined | null): Record<string, unknown>[] {
|
||||
return nuExtractToKiReservations(parseJson(content));
|
||||
}
|
||||
|
||||
const USER_TEXT = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
|
||||
|
||||
/** Tolerant parse: strip code fences, JSON.parse, pull `reservations`. `[]` on failure. */
|
||||
function parseReservations(content: string | undefined | null): Record<string, unknown>[] {
|
||||
const parsed = parseJson(content);
|
||||
if (Array.isArray(parsed)) return parsed as Record<string, unknown>[];
|
||||
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { reservations?: unknown }).reservations)) {
|
||||
return (parsed as { reservations: Record<string, unknown>[] }).reservations;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LlmExtractionClient } from './llm-provider.interface';
|
||||
import type { ResolvedLlmConfig } from '../../services/llmConfig';
|
||||
import { OpenAiCompatibleClient } from './clients/openai-compatible.client';
|
||||
import { AnthropicClient } from './clients/anthropic.client';
|
||||
|
||||
/**
|
||||
* Pick the provider client for a resolved config.
|
||||
* - 'anthropic' → Anthropic Messages API client
|
||||
* - 'openai' | 'local' → OpenAI-compatible client (cloud or local base URL)
|
||||
*/
|
||||
export function createLlmClient(config: ResolvedLlmConfig): LlmExtractionClient {
|
||||
switch (config.provider) {
|
||||
case 'anthropic':
|
||||
return new AnthropicClient();
|
||||
case 'openai':
|
||||
case 'local':
|
||||
return new OpenAiCompatibleClient();
|
||||
// TODO(nuextract): add a NuExtract template adapter here (local vision model
|
||||
// with its own template-fill API) once the OpenAI-compatible path proves
|
||||
// insufficient for small local models — see the design seam in the plan.
|
||||
default:
|
||||
return new OpenAiCompatibleClient();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user