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 { 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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user