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

View File

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

View File

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

View File

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