feat(oxibadge): warn before losing unexported review edits, add undo
Check, format and test / build (push) Successful in 10m25s

The review screen now tracks whether there are edits not yet exported
and asks for confirmation before discarding them: clicking back, or
closing the app window entirely (intercepted via onCloseRequested,
which needed a core:window:allow-destroy capability that wasn't
granted by core:default). The confirmation dialog (ConfirmDialog.svelte)
is generic, built on a shared ModalFrame extracted from ExportModal so
the two don't duplicate the backdrop/box styling.

Also adds undo: a per-row button (icon-only, left of the note, shown
when that day has edits) reverts just that day instantly, and a
per-employee "undo all" (icon in a red box, last among the header's
tools) reverts every day for the current employee after a confirmation
dialog, since that one can't be undone itself.

ActionButton moves out of review/ next to IconButton/PillButton, since
ConfirmDialog (outside the review feature) now uses it too.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 13:46:31 +02:00
parent 25127bee96
commit 0c320c39b0
13 changed files with 421 additions and 104 deletions
@@ -0,0 +1,83 @@
<script lang="ts">
import ModalFrame from './ModalFrame.svelte';
import ActionButton from './ActionButton.svelte';
let {
fixed = false,
title,
message,
confirmLabel,
cancelLabel,
onconfirm,
oncancel
}: {
/** See `ModalFrame`'s `fixed` prop. */
fixed?: boolean;
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
onconfirm: () => void;
oncancel: () => void;
} = $props();
// Unique per instance, so two ConfirmDialogs never clash if ever shown
// at once (e.g. this one plus the app-wide "quit with unsaved changes" one).
const uid = $props.id();
</script>
<ModalFrame
{fixed}
titleId="{uid}-title"
descId="{uid}-desc"
closeAriaLabel={cancelLabel}
onclose={oncancel}
>
{#snippet icon()}
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
d="M12 9.5v4M12 17h.01"
/></svg
>
{/snippet}
<div class="modal-text">
<h2 id="{uid}-title">{title}</h2>
<p id="{uid}-desc">{message}</p>
</div>
<div class="modal-actions">
<ActionButton onclick={oncancel}>{cancelLabel}</ActionButton>
<ActionButton primary onclick={onconfirm}>{confirmLabel}</ActionButton>
</div>
</ModalFrame>
<style>
.modal-text {
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-text h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 22px;
line-height: 1.2;
letter-spacing: -0.02em;
color: #ffffff;
}
.modal-text p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #a3a3a3;
}
.modal-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -5,6 +5,7 @@
size = 'sm',
accent = false,
danger = false,
dangerFilled = false,
title,
ariaLabel,
onclick,
@@ -12,7 +13,10 @@
}: {
size?: 'sm' | 'lg';
accent?: boolean;
/** Subtle red tint on hover only — for a destructive icon among others, e.g. in a list row. */
danger?: boolean;
/** Permanent red box, like `accent` but red — for a standalone destructive action. */
dangerFilled?: boolean;
title?: string;
ariaLabel: string;
onclick: () => void;
@@ -26,6 +30,7 @@
class:lg={size === 'lg'}
class:accent
class:danger
class:danger-filled={dangerFilled}
{title}
aria-label={ariaLabel}
{onclick}
@@ -81,4 +86,18 @@
background: rgba(229, 72, 77, 0.14);
color: #ff8a8f;
}
.icon-btn.danger-filled {
background: rgba(229, 72, 77, 0.14);
color: #ff6b74;
}
.icon-btn.danger-filled:hover {
background: rgba(229, 72, 77, 0.22);
color: #ff8a8f;
}
.icon-btn.danger-filled:focus-visible {
outline-color: #e5484d;
}
</style>
@@ -0,0 +1,86 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
fixed = false,
titleId,
descId,
closeAriaLabel,
onclose,
icon,
children
}: {
/** Use for a modal that must sit above the whole app (e.g. shown from
* outside the screen it concerns), instead of within the nearest
* positioned ancestor. */
fixed?: boolean;
titleId: string;
descId?: string;
closeAriaLabel: string;
onclose: () => void;
icon?: Snippet;
children: Snippet;
} = $props();
</script>
<div class="modal-wrap" class:fixed>
<button type="button" class="modal-backdrop" aria-label={closeAriaLabel} onclick={onclose}></button>
<div class="modal" role="alertdialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descId}>
{#if icon}
<div class="modal-icon">{@render icon()}</div>
{/if}
{@render children()}
</div>
</div>
<style>
.modal-wrap {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.modal-wrap.fixed {
position: fixed;
z-index: 100;
}
.modal-backdrop {
all: unset;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
cursor: default;
}
.modal {
position: relative;
width: 100%;
max-width: 440px;
box-sizing: border-box;
padding: 24px;
background: #1a1a1a;
border: 1px solid #333333;
border-radius: 18px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
gap: 18px;
}
.modal-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(229, 72, 77, 0.14);
color: #ff6b74;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -23,7 +23,8 @@
import TimesheetTable from './review/TimesheetTable.svelte';
import ExportModal from './review/ExportModal.svelte';
import Toast from './review/Toast.svelte';
import ActionButton from './review/ActionButton.svelte';
import ActionButton from './ActionButton.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
let {
year,
@@ -32,7 +33,8 @@
shift,
onexport,
onrename,
onback
onback,
ondirtychange
}: {
year: number;
month: number;
@@ -41,6 +43,8 @@
onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void;
onrename?: (id: Employee['id'], name: string) => void;
onback?: () => void;
/** Called whenever there are (or stop being) edits not yet exported. */
ondirtychange?: (dirty: boolean) => void;
} = $props();
// Working copies: the component never mutates the props. Loading another
@@ -52,6 +56,11 @@
let names = $state<string[]>(untrack(() => employees.map((e) => e.name)));
/** "employee-day-slot-index" -> time produced by rounding */
let rounded = $state<Record<string, string>>({});
let hasUnexportedChanges = $state(false);
$effect(() => {
ondirtychange?.(hasUnexportedChanges);
});
// ---- UI state ----
let emp = $state(0);
@@ -61,6 +70,7 @@
let draft = $state('');
let menuOpen = $state(false);
let confirmScope = $state<'all' | 'one' | null>(null);
let confirmingUndoAll = $state(false);
let toast = $state('');
let toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -92,6 +102,7 @@
cell[ii] = next;
if (input.value !== next) input.value = next; // keep the field in sync even if the state doesn't change
}
hasUnexportedChanges = true;
}
/** Redistributes the day's punches into E1, U1, E2, U2 based on the expected shifts. */
@@ -100,6 +111,7 @@
const times = values[emp][di].flat().filter((x) => x !== '');
values[emp][di] = assignToShifts(times, shift);
}
hasUnexportedChanges = true;
}
function addLunch(dis: number[]) {
@@ -107,6 +119,7 @@
const span = lunchSpan(values[emp][di], shift);
if (span) values[emp][di] = [[span[0]], [shift.out1], [shift.in2], [span[1]]];
}
hasUnexportedChanges = true;
}
function roundDaysOf(ei: number, dis: number[]) {
@@ -119,6 +132,24 @@
})
) as Day;
}
hasUnexportedChanges = true;
}
/** Reverts one day back to what the time clock originally reported. */
function undoDay(di: number) {
values[emp][di] = orig[emp][di].map((c) => [...c]) as Day;
hasUnexportedChanges = true;
}
function requestUndoAll() {
confirmingUndoAll = true;
}
/** Reverts every day of the current employee back to the time clock's original data. */
function confirmUndoAll() {
confirmingUndoAll = false;
values[emp] = orig[emp].map((day) => day.map((c) => [...c]) as Day);
hasUnexportedChanges = true;
}
// ---- Name ----
@@ -150,6 +181,7 @@
});
confirmScope = null;
menuOpen = false;
hasUnexportedChanges = false;
toast = $_('review.export_done', {
values: { who: scope === 'all' ? $_('review.everyone') : names[emp] }
});
@@ -212,6 +244,7 @@
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (confirmScope) confirmScope = null;
else if (confirmingUndoAll) confirmingUndoAll = false;
else if (menuOpen) menuOpen = false;
else if (editingName) editingName = false;
}
@@ -238,6 +271,17 @@
/>
{/if}
{#if confirmingUndoAll}
<ConfirmDialog
title={$_('review.undo_all_confirm_title')}
message={$_('review.undo_all_confirm_message')}
confirmLabel={$_('review.undo_all_confirm')}
cancelLabel={$_('review.cancel')}
onconfirm={confirmUndoAll}
oncancel={() => (confirmingUndoAll = false)}
/>
{/if}
<Toast message={toast} />
{#if names.length === 0}
@@ -293,6 +337,7 @@
onfixall={() => fixDays(cur.stackedDays)}
onaddlunchall={() => addLunch(cur.lunchDays)}
onroundall={() => roundDaysOf(emp, cur.roundDays)}
onundoall={requestUndoAll}
bind:onlyIssues
/>
@@ -305,6 +350,7 @@
onfixday={(di) => fixDays([di])}
onaddlunch={(di) => addLunch([di])}
onroundday={(di) => roundDaysOf(emp, [di])}
onundoday={undoDay}
/>
</main>
</div>
@@ -23,6 +23,7 @@
onfixall,
onaddlunchall,
onroundall,
onundoall,
onlyIssues = $bindable(false)
}: {
name: string;
@@ -41,6 +42,7 @@
onfixall: () => void;
onaddlunchall: () => void;
onroundall: () => void;
onundoall: () => void;
onlyIssues?: boolean;
} = $props();
@@ -150,6 +152,21 @@
>{$_('review.to_check_cap')}</button
>
</div>
{#if edits}
<IconButton
size="lg"
dangerFilled
ariaLabel={$_('review.undo_all')}
title={$_('review.undo_all_title')}
onclick={onundoall}
>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M9 14l-5-5 5-5" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></svg
>
{/snippet}
</IconButton>
{/if}
</div>
</div>
@@ -1,8 +1,9 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Shifts } from '$lib/shifts';
import ModalFrame from '../ModalFrame.svelte';
import FixButton from './FixButton.svelte';
import ActionButton from './ActionButton.svelte';
import ActionButton from '../ActionButton.svelte';
let {
title,
@@ -25,108 +26,64 @@
} = $props();
</script>
<div class="modal-wrap">
<button type="button" class="modal-backdrop" aria-label={$_('review.cancel_export_aria')} onclick={onclose}
></button>
<div class="modal" role="alertdialog" aria-modal="true" aria-labelledby="ox-exp-title" aria-describedby="ox-exp-desc">
<div class="modal-icon">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
d="M12 9.5v4M12 17h.01"
/></svg
>
</div>
<div class="modal-text">
<h2 id="ox-exp-title">{title}</h2>
<p id="ox-exp-desc">
{#if scopeIssues}
{$_('review.export_warning_issues')}
{:else}
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
{/if}
</p>
</div>
<div class="modal-list scroll">
{#each items as it (it.key)}
<button type="button" class="menu-item row-item" onclick={it.go}>
<span class="mi-label">{it.label}</span>
<span class="mi-detail" class:red={it.red} class:blue={!it.red}>{it.detail}</span>
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" class="mi-chev"
><path d="M9 6l6 6-6 6" /></svg
>
</button>
{/each}
</div>
<div class="modal-actions">
<ActionButton onclick={onclose}>{$_('review.back_to_review')}</ActionButton>
{#if scopeIssues === 0 && scopeInfos > 0}
<FixButton size="tall" blue onclick={onroundandexport}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
>
{/snippet}
{$_('review.round_and_export')}
</FixButton>
<ModalFrame
titleId="ox-exp-title"
descId="ox-exp-desc"
closeAriaLabel={$_('review.cancel_export_aria')}
{onclose}
>
{#snippet icon()}
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
d="M12 9.5v4M12 17h.01"
/></svg
>
{/snippet}
<div class="modal-text">
<h2 id="ox-exp-title">{title}</h2>
<p id="ox-exp-desc">
{#if scopeIssues}
{$_('review.export_warning_issues')}
{:else}
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
{/if}
<ActionButton primary onclick={onexportanyway}>
</p>
</div>
<div class="modal-list scroll">
{#each items as it (it.key)}
<button type="button" class="menu-item row-item" onclick={it.go}>
<span class="mi-label">{it.label}</span>
<span class="mi-detail" class:red={it.red} class:blue={!it.red}>{it.detail}</span>
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" class="mi-chev"
><path d="M9 6l6 6-6 6" /></svg
>
</button>
{/each}
</div>
<div class="modal-actions">
<ActionButton onclick={onclose}>{$_('review.back_to_review')}</ActionButton>
{#if scopeIssues === 0 && scopeInfos > 0}
<FixButton size="tall" blue onclick={onroundandexport}>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
>
{/snippet}
{$_('review.export_anyway')}
</ActionButton>
</div>
{$_('review.round_and_export')}
</FixButton>
{/if}
<ActionButton primary onclick={onexportanyway}>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
>
{/snippet}
{$_('review.export_anyway')}
</ActionButton>
</div>
</div>
</ModalFrame>
<style>
.modal-wrap {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.modal-backdrop {
all: unset;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
cursor: default;
}
.modal {
position: relative;
width: 100%;
max-width: 440px;
box-sizing: border-box;
padding: 24px;
background: #1a1a1a;
border: 1px solid #333333;
border-radius: 18px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
gap: 18px;
}
.modal-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(229, 72, 77, 0.14);
color: #ff6b74;
display: flex;
align-items: center;
justify-content: center;
}
.modal-text {
display: flex;
flex-direction: column;
@@ -12,7 +12,8 @@
oncellinput,
onfixday,
onaddlunch,
onroundday
onroundday,
onundoday
}: {
rows: Row[];
rounded: Record<string, string>;
@@ -22,6 +23,7 @@
onfixday: (di: number) => void;
onaddlunch: (di: number) => void;
onroundday: (di: number) => void;
onundoday: (di: number) => void;
} = $props();
</script>
@@ -85,6 +87,19 @@
<td class="note {note.kind}">
<span class="note-in">
{#if r.rowEdits > 0}
<button
type="button"
class="undo-btn"
aria-label={$_('review.undo_day_aria', { values: { label } })}
title={$_('review.undo_short')}
onclick={() => onundoday(r.di)}
>
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M9 14l-5-5 5-5" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></svg
>
</button>
{/if}
{#if note.kind === 'issue'}
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
@@ -377,6 +392,33 @@
gap: 8px;
}
.undo-btn {
all: unset;
box-sizing: border-box;
width: 22px;
height: 22px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: #6e6e6e;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.undo-btn:hover {
background: #262626;
color: #e6e6e6;
}
.undo-btn:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 1px;
}
.empty {
padding: 48px 24px;
display: flex;
@@ -7,7 +7,7 @@
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
import Field from '../Field.svelte';
import ErrorBanner from '../ErrorBanner.svelte';
import ActionButton from '../review/ActionButton.svelte';
import ActionButton from '../ActionButton.svelte';
import PillButton from '../PillButton.svelte';
import EmployeesDrawer from './EmployeesDrawer.svelte';
import { IPV4 } from './logic';
+11 -1
View File
@@ -114,7 +114,17 @@
"export_warning_unrounded": "The times are complete, but some aren't rounded to {step} minutes. You can round them automatically before exporting, or export them as they are.",
"back_to_review": "Back to review",
"round_and_export": "Round and export",
"export_anyway": "Export anyway"
"export_anyway": "Export anyway",
"leave_confirm_title": "Unexported changes",
"leave_confirm_message": "If you leave now, the changes made in this review will be lost.",
"leave_anyway": "Leave without exporting",
"undo_day_aria": "Undo the changes to {label}",
"undo_short": "Undo",
"undo_all": "Undo changes",
"undo_all_title": "Restore this employee's times to what the time clock originally reported",
"undo_all_confirm_title": "Undo all changes?",
"undo_all_confirm_message": "All edited times for this employee will revert to what the time clock reported. This can't be undone.",
"undo_all_confirm": "Undo all changes"
},
"settings": {
"title": "Settings",
+11 -1
View File
@@ -114,7 +114,17 @@
"export_warning_unrounded": "Gli orari sono completi, ma alcuni non sono arrotondati a {step} minuti. Puoi arrotondarli automaticamente prima di esportare, oppure esportarli così come sono.",
"back_to_review": "Torna alla revisione",
"round_and_export": "Arrotonda ed esporta",
"export_anyway": "Esporta comunque"
"export_anyway": "Esporta comunque",
"leave_confirm_title": "Modifiche non esportate",
"leave_confirm_message": "Se esci ora, le modifiche fatte in questa revisione andranno perse.",
"leave_anyway": "Esci senza esportare",
"undo_day_aria": "Annulla le modifiche di {label}",
"undo_short": "Annulla",
"undo_all": "Annulla modifiche",
"undo_all_title": "Ripristina gli orari di questo dipendente a quelli letti dal timbratore",
"undo_all_confirm_title": "Annullare tutte le modifiche?",
"undo_all_confirm_message": "Tutti gli orari modificati per questo dipendente torneranno a quelli letti dal timbratore. L'operazione non si può annullare.",
"undo_all_confirm": "Annulla tutte le modifiche"
},
"settings": {
"title": "Impostazioni",
+47 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { onMount } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
@@ -12,6 +13,7 @@
import SettingsButton from '$lib/components/SettingsButton.svelte';
import Settings from '$lib/components/Settings.svelte';
import NewEmployees from '$lib/components/NewEmployees.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords';
import type { NewEmployee } from '$lib/components/new-employees/logic';
import type { Shifts } from '$lib/shifts';
@@ -38,14 +40,45 @@
let newEmployees = $state<NewEmployee[]>([]);
// Placeholder: overwritten by load_settings before the review screen is reachable.
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
let reviewDirty = $state(false);
let pendingLeaveAction = $state<null | (() => void)>(null);
const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
const reviewEmployees = $derived(buildEmployees(selectedYear, selectedMonth, records, employeeNames));
/** Runs `action` immediately, unless the review screen has unexported
* edits, in which case it asks for confirmation first. */
function requestLeaveReview(action: () => void) {
if (step === 'review' && reviewDirty) {
pendingLeaveAction = action;
} else {
action();
}
}
function confirmLeaveReview() {
const action = pendingLeaveAction;
pendingLeaveAction = null;
action?.();
}
function cancelLeaveReview() {
pendingLeaveAction = null;
}
onMount(async () => {
const settings = await invoke<SettingsData>('load_settings');
shifts = settings.shifts;
step = settings.readers[0] ? 'confirm-month' : 'setup';
// Lives for the whole app, so there's no need to ever unlisten.
const appWindow = getCurrentWindow();
await appWindow.onCloseRequested((event) => {
if (step === 'review' && reviewDirty) {
event.preventDefault();
requestLeaveReview(() => appWindow.destroy());
}
});
});
function openSettings() {
@@ -118,9 +151,10 @@
month={selectedMonth}
employees={reviewEmployees}
shift={shifts}
onback={() => (step = 'confirm-month')}
onback={() => requestLeaveReview(() => (step = 'confirm-month'))}
onexport={(payload) => invoke('export_timesheet', { payload })}
onrename={onEmployeeRenamed}
ondirtychange={(dirty) => (reviewDirty = dirty)}
/>
{:else if step === 'new-employees'}
<NewEmployees employees={newEmployees} oncontinue={onNewEmployeesContinue} />
@@ -150,3 +184,15 @@
{/if}
</Screen>
{/if}
{#if pendingLeaveAction}
<ConfirmDialog
fixed
title={$_('review.leave_confirm_title')}
message={$_('review.leave_confirm_message')}
confirmLabel={$_('review.leave_anyway')}
cancelLabel={$_('review.cancel')}
onconfirm={confirmLeaveReview}
oncancel={cancelLeaveReview}
/>
{/if}