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:
@@ -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}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "paid" }),
|
||||
});
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user