v1.6.5: fix attendance unique constraint, schema sync, seed script

- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day)
- Reset migrations to single baseline init migration
- Add seed script with admin user (admin/admin)
- Update CLAUDE.md with migration workflow documentation
- Various frontend fixes (queries, pages, hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-17 16:21:34 +02:00
parent 1db5060c42
commit cd6d3daf43
75 changed files with 1748 additions and 1169 deletions

View File

@@ -38,10 +38,16 @@ import {
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import apiFetch from "../utils/api";
import { companySettingsOptions } from "../lib/queries/settings";
import { invoiceDetailOptions } from "../lib/queries/invoices";
import { offerCustomersOptions } from "../lib/queries/offers";
import { bankAccountsOptions } from "../lib/queries/common";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import {
invoiceDetailOptions,
type InvoiceDetail,
} from "../lib/queries/invoices";
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import { jsonQuery } from "../lib/apiAdapter";
import { formatCurrency, formatDate } from "../utils/formatters";
@@ -74,23 +80,6 @@ interface InvoiceItem {
vat_rate: number;
}
interface Customer {
id: number;
name: string;
company_id?: string;
city?: string;
}
interface BankAccount {
id: number;
account_name: string;
account_number?: string;
bank_name?: string;
bic?: string;
iban?: string;
is_default?: boolean;
}
interface InvoiceForm {
customer_id: number | null;
customer_name: string;
@@ -114,42 +103,6 @@ interface InvoiceForm {
bank_account: string;
}
interface InvoiceCustomer {
company_id?: string;
vat_id?: string;
}
interface Invoice {
id: number;
invoice_number: string;
customer_id?: number | null;
customer_name: string | null;
customer?: InvoiceCustomer;
order_id?: number;
order_number?: string;
currency: string;
status: string;
issue_date: string;
due_date: string;
tax_date: string;
payment_method: string;
constant_symbol?: string;
issued_by: string | null;
paid_date?: string;
notes: string;
language: string;
apply_vat: number | string;
vat_rate?: number;
billing_text?: string;
bank_name?: string;
bank_swift?: string;
bank_iban?: string;
bank_account?: string;
bank_account_id?: number | null;
items: Omit<InvoiceItem, "_key">[];
valid_transitions?: string[];
}
// Sortable row for create mode
function SortableInvoiceRow({
item,
@@ -226,7 +179,7 @@ function SortableInvoiceRow({
<input
type="number"
value={item.quantity}
onChange={(e) => onUpdate(index, "quantity", e.target.value)}
onChange={(e) => onUpdate(index, "quantity", Number(e.target.value))}
className="admin-form-input"
min="0"
step="any"
@@ -255,7 +208,9 @@ function SortableInvoiceRow({
<input
type="number"
value={item.unit_price}
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
onChange={(e) =>
onUpdate(index, "unit_price", Number(e.target.value))
}
className="admin-form-input"
step="any"
style={{
@@ -395,14 +350,7 @@ export default function InvoiceDetail() {
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const companySettings = useQuery(companySettingsOptions()).data as unknown as
| {
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
}
| undefined;
const companySettings = useQuery(companySettingsOptions()).data;
useEffect(() => {
if (companySettings && !isEdit) {
@@ -441,20 +389,13 @@ export default function InvoiceDetail() {
const queryClient = useQueryClient();
const customersQuery = useQuery(offerCustomersOptions());
const customers = useMemo<Customer[]>(() => {
const data = customersQuery.data;
if (!data) return [];
if (Array.isArray(data)) return data as Customer[];
const obj = data as Record<string, unknown>;
if (Array.isArray(obj.customers)) return obj.customers as Customer[];
return [];
}, [customersQuery.data]);
const customers = customersQuery.data ?? [];
const bankAccountsQuery = useQuery(bankAccountsOptions());
const bankAccounts = (bankAccountsQuery.data ?? []) as BankAccount[];
const bankAccounts = bankAccountsQuery.data ?? [];
const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = (invoiceQuery.data as Invoice | undefined) ?? null;
const invoice = invoiceQuery.data ?? null;
const nextNumberQuery = useQuery({
queryKey: ["invoices", "next-number"],
@@ -505,56 +446,54 @@ export default function InvoiceDetail() {
return;
if (!invoiceQuery.data) return;
const inv = invoiceQuery.data as Record<string, unknown>;
const inv = invoiceQuery.data;
// Match bank account from invoice's bank details
let matchedBankId: number | string = "";
const bankData = bankAccountsQuery.data ?? [];
if (Array.isArray(bankData)) {
const match = bankData.find(
(b: BankAccount) =>
(inv.bank_iban && b.iban === inv.bank_iban) ||
(inv.bank_account && b.account_number === inv.bank_account),
);
if (match) matchedBankId = match.id;
}
const match = bankData.find(
(b) =>
(inv.bank_iban && b.iban === inv.bank_iban) ||
(inv.bank_account && b.account_number === inv.bank_account),
);
if (match) matchedBankId = match.id;
const formData = {
customer_id: (inv.customer_id as number) || null,
customer_name: (inv.customer_name as string) || "",
order_id: (inv.order_id as number) || null,
const formData: InvoiceForm = {
customer_id: inv.customer_id || null,
customer_name: inv.customer_name || "",
order_id: inv.order_id || null,
issue_date: inv.issue_date
? new Date(inv.issue_date as string).toISOString().split("T")[0]
? new Date(inv.issue_date).toISOString().split("T")[0]
: "",
due_date: inv.due_date
? new Date(inv.due_date as string).toISOString().split("T")[0]
? new Date(inv.due_date).toISOString().split("T")[0]
: "",
tax_date: inv.tax_date
? new Date(inv.tax_date as string).toISOString().split("T")[0]
? new Date(inv.tax_date).toISOString().split("T")[0]
: "",
currency: (inv.currency as string) || "CZK",
currency: inv.currency || "CZK",
apply_vat: Number(inv.apply_vat) ? 1 : 0,
vat_rate: Number(inv.vat_rate) || 21,
payment_method: (inv.payment_method as string) || "Příkazem",
constant_symbol: (inv.constant_symbol as string) || "0308",
issued_by: (inv.issued_by as string) || "",
billing_text: (inv.billing_text as string) || "",
notes: (inv.notes as string) || "",
language: (inv.language as string) || "cs",
payment_method: inv.payment_method || "Příkazem",
constant_symbol: inv.constant_symbol || "0308",
issued_by: inv.issued_by || "",
billing_text: inv.billing_text || "",
notes: inv.notes || "",
language: inv.language || "cs",
bank_account_id: matchedBankId,
bank_name: (inv.bank_name as string) || "",
bank_swift: (inv.bank_swift as string) || "",
bank_iban: (inv.bank_iban as string) || "",
bank_account: (inv.bank_account as string) || "",
bank_name: inv.bank_name || "",
bank_swift: inv.bank_swift || "",
bank_iban: inv.bank_iban || "",
bank_account: inv.bank_account || "",
};
setForm(formData);
setNotes((inv.notes as string) || "");
setInvoiceNumber((inv.invoice_number as string) || "");
setNotes(inv.notes || "");
setInvoiceNumber(inv.invoice_number || "");
// Calculate dueDays from existing dates
if (inv.issue_date && inv.due_date) {
const issue = new Date(inv.issue_date as string);
const due = new Date(inv.due_date as string);
const issue = new Date(inv.issue_date);
const due = new Date(inv.due_date);
const diffDays = Math.round(
(due.getTime() - issue.getTime()) / (1000 * 60 * 60 * 24),
);
@@ -562,17 +501,17 @@ export default function InvoiceDetail() {
}
// Populate items from existing invoice
const invItems = inv.items as Record<string, unknown>[] | undefined;
const invItems = inv.items;
const mappedItems =
invItems && invItems.length > 0
? invItems.map((item) => ({
_key: `inv-${++keyCounterRef.current}`,
id: item.id as number | undefined,
description: (item.description as string) || "",
id: item.id,
description: item.description || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit: item.unit || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || Number(inv.vat_rate) || 21,
vat_rate: Number(inv.vat_rate) || 21,
}))
: [];
if (mappedItems.length > 0) {
@@ -613,7 +552,7 @@ export default function InvoiceDetail() {
}
// Set default bank account
const defaultAcc = bankAccounts.find((a: BankAccount) => a.is_default);
const defaultAcc = bankAccounts.find((a) => a.is_default);
if (defaultAcc) {
setForm((prev) => ({
...prev,
@@ -859,12 +798,14 @@ export default function InvoiceDetail() {
);
initialSnapshotRef.current = JSON.stringify({ form, items });
if (isEdit) {
queryClient.invalidateQueries({ queryKey: ["invoices", id] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
navigate(`/invoices/${result.data.invoice_id}`);
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate(`/invoices/${result.data.invoice_id}`);
}
} else {
alert.error(
@@ -895,8 +836,9 @@ export default function InvoiceDetail() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "Stav byl změněn");
queryClient.invalidateQueries({ queryKey: ["invoices", id] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(result.error || "Nepodařilo se změnit stav");
}
@@ -943,6 +885,7 @@ export default function InvoiceDetail() {
alert.success(result.message || "Faktura byla smazána");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate("/invoices");
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");