mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(files): list a hotel booking's files and files pinned to a stop on the place card
A hotel booking reaches its place through the stay, not its own place_id, so the card listed the booking but not its confirmation. Links to a stop, which MCP's link_trip_file and plugins can make, were never returned by the file list at all; it now carries linked_assignment_ids. Without files the row no longer opens to nothing.
This commit is contained in:
@@ -901,6 +901,13 @@ describe('PlaceInspector', () => {
|
||||
expect(container.querySelector('input[type="file"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-044b: with upload but no files, the files row does not open to nothing', () => {
|
||||
render(<PlaceInspector {...defaultProps} files={[]} />);
|
||||
const row = screen.getByRole('button', { name: 'Files' });
|
||||
expect(row).toBeDisabled();
|
||||
expect(row).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
// ── Participants section hidden when tripMembers <= 1 ─────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-045: participants section hidden when tripMembers has only 1 member', () => {
|
||||
|
||||
@@ -1189,12 +1189,12 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
|
||||
>
|
||||
<div className={BOX}>
|
||||
<button type="button" onClick={() => setFilesExpanded(f => !f)} aria-expanded={filesExpanded}
|
||||
className="flex w-full items-center gap-2 rounded-[10px] px-3 py-2 text-start transition-colors hover:bg-surface-hover">
|
||||
disabled={!placeFiles.length} className="group flex w-full items-center gap-2 rounded-[10px] px-3 py-2 text-start transition-colors enabled:hover:bg-surface-hover">
|
||||
<FileText size={13} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content-secondary" style={fs(12, 'body')}>
|
||||
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
|
||||
</span>
|
||||
{filesExpanded ? <ChevronUp size={13} className="flex-none text-content-faint" /> : <ChevronDown size={13} className="flex-none text-content-faint" />}
|
||||
{filesExpanded ? <ChevronUp size={13} className="flex-none text-content-faint group-disabled:hidden" /> : <ChevronDown size={13} className="flex-none text-content-faint group-disabled:hidden" />}
|
||||
</button>
|
||||
{filesExpanded && placeFiles.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-edge-faint p-1">
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface TripFile {
|
||||
reservation_title?: string
|
||||
linked_reservation_ids?: (number | null)[]
|
||||
linked_place_ids?: (number | null)[]
|
||||
/** Stops in the plan the file is pinned to (MCP's link_trip_file, plugins). */
|
||||
linked_assignment_ids?: (number | null)[]
|
||||
/** Served download path — always present on list/create/update responses (formatFile). */
|
||||
url: string
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Reservation, TripFile } from '../types'
|
||||
|
||||
/**
|
||||
* The files a place shows: its own, plus the ones attached to a booking that
|
||||
* hangs on this place or on one of its stops in the plan. A confirmation lives
|
||||
* on the booking, and the place card was the one screen that could not reach it
|
||||
* (#2217).
|
||||
* The files a place shows: its own, the ones pinned to one of its stops in the
|
||||
* plan, plus the ones attached to a booking that hangs on this place or on one
|
||||
* of its stops. A confirmation lives on the booking, and the place card was the
|
||||
* one screen that could not reach it (#2217). A hotel booking reaches its place
|
||||
* through the stay rather than its own place_id, the same way the card lists
|
||||
* the booking itself (getPlaceBookings, #2363).
|
||||
*/
|
||||
export function filesForPlace(
|
||||
files: TripFile[] | undefined,
|
||||
@@ -14,11 +16,18 @@ export function filesForPlace(
|
||||
): TripFile[] {
|
||||
if (placeId == null) return []
|
||||
const own = (files || []).filter(f =>
|
||||
!f.deleted_at && (String(f.place_id) === String(placeId) || (f.linked_place_ids || []).includes(placeId)),
|
||||
!f.deleted_at && (
|
||||
String(f.place_id) === String(placeId)
|
||||
|| (f.linked_place_ids || []).includes(placeId)
|
||||
|| (f.linked_assignment_ids || []).some(id => id != null && assignmentIds.includes(id))
|
||||
),
|
||||
)
|
||||
const bookingIds = new Set(
|
||||
(reservations || [])
|
||||
.filter(r => String(r.place_id) === String(placeId) || (r.assignment_id != null && assignmentIds.includes(r.assignment_id)))
|
||||
.filter(r =>
|
||||
String(r.place_id) === String(placeId)
|
||||
|| (r.accommodation_place_id != null && String(r.accommodation_place_id) === String(placeId))
|
||||
|| (r.assignment_id != null && assignmentIds.includes(r.assignment_id)))
|
||||
.map(r => r.id),
|
||||
)
|
||||
if (bookingIds.size === 0) return own
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { filesForPlace } from '../../../src/utils/placeFiles'
|
||||
import type { Reservation, TripFile } from '../../../src/types'
|
||||
|
||||
// FE-UTIL-PLACEFILES-001 to FE-UTIL-PLACEFILES-006
|
||||
// FE-UTIL-PLACEFILES-001 to FE-UTIL-PLACEFILES-008
|
||||
|
||||
const file = (over: Partial<TripFile>): TripFile => ({
|
||||
id: 1, trip_id: 5, original_name: 'a.pdf', url: '/uploads/a.pdf',
|
||||
@@ -54,4 +54,20 @@ describe('filesForPlace', () => {
|
||||
expect(filesForPlace([gone, bookingFileGone], 7, [booking({ id: 90, place_id: 7 })])).toEqual([])
|
||||
expect(filesForPlace([file({ id: 10, place_id: 7 })], undefined, [])).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-UTIL-PLACEFILES-007: reaches a hotel booking through the stay on the place', () => {
|
||||
// A hotel booking keeps place_id empty; the place is the stay's (#2363).
|
||||
const stay = booking({ id: 90, place_id: null, assignment_id: null, accommodation_place_id: 7 })
|
||||
const stored = file({ id: 11, reservation_id: 90 })
|
||||
const linked = file({ id: 12, linked_reservation_ids: [90] })
|
||||
expect(filesForPlace([stored, linked], 7, [stay]).map(f => f.id)).toEqual([11, 12])
|
||||
// A stay on another place lends nothing to this one.
|
||||
expect(filesForPlace([stored], 7, [{ ...stay, accommodation_place_id: 8 }])).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-UTIL-PLACEFILES-008: keeps a file pinned to one of the place\'s stops', () => {
|
||||
const pinned = file({ id: 13, linked_assignment_ids: [11] })
|
||||
expect(filesForPlace([pinned], 7, [], [11]).map(f => f.id)).toEqual([13])
|
||||
expect(filesForPlace([pinned], 7, [], [12])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -574,6 +574,7 @@ export const PLUGIN_ENTITY_FIELDS = {
|
||||
'url',
|
||||
'linked_reservation_ids',
|
||||
'linked_place_ids',
|
||||
'linked_assignment_ids',
|
||||
'linked_budget_item_ids',
|
||||
],
|
||||
budgetItem: [
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface FileLinkWithReservationRow extends FileLinkRow {
|
||||
export interface FileLinkTargetRow {
|
||||
file_id: number;
|
||||
reservation_id: number | null;
|
||||
assignment_id: number | null;
|
||||
place_id: number | null;
|
||||
budget_item_id: number | null;
|
||||
}
|
||||
@@ -64,7 +65,7 @@ export class FileLinksRepository extends TrekRepository<FileLinks> {
|
||||
if (file_ids.length === 0) return [];
|
||||
return await this.kysely<FileLinksKyselyDB>()
|
||||
.selectFrom('file_links')
|
||||
.select(['file_id', 'reservation_id', 'place_id', 'budget_item_id'])
|
||||
.select(['file_id', 'reservation_id', 'assignment_id', 'place_id', 'budget_item_id'])
|
||||
.where('file_id', 'in', file_ids)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -349,6 +349,8 @@ export class FilesService {
|
||||
...formatFile(f),
|
||||
linked_reservation_ids: fileLinks.filter((l) => l.reservation_id).map((l) => l.reservation_id),
|
||||
linked_place_ids: fileLinks.filter((l) => l.place_id).map((l) => l.place_id),
|
||||
// MCP's link_trip_file and plugins can pin a file to one stop of the plan.
|
||||
linked_assignment_ids: fileLinks.filter((l) => l.assignment_id).map((l) => l.assignment_id),
|
||||
linked_budget_item_ids: fileLinks.filter((l) => l.budget_item_id).map((l) => l.budget_item_id),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -225,6 +225,7 @@ export const PLUGIN_ENTITY_CONTRACT = {
|
||||
'url',
|
||||
'linked_reservation_ids',
|
||||
'linked_place_ids',
|
||||
'linked_assignment_ids',
|
||||
'linked_budget_item_ids',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -354,6 +354,19 @@ describe('listFiles', () => {
|
||||
const empty = createTrip(testDb, user.id);
|
||||
expect(await svc.listFiles(empty.id, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FILE-SVC-014b: lists the stops a file is pinned to, which MCP and plugins can link', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const day = createDay(testDb, trip.id);
|
||||
const stop = createDayAssignment(testDb, day.id, createPlace(testDb, trip.id).id);
|
||||
const pinned = await makeFile(trip.id, user.id);
|
||||
const bare = await makeFile(trip.id, user.id);
|
||||
await svc.createFileLink(pinned.id, { assignment_id: stop.id });
|
||||
|
||||
const files = (await svc.listFiles(trip.id, false)) as Record<string, unknown>[];
|
||||
expect(files.find((f) => f.id === pinned.id).linked_assignment_ids).toEqual([stop.id]);
|
||||
expect(files.find((f) => f.id === bare.id).linked_assignment_ids).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── receipts on an expense ────────────────────────────────────────────────────
|
||||
|
||||
@@ -76,6 +76,7 @@ export type TripFile = z.infer<typeof tripFileSchema>;
|
||||
export const tripFileListItemSchema = tripFileSchema.extend({
|
||||
linked_reservation_ids: z.array(z.number()),
|
||||
linked_place_ids: z.array(z.number()),
|
||||
linked_assignment_ids: z.array(z.number()),
|
||||
linked_budget_item_ids: z.array(z.number()),
|
||||
});
|
||||
export type TripFileListItem = z.infer<typeof tripFileListItemSchema>;
|
||||
|
||||
Reference in New Issue
Block a user