mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -912,3 +912,122 @@ describe('downloadTripPDF defaults', () => {
|
||||
expect(srcdoc()).toContain('Day 1')
|
||||
})
|
||||
})
|
||||
|
||||
// #1292 — every day started a new page, so a plan of short days printed one
|
||||
// sheet per handful of lines. The flowing layout is opt-in and remembered.
|
||||
describe('page breaks between days (#1292)', () => {
|
||||
const twoDays = {
|
||||
...minimalArgs,
|
||||
days: [
|
||||
{ id: 1, day_number: 1, title: 'First', date: '2025-06-01' },
|
||||
{ id: 2, day_number: 2, title: 'Second', date: '2025-06-02' },
|
||||
] as any[],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem('trek_pdf_page_break_per_day')
|
||||
})
|
||||
|
||||
const toggle = () =>
|
||||
document.querySelector<HTMLButtonElement>('#pdf-daybreak-toggle')
|
||||
const isOn = () => toggle()!.getAttribute('aria-checked') === 'true'
|
||||
|
||||
it('FE-PDF-BREAK-001: days break onto their own page by default', async () => {
|
||||
await downloadTripPDF(twoDays)
|
||||
const html = getIframe()!.srcdoc
|
||||
|
||||
expect(html).toContain('day-section day-break')
|
||||
expect(html).not.toContain('class="pdf-flow"')
|
||||
expect(isOn()).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-PDF-BREAK-001b: the control is the switch from the rest of the app, not a checkbox', async () => {
|
||||
await downloadTripPDF(twoDays)
|
||||
const el = toggle()!
|
||||
|
||||
expect(el.tagName).toBe('BUTTON')
|
||||
expect(el.getAttribute('role')).toBe('switch')
|
||||
expect(el.style.background).toContain('--accent')
|
||||
expect(document.querySelector('#pdf-daybreak-toggle input')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PDF-BREAK-002: the remembered flowing layout comes back on the next export', async () => {
|
||||
localStorage.setItem('trek_pdf_page_break_per_day', '0')
|
||||
await downloadTripPDF(twoDays)
|
||||
const html = getIframe()!.srcdoc
|
||||
|
||||
// The class stays on the day; the body is what decides whether it breaks.
|
||||
expect(html).toContain('day-section day-break')
|
||||
expect(html).toContain('<body class="pdf-flow">')
|
||||
expect(isOn()).toBe(false)
|
||||
// Off uses the neutral track, so the two states are told apart by more than the knob.
|
||||
expect(toggle()!.style.background).toContain('--border-primary')
|
||||
})
|
||||
|
||||
it('FE-PDF-BREAK-003: the first day never carries a break of its own', async () => {
|
||||
await downloadTripPDF(twoDays)
|
||||
const html = getIframe()!.srcdoc
|
||||
|
||||
expect(html.indexOf('<table class="day-section">')).toBeGreaterThan(-1)
|
||||
expect(html.match(/day-section day-break/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-PDF-BREAK-004: turning the breaks off is remembered and shown at once', async () => {
|
||||
await downloadTripPDF(twoDays)
|
||||
toggle()!.click()
|
||||
|
||||
expect(isOn()).toBe(false)
|
||||
expect(localStorage.getItem('trek_pdf_page_break_per_day')).toBe('0')
|
||||
const body = getIframe()!.contentDocument?.body
|
||||
if (body) expect(body.classList.contains('pdf-flow')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-PDF-BREAK-005: turning them back on is remembered too', async () => {
|
||||
localStorage.setItem('trek_pdf_page_break_per_day', '0')
|
||||
await downloadTripPDF(twoDays)
|
||||
toggle()!.click()
|
||||
|
||||
expect(isOn()).toBe(true)
|
||||
expect(localStorage.getItem('trek_pdf_page_break_per_day')).toBe('1')
|
||||
const body = getIframe()!.contentDocument?.body
|
||||
if (body) expect(body.classList.contains('pdf-flow')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-PDF-BREAK-006: a storage the browser blocks costs the preference, not the export', async () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked') })
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked') })
|
||||
|
||||
await downloadTripPDF(twoDays)
|
||||
const html = getIframe()!.srcdoc
|
||||
expect(html).toContain('day-section day-break')
|
||||
expect(html).not.toContain('class="pdf-flow"')
|
||||
|
||||
expect(() => toggle()!.click()).not.toThrow()
|
||||
})
|
||||
|
||||
// Verified against a real print engine: without this rule Chromium tore a stay
|
||||
// across the page edge, the check-in time on one sheet and the hotel name on
|
||||
// the next. Place and note cards already carried it.
|
||||
it('FE-PDF-BREAK-007: a stay may not be split by a page edge', async () => {
|
||||
await downloadTripPDF(twoDays)
|
||||
const html = getIframe()!.srcdoc
|
||||
const rule = html.slice(html.indexOf('.day-accommodation {'))
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('break-inside: avoid')
|
||||
})
|
||||
|
||||
// Measured against Chromium: with the days flowing, a header bar could be left
|
||||
// at the foot of a sheet while its content moved on, and the repeat from #1471
|
||||
// then read as the same day printed twice. break-after on the thead and
|
||||
// break-before on the tbody are both ignored; holding the day together is what
|
||||
// works, and a day too long for one page still breaks and still repeats.
|
||||
it('FE-PDF-BREAK-008: a flowing day is held together so its header is never stranded', async () => {
|
||||
localStorage.setItem('trek_pdf_page_break_per_day', '0')
|
||||
await downloadTripPDF(twoDays)
|
||||
const html = getIframe()!.srcdoc
|
||||
|
||||
expect(html).toContain('.pdf-flow .day-section { break-inside: avoid; page-break-inside: avoid; }')
|
||||
// Scoped: a day that starts its own page cannot strand a header.
|
||||
const at = html.indexOf('.day-section { break-inside: avoid')
|
||||
expect(html.slice(at - 10, at)).toContain('.pdf-flow ')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,37 @@ import { formatMoney, formatMoneySum, splitReservationDateTime, type MoneyEntry
|
||||
import { fetchExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
* one sheet per handful of lines, so the preview offers the flowing layout the
|
||||
* in-app view already uses, and remembers which one was picked (#1292).
|
||||
*/
|
||||
const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day'
|
||||
|
||||
function pageBreakPerDay(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(PAGE_BREAK_KEY) !== '0'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function rememberPageBreakPerDay(on: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(PAGE_BREAK_KEY, on ? '1' : '0')
|
||||
} catch {
|
||||
// A blocked localStorage costs the preference, not the export.
|
||||
}
|
||||
}
|
||||
|
||||
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
|
||||
rebuilt here rather than rendered — as static markup it would carry no
|
||||
behaviour. Same geometry and the same tokens, so it reads as the same control. */
|
||||
const TOGGLE_TRACK = 'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;'
|
||||
const TOGGLE_KNOB = 'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);'
|
||||
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)')
|
||||
const knobOffset = (on: boolean) => (on ? '22px' : '2px')
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
return _renderToStaticMarkup(
|
||||
@@ -150,6 +181,7 @@ interface downloadTripPDFProps {
|
||||
// relies on it being an object.
|
||||
export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) {
|
||||
await ensureRenderer()
|
||||
const breaksPerDay = pageBreakPerDay()
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
||||
@@ -403,7 +435,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
// every page an overflowing day spills onto (#1471). CSS `table-header-group`
|
||||
// on a <div> is NOT repeated by Chromium's print engine — only real thead is.
|
||||
return `
|
||||
<table class="day-section${di > 0 ? ' page-break' : ''}">
|
||||
<table class="day-section${di > 0 ? ' day-break' : ''}">
|
||||
<thead class="day-header"><tr><td>
|
||||
<div class="day-header-bar">
|
||||
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
|
||||
@@ -505,6 +537,20 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
/* ── Day ───────────────────────────────────────── */
|
||||
/* .day-section is a real <table>; its <thead> day header repeats on overflow pages. */
|
||||
.page-break { page-break-before: always; }
|
||||
/* Days break by default; .pdf-flow on <body> is the toggle in the preview (#1292).
|
||||
Flowing days butt against each other without the page edge between them. */
|
||||
.day-break { page-break-before: always; }
|
||||
.pdf-flow .day-break { page-break-before: auto; }
|
||||
.pdf-flow .day-section + .day-section { margin-top: 18px; }
|
||||
/* Hold a flowing day together. Without this the header bar can be placed at the
|
||||
foot of a sheet while its content moves to the next one, which then repeats
|
||||
the header (#1471) and reads as the same day printed twice. A day too long
|
||||
for one page still breaks and still repeats its header — the engine only
|
||||
honours this where it can. Days that start a page of their own cannot strand
|
||||
a header, so it is scoped to the flowing layout.
|
||||
Measured, not assumed: break-after on the thead and break-before on the tbody
|
||||
are both ignored by Chromium here. */
|
||||
.pdf-flow .day-section { break-inside: avoid; page-break-inside: avoid; }
|
||||
.day-section { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.day-header-bar {
|
||||
background: #0f172a; padding: 11px 28px;
|
||||
@@ -528,6 +574,10 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
flex: 1 1 45%; min-width: 200px; margin: 4px 0; padding: 10px;
|
||||
border: 2px solid #e2e8f0; border-radius: 12px;
|
||||
display: flex; flex-direction: column;
|
||||
/* Place and note cards have said this all along; without it a stay is torn
|
||||
in half by a page edge, its check-in time on one sheet and the hotel name
|
||||
on the next. Rare while every day started a page, common once they flow. */
|
||||
break-inside: avoid; page-break-inside: avoid;
|
||||
}
|
||||
.day-accommodation-title {
|
||||
font-size: 16px; font-weight: 600; text-align: center;
|
||||
@@ -608,7 +658,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body${breaksPerDay ? '' : ' class="pdf-flow"'}>
|
||||
|
||||
<!-- Footer on every page -->
|
||||
<div class="pdf-footer">
|
||||
@@ -666,10 +716,14 @@ ${pluginSectionsHtml}
|
||||
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<label for="pdf-daybreak-toggle" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escHtml(tr('pdf.pageBreakPerDay'))}</label>
|
||||
<button id="pdf-daybreak-toggle" type="button" role="switch" aria-checked="${breaksPerDay}" aria-label="${escHtml(tr('pdf.pageBreakPerDay'))}" style="${TOGGLE_TRACK} background:${trackColour(breaksPerDay)}">
|
||||
<span style="${TOGGLE_KNOB} left:${knobOffset(breaksPerDay)}"></span>
|
||||
</button>
|
||||
<button id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
|
||||
<button id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
@@ -693,4 +747,17 @@ ${pluginSectionsHtml}
|
||||
if (closeBtn) closeBtn.onclick = () => overlay.remove()
|
||||
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn')
|
||||
if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() }
|
||||
|
||||
// The two layouts differ by one class, so switching is instant in the preview
|
||||
// and there is no need to re-fetch photos or rebuild the document.
|
||||
const dayBreakSwitch = header.querySelector<HTMLButtonElement>('#pdf-daybreak-toggle')
|
||||
if (dayBreakSwitch) dayBreakSwitch.onclick = () => {
|
||||
const on = dayBreakSwitch.getAttribute('aria-checked') !== 'true'
|
||||
dayBreakSwitch.setAttribute('aria-checked', String(on))
|
||||
dayBreakSwitch.style.background = trackColour(on)
|
||||
const knob = dayBreakSwitch.firstElementChild as HTMLElement | null
|
||||
if (knob) knob.style.left = knobOffset(on)
|
||||
rememberPageBreakPerDay(on)
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
const [lockHoverId, setLockHoverId] = useState(null)
|
||||
const [undoHover, setUndoHover] = useState(false)
|
||||
const [pdfHover, setPdfHover] = useState(false)
|
||||
const [icsHover, setIcsHover] = useState(false)
|
||||
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
|
||||
// Transit rows fold their itinerary out inline (#1065).
|
||||
@@ -984,8 +983,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setLockHoverId,
|
||||
undoHover,
|
||||
setUndoHover,
|
||||
pdfHover,
|
||||
setPdfHover,
|
||||
icsHover,
|
||||
setIcsHover,
|
||||
hoveredAssignmentId,
|
||||
@@ -1155,8 +1152,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setLockHoverId,
|
||||
undoHover,
|
||||
setUndoHover,
|
||||
pdfHover,
|
||||
setPdfHover,
|
||||
icsHover,
|
||||
setIcsHover,
|
||||
hoveredAssignmentId,
|
||||
@@ -1267,8 +1262,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
t={t}
|
||||
locale={locale}
|
||||
toast={toast}
|
||||
pdfHover={pdfHover}
|
||||
setPdfHover={setPdfHover}
|
||||
icsHover={icsHover}
|
||||
setIcsHover={setIcsHover}
|
||||
expandedDays={expandedDays}
|
||||
|
||||
@@ -46,8 +46,6 @@ function makeProps(overrides: Partial<React.ComponentProps<typeof DayPlanSidebar
|
||||
t,
|
||||
locale: 'en-US',
|
||||
toast: makeToast(),
|
||||
pdfHover: false,
|
||||
setPdfHover: vi.fn((_v: boolean) => {}),
|
||||
icsHover: false,
|
||||
setIcsHover: vi.fn((_v: boolean) => {}),
|
||||
expandedDays: new Set<number>(),
|
||||
@@ -97,21 +95,29 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-003: hovering the PDF button reports the hover state upward', async () => {
|
||||
it('FE-PLANNER-DPTOOLBAR-003: hovering the PDF button shows the export tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setPdfHover = vi.fn((_v: boolean) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ setPdfHover })} />)
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('dayplan.pdf'))
|
||||
expect(setPdfHover).toHaveBeenCalledWith(true)
|
||||
await waitFor(() => expect(screen.getByText('dayplan.pdfTooltip')).toBeInTheDocument())
|
||||
await user.unhover(screen.getByText('dayplan.pdf'))
|
||||
expect(setPdfHover).toHaveBeenCalledWith(false)
|
||||
await waitFor(() => expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-004: pdfHover renders the export tooltip', () => {
|
||||
const { rerender } = render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument()
|
||||
rerender(<DayPlanSidebarToolbar {...makeProps({ pdfHover: true })} />)
|
||||
expect(screen.getByText('dayplan.pdfTooltip')).toBeInTheDocument()
|
||||
// The button sits at the right edge of the leftmost pane. Its own tooltip was
|
||||
// anchored with `right: 0` and never wrapped, so a long label ran off the left
|
||||
// of the window. The shared tooltip is portalled and clamped to the viewport.
|
||||
it('FE-PLANNER-DPTOOLBAR-004: the export tooltip stays inside the window', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('dayplan.pdf'))
|
||||
const tip = await screen.findByText('dayplan.pdfTooltip')
|
||||
expect(tip.closest('[role="tooltip"]')).not.toBeNull()
|
||||
expect(tip.parentElement).toBe(document.body)
|
||||
expect(tip.style.position).toBe('fixed')
|
||||
// Placed once measured, and never to the left of the window edge.
|
||||
await waitFor(() => expect(parseFloat(tip.style.left)).toBeGreaterThanOrEqual(0))
|
||||
})
|
||||
|
||||
// ── ICS menu ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,8 +22,6 @@ interface DayPlanSidebarToolbarProps {
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
toast: ReturnType<typeof useToast>
|
||||
pdfHover: boolean
|
||||
setPdfHover: (v: boolean) => void
|
||||
icsHover: boolean
|
||||
setIcsHover: (v: boolean) => void
|
||||
expandedDays: Set<number>
|
||||
@@ -41,7 +39,7 @@ interface DayPlanSidebarToolbarProps {
|
||||
export function DayPlanSidebarToolbar({
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast, pdfHover, setPdfHover, setIcsHover,
|
||||
t, locale, toast, setIcsHover,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, onReorderDays, onAddDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
@@ -74,6 +72,7 @@ export function DayPlanSidebarToolbar({
|
||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Tooltip label={t('dayplan.pdfTooltip')} placement="bottom">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
@@ -86,8 +85,6 @@ export function DayPlanSidebarToolbar({
|
||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setPdfHover(true)}
|
||||
onMouseLeave={() => setPdfHover(false)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
@@ -99,18 +96,7 @@ export function DayPlanSidebarToolbar({
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
{t('dayplan.pdf')}
|
||||
</button>
|
||||
{pdfHover && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px',
|
||||
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{t('dayplan.pdfTooltip')}
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
style={{ position: 'relative', flexShrink: 0 }}
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'التكلفة',
|
||||
'pdf.preview': 'معاينة PDF',
|
||||
'pdf.saveAsPdf': 'حفظ كـ PDF',
|
||||
'pdf.pageBreakPerDay': 'فاصل صفحة لكل يوم',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Custo',
|
||||
'pdf.preview': 'Pré-visualização do PDF',
|
||||
'pdf.saveAsPdf': 'Salvar como PDF',
|
||||
'pdf.pageBreakPerDay': 'Quebra de página por dia',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Cost EUR',
|
||||
'pdf.preview': 'Vista prèvia del PDF',
|
||||
'pdf.saveAsPdf': 'Desa com a PDF',
|
||||
'pdf.pageBreakPerDay': 'Salt de pàgina per dia',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Náklady',
|
||||
'pdf.preview': 'Náhled PDF',
|
||||
'pdf.saveAsPdf': 'Uložit jako PDF',
|
||||
'pdf.pageBreakPerDay': 'Zalomení stránky pro každý den',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Kosten',
|
||||
'pdf.preview': 'PDF Vorschau',
|
||||
'pdf.saveAsPdf': 'Als PDF speichern',
|
||||
'pdf.pageBreakPerDay': 'Seitenumbruch pro Tag',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Cost',
|
||||
'pdf.preview': 'PDF Preview',
|
||||
'pdf.saveAsPdf': 'Save as PDF',
|
||||
'pdf.pageBreakPerDay': 'Page break per day',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Coste',
|
||||
'pdf.preview': 'Vista previa PDF',
|
||||
'pdf.saveAsPdf': 'Guardar como PDF',
|
||||
'pdf.pageBreakPerDay': 'Salto de página por día',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Coût',
|
||||
'pdf.preview': 'Aperçu PDF',
|
||||
'pdf.saveAsPdf': 'Enregistrer en PDF',
|
||||
'pdf.pageBreakPerDay': 'Saut de page par jour',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Κόστος',
|
||||
'pdf.preview': 'Προεπισκόπηση PDF',
|
||||
'pdf.saveAsPdf': 'Αποθήκευση ως PDF',
|
||||
'pdf.pageBreakPerDay': 'Αλλαγή σελίδας ανά ημέρα',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Költség',
|
||||
'pdf.preview': 'PDF előnézet',
|
||||
'pdf.saveAsPdf': 'Mentés PDF-ként',
|
||||
'pdf.pageBreakPerDay': 'Oldaltörés naponta',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Biaya',
|
||||
'pdf.preview': 'Pratinjau PDF',
|
||||
'pdf.saveAsPdf': 'Simpan sebagai PDF',
|
||||
'pdf.pageBreakPerDay': 'Pemisah halaman per hari',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Costo',
|
||||
'pdf.preview': 'Anteprima PDF',
|
||||
'pdf.saveAsPdf': 'Salva come PDF',
|
||||
'pdf.pageBreakPerDay': 'Interruzione di pagina per giorno',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': '費用',
|
||||
'pdf.preview': 'PDFプレビュー',
|
||||
'pdf.saveAsPdf': 'PDFとして保存',
|
||||
'pdf.pageBreakPerDay': '日ごとに改ページ',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': '비용',
|
||||
'pdf.preview': 'PDF 미리보기',
|
||||
'pdf.saveAsPdf': 'PDF로 저장',
|
||||
'pdf.pageBreakPerDay': '날짜별 페이지 나누기',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Kosten',
|
||||
'pdf.preview': 'PDF-voorbeeld',
|
||||
'pdf.saveAsPdf': 'Opslaan als PDF',
|
||||
'pdf.pageBreakPerDay': 'Pagina-einde per dag',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Koszt',
|
||||
'pdf.preview': 'Podgląd PDF',
|
||||
'pdf.saveAsPdf': 'Zapisz jako PDF',
|
||||
'pdf.pageBreakPerDay': 'Podział strony dla każdego dnia',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Стоимость',
|
||||
'pdf.preview': 'Предпросмотр PDF',
|
||||
'pdf.saveAsPdf': 'Сохранить как PDF',
|
||||
'pdf.pageBreakPerDay': 'Разрыв страницы для каждого дня',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Kostnad',
|
||||
'pdf.preview': 'Förhandsgranskning av PDF',
|
||||
'pdf.saveAsPdf': 'Spara som PDF',
|
||||
'pdf.pageBreakPerDay': 'Sidbrytning per dag',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Maliyet',
|
||||
'pdf.preview': 'PDF Önizleme',
|
||||
'pdf.saveAsPdf': 'PDF olarak Kaydet',
|
||||
'pdf.pageBreakPerDay': 'Her gün için sayfa sonu',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Вартість',
|
||||
'pdf.preview': 'Попередній перегляд PDF',
|
||||
'pdf.saveAsPdf': 'Зберегти як PDF',
|
||||
'pdf.pageBreakPerDay': 'Розрив сторінки для кожного дня',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Chi phí',
|
||||
'pdf.preview': 'PDF Xem trước',
|
||||
'pdf.saveAsPdf': 'Lưu dưới dạng PDF',
|
||||
'pdf.pageBreakPerDay': 'Ngắt trang cho mỗi ngày',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': '費用',
|
||||
'pdf.preview': 'PDF 預覽',
|
||||
'pdf.saveAsPdf': '儲存為 PDF',
|
||||
'pdf.pageBreakPerDay': '每天分頁',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': '费用',
|
||||
'pdf.preview': 'PDF 预览',
|
||||
'pdf.saveAsPdf': '保存为 PDF',
|
||||
'pdf.pageBreakPerDay': '每天分页',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
Reference in New Issue
Block a user