mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
feat(bookings): download button on booking and transport attachments (#2585)
Each attached file row now has a download icon next to its name, on the desktop booking cards and detail dialog and on the phone's bookings and transports cards. On the phone the file rows move out of the card body button so open and download are two real sibling buttons.
This commit is contained in:
@@ -1,8 +1,16 @@
|
||||
// FE-PLANNER-BKPARTS-001 to FE-PLANNER-BKPARTS-002
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
// FE-PLANNER-BKPARTS-001 to FE-PLANNER-BKPARTS-004
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildTripFile } from '../../../../tests/helpers/factories';
|
||||
import { downloadFile, openFile } from '../../../utils/fileDownload';
|
||||
import type { BookingFacts } from './bookingFacts';
|
||||
import { WhenFields } from './bookingParts';
|
||||
import { FileRows, WhenFields } from './bookingParts';
|
||||
|
||||
vi.mock('../../../utils/fileDownload', () => ({
|
||||
openFile: vi.fn(() => Promise.resolve()),
|
||||
downloadFile: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
function facts(day: BookingFacts['day'], time: string | null = '15:35 → 09:55'): BookingFacts {
|
||||
return {
|
||||
@@ -47,3 +55,37 @@ describe('WhenFields', () => {
|
||||
expect(screen.getByText('Day 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileRows', () => {
|
||||
const file = buildTripFile({ id: 5, url: '/uploads/f/5', original_name: 'ticket.pdf' });
|
||||
|
||||
it('FE-PLANNER-BKPARTS-003: the name opens the file, the icon downloads it, and neither reaches the card (#2585)', () => {
|
||||
vi.mocked(openFile).mockClear();
|
||||
vi.mocked(downloadFile).mockClear();
|
||||
const onCard = vi.fn();
|
||||
render(
|
||||
<div onClick={onCard}>
|
||||
<FileRows files={[file]} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const open = screen.getByRole('button', { name: 'ticket.pdf' });
|
||||
const download = screen.getByRole('button', { name: /: ticket\.pdf$/ });
|
||||
// Two sibling buttons, never one inside the other.
|
||||
expect(open.contains(download)).toBe(false);
|
||||
expect(download.contains(open)).toBe(false);
|
||||
|
||||
fireEvent.click(open);
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/f/5', 'ticket.pdf');
|
||||
expect(downloadFile).not.toHaveBeenCalled();
|
||||
fireEvent.click(download);
|
||||
expect(downloadFile).toHaveBeenCalledWith('/uploads/f/5', 'ticket.pdf');
|
||||
expect(openFile).toHaveBeenCalledOnce();
|
||||
expect(onCard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BKPARTS-004: no files render nothing', () => {
|
||||
const { container } = render(<FileRows files={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { AlertCircle, ChevronDown, ChevronUp, FileText, Plane } from 'lucide-react'
|
||||
import { AlertCircle, ChevronDown, ChevronUp, Download, FileText, Plane } from 'lucide-react'
|
||||
import type { Reservation, TripFile } from '../../../types'
|
||||
import type { ReservationTraveler } from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
@@ -10,7 +10,7 @@ import { PILL } from '../../shared/DialogShell'
|
||||
import { useToast } from '../../shared/Toast'
|
||||
import { parseMeta, typeInfo } from './bookingsModel'
|
||||
import type { BookingFacts } from './bookingFacts'
|
||||
import { openAttachment } from './openAttachment'
|
||||
import { downloadAttachment, openAttachment } from './openAttachment'
|
||||
|
||||
// The phone's card language on the desktop: small Geist eyebrows, framed value
|
||||
// boxes, count pills. Sizes scale with the user's text size setting.
|
||||
@@ -289,22 +289,46 @@ export function useOpenFile() {
|
||||
return (f: TripFile) => openAttachment(f, () => toast.error(t('files.openError')))
|
||||
}
|
||||
|
||||
/** The attached files, one framed row each, opening the file. */
|
||||
/** Saves an attached file, and says so when it cannot. */
|
||||
function useDownloadFile() {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
return (f: TripFile) => downloadAttachment(f, () => toast.error(t('files.downloadError')))
|
||||
}
|
||||
|
||||
/**
|
||||
* The attached files, one framed row each: the name opens the file, the icon at
|
||||
* the end saves it. Two sibling buttons rather than one row button, since
|
||||
* buttons cannot nest; both stop the click so the card around them stays put.
|
||||
*/
|
||||
export function FileRows({ files }: { files: TripFile[] }) {
|
||||
const { t } = useTranslation()
|
||||
const open = useOpenFile()
|
||||
const download = useDownloadFile()
|
||||
if (files.length === 0) return null
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{files.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); open(f) }}
|
||||
className={`${BOX} flex items-center gap-1.5 px-[10px] py-[7px] text-start hover:bg-surface-hover`}
|
||||
>
|
||||
<FileText size={12} strokeWidth={2} className="flex-none text-content-muted" />
|
||||
<span className="truncate font-geist font-semibold text-content-muted" style={fs(11.5)}>{f.original_name}</span>
|
||||
</button>
|
||||
<div key={f.id} className={`${BOX} flex items-stretch overflow-hidden`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); open(f) }}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 py-[7px] pe-1 ps-[10px] text-start hover:bg-surface-hover"
|
||||
>
|
||||
<FileText size={12} strokeWidth={2} className="flex-none text-content-muted" />
|
||||
<span className="truncate font-geist font-semibold text-content-muted" style={fs(11.5)}>{f.original_name}</span>
|
||||
</button>
|
||||
<Tooltip label={t('files.download')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); download(f) }}
|
||||
aria-label={`${t('files.download')}: ${f.original_name}`}
|
||||
className="grid flex-none place-items-center px-[9px] text-content-faint hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<Download size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// FE-PLANNER-OPENATTACH-001 to -003: opening a booking's attached file.
|
||||
// FE-PLANNER-OPENATTACH-001 to -006: opening and downloading a booking's attached file.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { openFile } from '../../../utils/fileDownload';
|
||||
import { openAttachment } from './openAttachment';
|
||||
import { downloadFile, openFile } from '../../../utils/fileDownload';
|
||||
import { downloadAttachment, openAttachment } from './openAttachment';
|
||||
|
||||
vi.mock('../../../utils/fileDownload', () => ({ openFile: vi.fn(() => Promise.resolve()) }));
|
||||
vi.mock('../../../utils/fileDownload', () => ({
|
||||
openFile: vi.fn(() => Promise.resolve()),
|
||||
downloadFile: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const file = { url: '/uploads/f/1', original_name: 'voucher.pdf' };
|
||||
|
||||
@@ -12,6 +15,7 @@ describe('openAttachment', () => {
|
||||
// A block body: a hook that returns the mock would have it run again as teardown.
|
||||
beforeEach(() => {
|
||||
vi.mocked(openFile).mockReset();
|
||||
vi.mocked(downloadFile).mockReset();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENATTACH-001: opens the file by its url and name', async () => {
|
||||
@@ -36,4 +40,31 @@ describe('openAttachment', () => {
|
||||
expect(openFile).toHaveBeenCalledTimes(1);
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/f/1', 'voucher.pdf');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENATTACH-004: downloads the file under its original name, without opening it (#2585)', async () => {
|
||||
vi.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
const onError = vi.fn();
|
||||
downloadAttachment(file, onError);
|
||||
expect(downloadFile).toHaveBeenCalledWith('/uploads/f/1', 'voucher.pdf');
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
await Promise.resolve();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENATTACH-005: a failed download reaches onError when one is given', async () => {
|
||||
vi.mocked(downloadFile).mockRejectedValue(new Error('offline'));
|
||||
const onError = vi.fn();
|
||||
downloadAttachment(file, onError);
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENATTACH-006: without onError a failure is swallowed, not left unhandled', async () => {
|
||||
vi.mocked(openFile).mockRejectedValue(new Error('offline'));
|
||||
vi.mocked(downloadFile).mockRejectedValue(new Error('offline'));
|
||||
openAttachment(file);
|
||||
downloadAttachment(file);
|
||||
// A rejection nobody caught would fail the run; settling the queue proves both were caught.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(downloadFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { openFile } from '../../../utils/fileDownload';
|
||||
import { downloadFile, openFile } from '../../../utils/fileDownload';
|
||||
|
||||
export type Attachment = { url: string; original_name: string };
|
||||
|
||||
/**
|
||||
* Opens a file attached to a booking. The desktop panel passes `onError` to say
|
||||
* so with a toast when the file cannot be opened; the phone's bookings and
|
||||
* transports cards pass nothing and stay silent, as they always have.
|
||||
*/
|
||||
export function openAttachment(f: { url: string; original_name: string }, onError?: () => void): void {
|
||||
const opening = openFile(f.url, f.original_name);
|
||||
void (onError ? opening.catch(onError) : opening);
|
||||
export function openAttachment(f: Attachment, onError?: () => void): void {
|
||||
settle(openFile(f.url, f.original_name), onError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a file attached to a booking under its original name, where opening it
|
||||
* would only show it in a viewer (a PDF ticket on a phone, say). Failures are
|
||||
* reported the same way as for opening.
|
||||
*/
|
||||
export function downloadAttachment(f: Attachment, onError?: () => void): void {
|
||||
settle(downloadFile(f.url, f.original_name), onError);
|
||||
}
|
||||
|
||||
function settle(pending: Promise<void>, onError?: () => void): void {
|
||||
// Without a handler the rejection is still caught, so it never surfaces as unhandled.
|
||||
void pending.catch(() => onError?.());
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
// FE-PLANNER-RESLISTFILTER-005 to -009: what one card of the phone's bookings
|
||||
// and transports tabs does.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildBudgetItem, buildReservation } from '../../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useTripStore } from '../../../store/tripStore';
|
||||
import type { Reservation } from '../../../types';
|
||||
import { openFile } from '../../../utils/fileDownload';
|
||||
import { downloadFile, openFile } from '../../../utils/fileDownload';
|
||||
import { useReservationCard } from './useReservationCard';
|
||||
|
||||
vi.mock('../../../utils/fileDownload', () => ({ openFile: vi.fn(() => Promise.resolve()) }));
|
||||
vi.mock('../../../utils/fileDownload', () => ({
|
||||
openFile: vi.fn(() => Promise.resolve()),
|
||||
downloadFile: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const traveler = (user_id: number) => ({ user_id, username: `u${user_id}` });
|
||||
const ann = buildReservation({
|
||||
@@ -36,7 +38,10 @@ describe('useReservationCard', () => {
|
||||
t: (key: string) => key,
|
||||
});
|
||||
|
||||
beforeEach(() => vi.mocked(openFile).mockClear());
|
||||
beforeEach(() => {
|
||||
vi.mocked(openFile).mockClear();
|
||||
vi.mocked(downloadFile).mockClear();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESLISTFILTER-005: with the blur preference on the code is blurred until toggled', () => {
|
||||
const { result } = renderHook(() => useReservationCard(ann, host(true)));
|
||||
@@ -70,25 +75,14 @@ describe('useReservationCard', () => {
|
||||
await vi.waitFor(() => expect(opts.toast.error).toHaveBeenCalledWith('reservations.toast.deleteError'));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESLISTFILTER-008: a file chip opens its file on click, Enter or Space and stops there', () => {
|
||||
it('FE-PLANNER-RESLISTFILTER-008: a file opens or downloads under its own name (#2585)', () => {
|
||||
const { result } = renderHook(() => useReservationCard(ann, host()));
|
||||
const chip = result.current.fileChip(file);
|
||||
const click = { stopPropagation: vi.fn() } as unknown as MouseEvent;
|
||||
chip.onClick(click);
|
||||
expect(click.stopPropagation).toHaveBeenCalled();
|
||||
const key = (k: string) =>
|
||||
({ key: k, preventDefault: vi.fn(), stopPropagation: vi.fn() }) as unknown as KeyboardEvent;
|
||||
const enter = key('Enter');
|
||||
chip.onKeyDown(enter);
|
||||
chip.onKeyDown(key(' '));
|
||||
const tab = key('Tab');
|
||||
chip.onKeyDown(tab);
|
||||
expect(enter.preventDefault).toHaveBeenCalled();
|
||||
expect(enter.stopPropagation).toHaveBeenCalled();
|
||||
expect(tab.preventDefault).not.toHaveBeenCalled();
|
||||
expect(tab.stopPropagation).not.toHaveBeenCalled();
|
||||
expect(openFile).toHaveBeenCalledTimes(3);
|
||||
result.current.openFile(file);
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/f/1', 'voucher.pdf');
|
||||
expect(downloadFile).not.toHaveBeenCalled();
|
||||
result.current.downloadFile(file);
|
||||
expect(downloadFile).toHaveBeenCalledWith('/uploads/f/1', 'voucher.pdf');
|
||||
expect(openFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESLISTFILTER-009: the card knows whether deleting it takes a linked expense along (#2604)', () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, type KeyboardEvent, type MouseEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useTripStore } from '../../../store/tripStore';
|
||||
import type { Reservation } from '../../../types';
|
||||
import { deleteBodyKey } from './bookingsModel';
|
||||
import { openAttachment } from './openAttachment';
|
||||
import { type Attachment, downloadAttachment, openAttachment } from './openAttachment';
|
||||
import { useReservationDelete } from './useReservationDelete';
|
||||
|
||||
/** What a phone card needs from the planner it sits in. */
|
||||
@@ -19,10 +19,9 @@ export interface ReservationCardHost {
|
||||
* opening: its booking code blurred until tapped while the "Blur booking codes"
|
||||
* preference is on (off, the code is plain and has no toggle), the delete
|
||||
* behind a question with a toast when it fails, and its attached files, which
|
||||
* open from a chip inside the card's own button (so a click or Enter/Space on
|
||||
* the chip stops there). A file that cannot be opened stays silent here; the
|
||||
* desktop panel toasts it. The delete question names a linked expense, which
|
||||
* goes with the booking.
|
||||
* open or download from their own row. A file that cannot be opened or saved
|
||||
* stays silent here; the desktop panel toasts it. The delete question names a
|
||||
* linked expense, which goes with the booking.
|
||||
*/
|
||||
export function useReservationCard(res: Reservation, host: ReservationCardHost) {
|
||||
const blurCodes = host.settings.blur_booking_codes;
|
||||
@@ -45,18 +44,7 @@ export function useReservationCard(res: Reservation, host: ReservationCardHost)
|
||||
confirmDelete: () => {
|
||||
void confirmDelete();
|
||||
},
|
||||
fileChip: (f: { url: string; original_name: string }) => ({
|
||||
onClick: (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openAttachment(f);
|
||||
},
|
||||
onKeyDown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openAttachment(f);
|
||||
}
|
||||
},
|
||||
}),
|
||||
openFile: (f: Attachment) => openAttachment(f),
|
||||
downloadFile: (f: Attachment) => downloadAttachment(f),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,9 +185,9 @@ function BookingCard({ res, planner, canEdit, compact, onView }: {
|
||||
)}
|
||||
|
||||
<CardNotes notes={res.notes} />
|
||||
|
||||
<CardFiles files={files} card={card} t={t} />
|
||||
</button>
|
||||
|
||||
<CardFiles files={files} card={card} t={t} />
|
||||
</div>
|
||||
)}
|
||||
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
|
||||
|
||||
@@ -29,7 +29,8 @@ function cardState(overrides: Partial<ReturnType<typeof useReservationCard>> = {
|
||||
askDelete: vi.fn(),
|
||||
cancelDelete: vi.fn(),
|
||||
confirmDelete: vi.fn(),
|
||||
fileChip: () => ({ onClick: vi.fn(), onKeyDown: vi.fn() }),
|
||||
openFile: vi.fn(),
|
||||
downloadFile: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as ReturnType<typeof useReservationCard>;
|
||||
}
|
||||
@@ -130,18 +131,30 @@ describe('MReservationCardParts', () => {
|
||||
expect(toggleCode).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('FE-COMP-MRESCARD-006: files render as button-role chips, and no files render nothing', () => {
|
||||
it('FE-COMP-MRESCARD-006: each file row opens the file by its name and downloads it by the icon (#2585)', () => {
|
||||
const { container, rerender } = render(<CardFiles files={[]} card={cardState()} t={t} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
const files = [{ id: 5, original_name: 'ticket.pdf', url: '/uploads/f/5' }] as Parameters<
|
||||
typeof CardFiles
|
||||
>[0]['files'];
|
||||
rerender(<CardFiles files={files} card={cardState()} t={t} />);
|
||||
const card = cardState();
|
||||
rerender(<CardFiles files={files} card={card} t={t} />);
|
||||
expect(screen.getByText('files.title')).toBeInTheDocument();
|
||||
const chip = screen.getByRole('button', { name: 'ticket.pdf' });
|
||||
expect(chip.tagName).toBe('SPAN');
|
||||
expect(chip).toHaveAttribute('tabindex', '0');
|
||||
|
||||
const open = screen.getByRole('button', { name: 'ticket.pdf' });
|
||||
const download = screen.getByRole('button', { name: 'files.download: ticket.pdf' });
|
||||
// Two sibling buttons, never one inside the other.
|
||||
expect(open.tagName).toBe('BUTTON');
|
||||
expect(open.contains(download)).toBe(false);
|
||||
expect(download.closest('button')).toBe(download);
|
||||
|
||||
fireEvent.click(open);
|
||||
expect(card.openFile).toHaveBeenCalledWith(files[0]);
|
||||
expect(card.downloadFile).not.toHaveBeenCalled();
|
||||
fireEvent.click(download);
|
||||
expect(card.downloadFile).toHaveBeenCalledWith(files[0]);
|
||||
expect(card.openFile).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('FE-COMP-MRESCARD-007: the delete sheet names the reservation and confirms through the card', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, type LucideIcon, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Download, FileText, type LucideIcon, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard';
|
||||
import type { useTranslation } from '../../../../i18n';
|
||||
@@ -163,7 +163,11 @@ export function CardNotes({ notes }: { notes: string | null | undefined }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Attached files, listed inside the card body button. */
|
||||
/**
|
||||
* Attached files, under the card body button rather than inside it, so each row
|
||||
* can hold two real buttons: the name opens the file, the icon at the end saves
|
||||
* it (a PDF ticket that only opens in a viewer cannot be kept otherwise).
|
||||
*/
|
||||
export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFor>; card: CardState; t: T }) {
|
||||
if (files.length === 0) return null;
|
||||
return (
|
||||
@@ -172,21 +176,30 @@ export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFo
|
||||
{t('files.title')}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* A span with a button role, not a <button>: the whole card body
|
||||
is already one, and buttons cannot nest. */}
|
||||
{files.map((f) => (
|
||||
<span
|
||||
<div
|
||||
key={f.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
{...card.fileChip(f)}
|
||||
className="flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]"
|
||||
className="flex items-stretch overflow-hidden rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card"
|
||||
>
|
||||
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">
|
||||
{f.original_name}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => card.openFile(f)}
|
||||
className="flex min-w-0 flex-1 items-center gap-[6px] py-[7px] pe-1 ps-[10px] text-start"
|
||||
>
|
||||
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">
|
||||
{f.original_name}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => card.downloadFile(f)}
|
||||
aria-label={`${t('files.download')}: ${f.original_name}`}
|
||||
className="grid w-9 flex-none place-items-center text-m-muted"
|
||||
>
|
||||
<Download size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -194,9 +194,9 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
|
||||
|
||||
<CardNotes notes={res.notes} />
|
||||
|
||||
<CardFiles files={files} card={card} t={t} />
|
||||
</button>
|
||||
|
||||
<CardFiles files={files} card={card} t={t} />
|
||||
</div>
|
||||
)}
|
||||
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
|
||||
|
||||
@@ -17,6 +17,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.many': '{count} ملفًا لـ {trip}',
|
||||
'files.download': 'تنزيل',
|
||||
'files.openError': 'تعذر فتح الملف',
|
||||
'files.downloadError': 'تعذر تنزيل الملف',
|
||||
'files.downloadPdf': 'تنزيل PDF',
|
||||
'files.count': '{count} ملف',
|
||||
'files.count.zero': 'لا توجد ملفات',
|
||||
|
||||
@@ -14,6 +14,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{trip} üçün {count} fayl',
|
||||
'files.download': 'Endir',
|
||||
'files.openError': 'Faylı açmaq mümkün olmadı',
|
||||
'files.downloadError': 'Faylı yükləmək mümkün olmadı',
|
||||
'files.downloadPdf': 'PDF-i endir',
|
||||
'files.count': '{count} fayl',
|
||||
'files.count.one': '{count} fayl',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} arquivo para {trip}',
|
||||
'files.download': 'Baixar',
|
||||
'files.openError': 'Não foi possível abrir o arquivo',
|
||||
'files.downloadError': 'Não foi possível baixar o arquivo',
|
||||
'files.downloadPdf': 'Baixar PDF',
|
||||
'files.count': '{count} arquivos',
|
||||
'files.count.one': '{count} arquivo',
|
||||
|
||||
@@ -7,6 +7,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} fitxer per a {trip}',
|
||||
'files.download': 'Baixa',
|
||||
'files.openError': "No s'ha pogut obrir el fitxer",
|
||||
'files.downloadError': "No s'ha pogut baixar el fitxer",
|
||||
'files.downloadPdf': 'Baixa el PDF',
|
||||
'files.count': '{count} fitxers',
|
||||
'files.count.one': '{count} fitxer',
|
||||
|
||||
@@ -14,6 +14,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.few': '{count} soubory pro {trip}',
|
||||
'files.download': 'Stáhnout',
|
||||
'files.openError': 'Soubor nelze otevřít',
|
||||
'files.downloadError': 'Soubor nelze stáhnout',
|
||||
'files.downloadPdf': 'Stáhnout PDF',
|
||||
'files.count': '{count} souborů',
|
||||
'files.count.one': '{count} soubor',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} Datei für {trip}',
|
||||
'files.download': 'Herunterladen',
|
||||
'files.openError': 'Datei konnte nicht geöffnet werden',
|
||||
'files.downloadError': 'Datei konnte nicht heruntergeladen werden',
|
||||
'files.downloadPdf': 'PDF herunterladen',
|
||||
'files.count': '{count} Dateien',
|
||||
'files.count.one': '{count} Datei',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} file for {trip}',
|
||||
'files.download': 'Download',
|
||||
'files.openError': 'Could not open file',
|
||||
'files.downloadError': 'Could not download file',
|
||||
'files.downloadPdf': 'Download PDF',
|
||||
'files.count': '{count} files',
|
||||
'files.count.one': '{count} file',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} archivo para {trip}',
|
||||
'files.download': 'Descargar',
|
||||
'files.openError': 'No se pudo abrir el archivo',
|
||||
'files.downloadError': 'No se pudo descargar el archivo',
|
||||
'files.downloadPdf': 'Descargar PDF',
|
||||
'files.count': '{count} archivos',
|
||||
'files.count.one': '{count} archivo',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} fail reisile {trip}',
|
||||
'files.download': 'Laadi alla',
|
||||
'files.openError': 'Faili avamine ebaõnnestus',
|
||||
'files.downloadError': 'Faili allalaadimine ebaõnnestus',
|
||||
'files.downloadPdf': 'Laadi PDF alla',
|
||||
'files.count': '{count} faili',
|
||||
'files.count.one': '{count} fail',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} fichier pour {trip}',
|
||||
'files.download': 'Télécharger',
|
||||
'files.openError': "Impossible d'ouvrir le fichier",
|
||||
'files.downloadError': 'Impossible de télécharger le fichier',
|
||||
'files.downloadPdf': 'Télécharger le PDF',
|
||||
'files.count': '{count} fichiers',
|
||||
'files.count.one': '{count} fichier',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} αρχείο για {trip}',
|
||||
'files.download': 'Λήψη',
|
||||
'files.openError': 'Δεν ήταν δυνατό το άνοιγμα του αρχείου',
|
||||
'files.downloadError': 'Δεν ήταν δυνατή η λήψη του αρχείου',
|
||||
'files.downloadPdf': 'Λήψη PDF',
|
||||
'files.count': '{count} αρχεία',
|
||||
'files.count.one': '{count} αρχείο',
|
||||
|
||||
@@ -14,6 +14,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.two': 'שני קבצים עבור {trip}',
|
||||
'files.download': 'הורדה',
|
||||
'files.openError': 'לא ניתן לפתוח את הקובץ',
|
||||
'files.downloadError': 'לא ניתן להוריד את הקובץ',
|
||||
'files.downloadPdf': 'הורדת PDF',
|
||||
'files.count': '{count} קבצים',
|
||||
'files.count.one': 'קובץ אחד',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} fájl a következőhöz: {trip}',
|
||||
'files.download': 'Letöltés',
|
||||
'files.openError': 'A fájl megnyitása sikertelen',
|
||||
'files.downloadError': 'A fájl letöltése sikertelen',
|
||||
'files.downloadPdf': 'PDF letöltése',
|
||||
'files.count': '{count} fájl',
|
||||
'files.count.one': '{count} fájl',
|
||||
|
||||
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle': '{count} file untuk {trip}',
|
||||
'files.download': 'Unduh',
|
||||
'files.openError': 'Tidak dapat membuka file',
|
||||
'files.downloadError': 'Tidak dapat mengunduh file',
|
||||
'files.downloadPdf': 'Unduh PDF',
|
||||
'files.count': '{count} file',
|
||||
'files.uploaded': '{count} diunggah',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} file per {trip}',
|
||||
'files.download': 'Scarica',
|
||||
'files.openError': 'Impossibile aprire il file',
|
||||
'files.downloadError': 'Impossibile scaricare il file',
|
||||
'files.downloadPdf': 'Scarica PDF',
|
||||
'files.count': '{count} file',
|
||||
'files.count.one': '{count} file', // same-as-en
|
||||
|
||||
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle': '{trip} のファイル {count} 件',
|
||||
'files.download': 'ダウンロード',
|
||||
'files.openError': 'ファイルを開けませんでした',
|
||||
'files.downloadError': 'ファイルをダウンロードできませんでした',
|
||||
'files.downloadPdf': 'PDFをダウンロード',
|
||||
'files.count': '{count}件のファイル',
|
||||
'files.uploaded': '{count}件アップロードしました',
|
||||
|
||||
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle': '{trip}의 파일 {count}개',
|
||||
'files.download': '다운로드',
|
||||
'files.openError': '파일을 열 수 없습니다',
|
||||
'files.downloadError': '파일을 다운로드할 수 없습니다',
|
||||
'files.downloadPdf': 'PDF 다운로드',
|
||||
'files.count': '파일 {count}개',
|
||||
'files.uploaded': '{count}개 업로드됨',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} bestand voor {trip}',
|
||||
'files.download': 'Downloaden',
|
||||
'files.openError': 'Bestand kon niet worden geopend',
|
||||
'files.downloadError': 'Bestand kon niet worden gedownload',
|
||||
'files.downloadPdf': 'PDF downloaden',
|
||||
'files.count': '{count} bestanden',
|
||||
'files.count.one': '{count} bestand',
|
||||
|
||||
@@ -15,6 +15,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.many': '{count} plików z podróży {trip}',
|
||||
'files.download': 'Pobierz',
|
||||
'files.openError': 'Nie można otworzyć pliku',
|
||||
'files.downloadError': 'Nie można pobrać pliku',
|
||||
'files.downloadPdf': 'Pobierz PDF',
|
||||
'files.count': '{count} pliku',
|
||||
'files.count.one': '{count} plik',
|
||||
|
||||
@@ -15,6 +15,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.many': '{count} файлов для {trip}',
|
||||
'files.download': 'Скачать',
|
||||
'files.openError': 'Не удалось открыть файл',
|
||||
'files.downloadError': 'Не удалось скачать файл',
|
||||
'files.downloadPdf': 'Скачать PDF',
|
||||
'files.count': '{count} файла',
|
||||
'files.count.one': '{count} файл',
|
||||
|
||||
@@ -14,6 +14,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.few': '{count} súbory pre {trip}',
|
||||
'files.download': 'Stiahnuť',
|
||||
'files.openError': 'Súbor sa nedá otvoriť',
|
||||
'files.downloadError': 'Súbor sa nedá stiahnuť',
|
||||
'files.downloadPdf': 'Stiahnuť PDF',
|
||||
'files.count': '{count} súborov',
|
||||
'files.count.one': '{count} súbor',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{count} fil för {trip}',
|
||||
'files.download': 'Ladda ner',
|
||||
'files.openError': 'Kunde inte öppna fil',
|
||||
'files.downloadError': 'Kunde inte ladda ner filen',
|
||||
'files.downloadPdf': 'Ladda ner PDF',
|
||||
'files.count': '{count} filer',
|
||||
'files.count.one': '{count} fil',
|
||||
|
||||
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle': '{count} ไฟล์สำหรับ {trip}',
|
||||
'files.download': 'ดาวน์โหลด',
|
||||
'files.openError': 'ไม่สามารถเปิดไฟล์ได้',
|
||||
'files.downloadError': 'ไม่สามารถดาวน์โหลดไฟล์ได้',
|
||||
'files.downloadPdf': 'ดาวน์โหลดไฟล์ PDF',
|
||||
'files.count': '{count} ไฟล์',
|
||||
'files.uploaded': 'อัปโหลดแล้ว {count} ไฟล์',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.one': '{trip} için {count} dosya',
|
||||
'files.download': 'İndir',
|
||||
'files.openError': 'Dosya açılamadı',
|
||||
'files.downloadError': 'Dosya indirilemedi',
|
||||
'files.downloadPdf': 'PDF İndir',
|
||||
'files.count': '{count} dosya',
|
||||
'files.count.one': '{count} dosya',
|
||||
|
||||
@@ -15,6 +15,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle.many': '{count} файлів для {trip}',
|
||||
'files.download': 'Завантажити',
|
||||
'files.openError': 'Не вдалося відкрити файл',
|
||||
'files.downloadError': 'Не вдалося завантажити файл',
|
||||
'files.downloadPdf': 'Завантажити PDF',
|
||||
'files.count': '{count} файлу',
|
||||
'files.count.one': '{count} файл',
|
||||
|
||||
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle': '{count} tệp cho {trip}',
|
||||
'files.download': 'Tải xuống',
|
||||
'files.openError': 'Không thể mở tập tin',
|
||||
'files.downloadError': 'Không thể tải xuống tập tin',
|
||||
'files.downloadPdf': 'Tải xuống PDF',
|
||||
'files.count': '{count} tập tin',
|
||||
'files.uploaded': 'Đã tải lên {count} tệp',
|
||||
|
||||
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle': '{trip} 的 {count} 個檔案',
|
||||
'files.download': '下載',
|
||||
'files.openError': '無法開啟檔案',
|
||||
'files.downloadError': '無法下載檔案',
|
||||
'files.downloadPdf': '下載 PDF',
|
||||
'files.count': '{count} 個檔案',
|
||||
'files.uploaded': '已上傳 {count} 個檔案',
|
||||
|
||||
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
|
||||
'files.subtitle': '{trip} 的 {count} 个文件',
|
||||
'files.download': '下载',
|
||||
'files.openError': '无法打开文件',
|
||||
'files.downloadError': '无法下载文件',
|
||||
'files.downloadPdf': '下载 PDF',
|
||||
'files.count': '{count} 个文件',
|
||||
'files.uploaded': '已上传 {count} 个文件',
|
||||
|
||||
Reference in New Issue
Block a user