feat(invoices/orders): confirm before paid, draft PDF gate, debounced search, distinct create labels

- Marking an invoice paid (issued + received lists) now confirms via
  ConfirmDialog with loading guard — was a single unguarded chip click on a
  terminal transition; chips got affordance tooltips.
- Invoices list PDF icon hidden for drafts (no number → /file 404s),
  matching Offers/IssuedOrders.
- Search debounced (300ms) on Invoices/ReceivedInvoices (list + totals).
- Orders tabs create buttons renamed 'Nová vydaná/přijatá objednávka'
  (were identical for two document types); issued empty-state CTA aligned;
  draft delete dialog uses documentNumberLabel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-07-04 03:22:06 +02:00
parent 4d0ec53514
commit 9c192e79e7
4 changed files with 72 additions and 23 deletions

View File

@@ -20,6 +20,7 @@ import {
import { normalizeDateStr } from "../utils/attendanceHelpers";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import {
invoiceListOptions,
invoiceStatsOptions,
@@ -214,6 +215,7 @@ export default function Invoices() {
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false);
const { sort, order, handleSort } = useTableSort("invoice_number");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
@@ -269,6 +271,11 @@ export default function Invoices() {
invoice: Invoice | null;
}>({ show: false, invoice: null });
const [deleting, setDeleting] = useState(false);
const [paidConfirm, setPaidConfirm] = useState<{
show: boolean;
invoice: Invoice | null;
}>({ show: false, invoice: null });
const [markingPaid, setMarkingPaid] = useState(false);
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const queryClient = useQueryClient();
@@ -279,7 +286,7 @@ export default function Invoices() {
isFetching: loading,
} = usePaginatedQuery<Invoice>(
invoiceListOptions({
search,
search: debouncedSearch,
sort,
order,
page,
@@ -294,7 +301,7 @@ export default function Invoices() {
// matches what's shown. Separate from the KPI `statsQuery` above.
const listTotalsQuery = useQuery(
invoiceTotalsOptions({
search,
search: debouncedSearch,
status: statusFilter || undefined,
month: statsMonth,
year: statsYear,
@@ -336,16 +343,21 @@ export default function Invoices() {
}
};
const toggleStatus = async (inv: Invoice) => {
if (inv.status === "paid") return;
const handleMarkPaid = async () => {
if (!paidConfirm.invoice) return;
setMarkingPaid(true);
try {
const res = await apiFetch(`${API_BASE}/invoices/${inv.id}`, {
const res = await apiFetch(
`${API_BASE}/invoices/${paidConfirm.invoice.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "paid" }),
});
},
);
const data = await res.json();
if (data.success) {
setPaidConfirm({ show: false, invoice: null });
alert.success("Faktura označena jako zaplacená");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
@@ -355,6 +367,8 @@ export default function Invoices() {
}
} catch {
alert.error("Chyba připojení");
} finally {
setMarkingPaid(false);
}
};
@@ -557,7 +571,8 @@ export default function Invoices() {
<StatusChip
label={statusLabel(INVOICE_STATUS, inv.status)}
color={statusColor(INVOICE_STATUS, inv.status)}
onClick={() => toggleStatus(inv)}
title="Označit jako zaplacenou"
onClick={() => setPaidConfirm({ show: true, invoice: inv })}
/>
),
},
@@ -613,7 +628,8 @@ export default function Invoices() {
>
{inv.status === "paid" ? ViewIcon : EditIcon}
</IconButton>
{hasPermission("invoices.view") && (
{/* Drafts have no number yet — /file 404s for them. */}
{hasPermission("invoices.view") && inv.invoice_number && (
<IconButton
size="small"
onClick={() => handlePdf(inv)}
@@ -760,7 +776,7 @@ export default function Invoices() {
sortDir={order}
onSort={handleSort}
empty={
search || statusFilter ? (
debouncedSearch || statusFilter ? (
<EmptyState title="Žádné faktury neodpovídají filtru." />
) : (
<EmptyState
@@ -807,12 +823,23 @@ export default function Invoices() {
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
onConfirm={handleDelete}
title="Smazat fakturu"
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.invoice_number}"? Tato akce je nevratná.`}
message={`Opravdu chcete smazat fakturu "${documentNumberLabel(deleteConfirm.invoice?.invoice_number)}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleting}
/>
<ConfirmDialog
isOpen={paidConfirm.show}
onClose={() => setPaidConfirm({ show: false, invoice: null })}
onConfirm={handleMarkPaid}
title="Označit fakturu jako zaplacenou"
message={`Označit fakturu "${documentNumberLabel(paidConfirm.invoice?.invoice_number)}" jako zaplacenou? Tato akce je nevratná.`}
confirmText="Označit jako zaplacenou"
cancelText="Zrušit"
loading={markingPaid}
/>
</>
</TabPanel>
</PageEnter>

View File

@@ -436,7 +436,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
action={
hasPermission("orders.create") ? (
<Button component={RouterLink} to="/orders/issued/new">
Vytvořit objednávku
Nová vydaná objednávka
</Button>
) : undefined
}

View File

@@ -122,11 +122,11 @@ export default function Orders() {
startIcon={PlusIcon}
onClick={() => navigate("/orders/issued/new")}
>
Vytvořit objednávku
Nová vydaná objednávka
</Button>
) : (
<Button startIcon={PlusIcon} onClick={() => setCreateOpen(true)}>
Vytvořit objednávku
Nová přijatá objednávka
</Button>
)
) : undefined

View File

@@ -16,6 +16,7 @@ import {
} from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
companySettingsOptions,
@@ -248,6 +249,7 @@ export default function ReceivedInvoices({
const queryClient = useQueryClient();
const { sort, order, handleSort } = useTableSort("created_at");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [editOpen, setEditOpen] = useState(false);
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
@@ -257,6 +259,10 @@ export default function ReceivedInvoices({
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const [paidConfirm, setPaidConfirm] = useState<{
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const hasLoadedOnce = useRef(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
@@ -286,7 +292,7 @@ export default function ReceivedInvoices({
receivedInvoiceListOptions({
month: statsMonth,
year: statsYear,
search,
search: debouncedSearch,
sort,
order,
page,
@@ -306,7 +312,7 @@ export default function ReceivedInvoices({
receivedInvoiceTotalsOptions({
month: statsMonth,
year: statsYear,
search,
search: debouncedSearch,
}),
);
@@ -580,10 +586,14 @@ export default function ReceivedInvoices({
}
};
const toggleStatus = async (inv: ReceivedInvoice) => {
if (inv.status === "paid") return;
const handleMarkPaid = async () => {
if (!paidConfirm.invoice) return;
try {
await toggleStatusMutation.mutateAsync({ id: inv.id, status: "paid" });
await toggleStatusMutation.mutateAsync({
id: paidConfirm.invoice.id,
status: "paid",
});
setPaidConfirm({ show: false, invoice: null });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Nepodařilo se změnit stav");
}
@@ -675,7 +685,8 @@ export default function ReceivedInvoices({
<StatusChip
label={statusLabel(RECEIVED_INVOICE_STATUS, inv.status)}
color={statusColor(RECEIVED_INVOICE_STATUS, inv.status)}
onClick={() => toggleStatus(inv)}
title="Označit jako uhrazenou"
onClick={() => setPaidConfirm({ show: true, invoice: inv })}
/>
),
},
@@ -855,7 +866,7 @@ export default function ReceivedInvoices({
sortDir={order}
onSort={handleSort}
empty={
search ? (
debouncedSearch ? (
<EmptyState title="Žádné faktury neodpovídají hledání." />
) : (
<EmptyState
@@ -1329,6 +1340,17 @@ export default function ReceivedInvoices({
confirmVariant="danger"
loading={deleting}
/>
<ConfirmDialog
isOpen={paidConfirm.show}
onClose={() => setPaidConfirm({ show: false, invoice: null })}
onConfirm={handleMarkPaid}
title="Označit fakturu jako uhrazenou"
message={`Označit fakturu "${paidConfirm.invoice?.invoice_number || paidConfirm.invoice?.supplier_name || ""}" jako uhrazenou?`}
confirmText="Označit jako uhrazenou"
cancelText="Zrušit"
loading={toggleStatusMutation.isPending}
/>
</>
);
}