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

@@ -244,7 +244,7 @@ export default function Attendance() {
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
queryKey: ["attendance"],
});
punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
@@ -271,7 +271,7 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
queryKey: ["attendance"],
});
alert.success(
result.data?.message || result.message || "Přestávka zaznamenána",
@@ -297,6 +297,7 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Poznámka byla uložena");
} else {
alert.error(result.error);
@@ -319,7 +320,7 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
queryKey: ["attendance"],
});
alert.success(
result.data?.message || result.message || "Projekt přepnut",
@@ -363,7 +364,13 @@ export default function Attendance() {
if (result.success) {
setIsLeaveModalOpen(false);
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
queryKey: ["attendance"],
});
await queryClient.invalidateQueries({
queryKey: ["leave-requests"],
});
await queryClient.invalidateQueries({
queryKey: ["leave"],
});
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -910,7 +917,7 @@ export default function Attendance() {
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
{hasPermission("attendance.admin") && (
{hasPermission("attendance.manage") && (
<Link to="/attendance/admin" className="attendance-quick-link">
<svg
width="20"

View File

@@ -87,7 +87,7 @@ export default function AttendanceAdmin() {
useModalLock(showEditModal);
useModalLock(showCreateModal);
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (!hasPermission("attendance.manage")) return <Forbidden />;
// Show skeleton only on initial load (no data yet), not on filter changes
const isInitialLoad =

View File

@@ -11,6 +11,13 @@ import {
attendanceBalancesOptions,
attendanceWorkFundOptions,
attendanceProjectReportOptions,
type BalanceEntry,
type BalancesData,
type FundData,
type FundUserData,
type MonthFundData,
type ProjectData,
type UserShort,
} from "../lib/queries/attendance";
import apiFetch from "../utils/api";
@@ -18,70 +25,6 @@ import { Skeleton } from "boneyard-js/react";
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
const API_BASE = "/api/admin";
interface BalanceEntry {
name: string;
vacation_total: number;
vacation_used: number;
vacation_remaining: number;
sick_used: number;
}
interface UserShort {
id: number | string;
name: string;
}
interface FundUserData {
name: string;
worked: number;
covered: number;
overtime: number;
missing: number;
}
interface MonthFundData {
month_name: string;
fund: number;
fund_to_date: number;
business_days: number;
users?: Record<string, FundUserData>;
}
interface ProjectUser {
user_id: number;
user_name: string;
hours: number;
}
interface ProjectEntry {
project_id: number | null;
project_number?: string;
project_name?: string;
hours: number;
users: ProjectUser[];
}
interface MonthProjectData {
month_name: string;
projects: ProjectEntry[];
}
interface BalancesData {
users: UserShort[];
balances: Record<string, BalanceEntry>;
}
interface FundData {
months: Record<string, MonthFundData>;
holidays: unknown[];
users: UserShort[];
balances: Record<string, unknown>;
}
interface ProjectData {
months: Record<string, MonthProjectData>;
}
const getVacationClass = (remaining: number): string => {
if (remaining <= 0) return "text-danger";
if (remaining < 20) return "text-warning";
@@ -144,18 +87,15 @@ export default function AttendanceBalances() {
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [year, setYear] = useState(new Date().getFullYear());
const { data: balancesRaw, isPending: balancesPending } = useQuery(
const { data: balancesData, isPending: balancesPending } = useQuery(
attendanceBalancesOptions(year),
);
const { data: fundRaw, isPending: fundPending } = useQuery(
const { data: fundData, isPending: fundPending } = useQuery(
attendanceWorkFundOptions(year),
);
const { data: projectRaw, isPending: projectPending } = useQuery(
const { data: projectData, isPending: projectPending } = useQuery(
attendanceProjectReportOptions(year),
);
const balancesData = balancesRaw as BalancesData | undefined;
const fundData = fundRaw as FundData | undefined;
const projectData = projectRaw as ProjectData | undefined;
const [showEditModal, setShowEditModal] = useState(false);
const [editingUser, setEditingUser] = useState<{

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { userListOptions } from "../lib/queries/users";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
@@ -14,11 +14,6 @@ import { Skeleton } from "boneyard-js/react";
import AttendanceCreateFixture from "../fixtures/AttendanceCreateFixture";
const API_BASE = "/api/admin";
interface User {
id: number | string;
name: string;
}
interface CreateForm {
user_id: string;
shift_date: string;
@@ -39,8 +34,12 @@ export default function AttendanceCreate() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: usersData, isPending: loading } = useQuery(userListOptions());
const users = (usersData as unknown as User[] | undefined) ?? [];
const users = (usersData ?? []).map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim() || u.username,
}));
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState<CreateForm>(() => {
@@ -83,6 +82,7 @@ export default function AttendanceCreate() {
if (result.success) {
alert.success(result.message);
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} else {
alert.error(result.error);
@@ -107,7 +107,7 @@ export default function AttendanceCreate() {
const isWorkType = form.leave_type === "work";
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (!hasPermission("attendance.manage")) return <Forbidden />;
return (
<Skeleton

View File

@@ -5,7 +5,11 @@ import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import { attendanceHistoryOptions } from "../lib/queries/attendance";
import {
attendanceHistoryOptions,
type AttendanceRecord,
type ProjectLogEntry,
} from "../lib/queries/attendance";
import {
formatDate,
formatDatetime,
@@ -20,29 +24,6 @@ import {
import FormField from "../components/FormField";
import { Skeleton } from "boneyard-js/react";
import AttendanceHistoryFixture from "../fixtures/AttendanceHistoryFixture";
interface ProjectLog {
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}
interface AttendanceRecord {
id: number;
shift_date: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string;
project_name?: string;
project_logs?: ProjectLog[];
}
const MONTH_NAMES = [
"Leden",
@@ -196,9 +177,7 @@ export default function AttendanceHistory() {
const { user, hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName =
((companySettings as Record<string, unknown> | undefined)
?.company_name as string) || "";
const companyName = companySettings?.company_name || "";
const printRef = useRef<HTMLDivElement>(null);
const [month, setMonth] = useState(() => {
const now = new Date();
@@ -207,7 +186,7 @@ export default function AttendanceHistory() {
const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }),
);
const records = (data as AttendanceRecord[] | undefined) ?? [];
const records = data ?? [];
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-");

View File

@@ -154,7 +154,7 @@ export default function AttendanceLocation() {
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`;
};
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (!hasPermission("attendance.manage")) return <Forbidden />;
if (!record) {
return null;

View File

@@ -5,8 +5,11 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import FormField from "../components/FormField";
import ConfirmModal from "../components/ConfirmModal";
import { companySettingsOptions } from "../lib/queries/settings";
import { bankAccountsOptions } from "../lib/queries/common";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import { motion } from "framer-motion";
import apiFetch from "../utils/api";
@@ -47,17 +50,6 @@ interface CompanyForm {
vat_id: string;
}
interface BankAccount {
id: number;
account_name: string;
bank_name: string;
account_number: string;
iban: string;
bic: string;
currency: string;
is_default: boolean;
}
interface BankForm {
account_name: string;
bank_name: string;
@@ -196,49 +188,49 @@ export default function CompanySettings({
);
const bankAccountsList: BankAccount[] = Array.isArray(bankAccountsData)
? (bankAccountsData as unknown as BankAccount[])
? bankAccountsData
: [];
// Populate form state when settings data arrives
useEffect(() => {
if (!settingsData) return;
const d = settingsData as Record<string, unknown>;
setForm({
company_name: (d.company_name as string) || "",
street: (d.street as string) || "",
city: (d.city as string) || "",
postal_code: (d.postal_code as string) || "",
country: (d.country as string) || "",
company_id: (d.company_id as string) || "",
vat_id: (d.vat_id as string) || "",
company_name: settingsData.company_name || "",
street: settingsData.street || "",
city: settingsData.city || "",
postal_code: settingsData.postal_code || "",
country: settingsData.country || "",
company_id: settingsData.company_id || "",
vat_id: settingsData.vat_id || "",
});
const cf: CustomField[] =
Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? (d.custom_fields as CustomField[]).map((f, i) => ({
Array.isArray(settingsData.custom_fields) &&
settingsData.custom_fields.length > 0
? settingsData.custom_fields.map((f, i) => ({
...f,
showLabel: f.showLabel !== false,
_key: f._key || `cf-${Date.now()}-${i}`,
_key: (f as CustomField)._key || `cf-${Date.now()}-${i}`,
}))
: [];
setCustomFields(cf);
if (
Array.isArray(d.supplier_field_order) &&
d.supplier_field_order.length > 0
Array.isArray(settingsData.supplier_field_order) &&
settingsData.supplier_field_order.length > 0
) {
setFieldOrder(d.supplier_field_order as string[]);
setFieldOrder(settingsData.supplier_field_order);
} else {
setFieldOrder([...DEFAULT_FIELD_ORDER]);
}
if (
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
Array.isArray(settingsData.available_currencies) &&
settingsData.available_currencies.length > 0
) {
setAvailableCurrencies(d.available_currencies as string[]);
setAvailableCurrencies(settingsData.available_currencies);
}
if (d.has_logo) {
if (settingsData.has_logo) {
fetchLogo("light");
}
if (d.has_logo_dark) {
if (settingsData.has_logo_dark) {
fetchLogo("dark");
}
}, [settingsData, fetchLogo]);
@@ -277,6 +269,7 @@ export default function CompanySettings({
alert.success(result.message);
resetBankForm();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Chyba při ukládání");
}
@@ -305,6 +298,7 @@ export default function CompanySettings({
alert.success(result.message);
if (editingBank === bankDeleteConfirm.id) resetBankForm();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Chyba při mazání");
}
@@ -355,6 +349,9 @@ export default function CompanySettings({
if (result.success) {
alert.success(result.message || "Nastavení bylo uloženo");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
@@ -390,6 +387,9 @@ export default function CompanySettings({
if (result.success) {
alert.success(result.message || "Logo bylo nahráno");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
fetchLogo(variant);
} else {
alert.error(result.error || "Nepodařilo se nahrát logo");
@@ -406,7 +406,7 @@ export default function CompanySettings({
setForm((prev) => ({ ...prev, [field]: value }));
};
if (!embedded && !hasPermission("settings.manage")) return <Forbidden />;
if (!embedded && !hasPermission("settings.company")) return <Forbidden />;
if (settingsLoading) {
return (

View File

@@ -113,6 +113,7 @@ export default function Dashboard() {
if (result.success) {
alert.success(result.data?.message || "Docházka zaznamenána");
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(result.error || "Chyba při záznamu docházky");
}
@@ -171,7 +172,9 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setBackupCodes(data.data?.backup_codes || null);
setTotpSecret(null);
setTotpQrUri(null);
@@ -199,7 +202,9 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setShow2FADisable(false);
setDisableCode("");
updateUser({ totpEnabled: false });
@@ -343,7 +348,7 @@ export default function Dashboard() {
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
)}
{hasPermission("attendance.admin") && (
{hasPermission("attendance.manage") && (
<DashAttendanceToday attendance={dashData?.attendance ?? null} />
)}

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");

View File

@@ -220,6 +220,7 @@ export default function Invoices() {
alert.success(result.message || "Faktura byla smazána");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");
}
@@ -243,6 +244,7 @@ export default function Invoices() {
alert.success("Faktura označena jako zaplacená");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}

View File

@@ -158,6 +158,8 @@ export default function LeaveApproval() {
if (result.success) {
setApproveModal({ open: false, request: null });
await queryClient.invalidateQueries({ queryKey: ["leave"] });
await queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Žádost byla schválena");
} else {
alert.error(result.error);
@@ -195,6 +197,8 @@ export default function LeaveApproval() {
setRejectModal({ open: false, request: null });
setRejectNote("");
await queryClient.invalidateQueries({ queryKey: ["leave"] });
await queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Žádost byla zamítnuta");
} else {
alert.error(result.error);

View File

@@ -9,7 +9,7 @@ import LeaveRequestsFixture from "../fixtures/LeaveRequestsFixture";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import ConfirmModal from "../components/ConfirmModal";
import { leaveRequestsOptions } from "../lib/queries/leave";
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
const API_BASE = "/api/admin";
@@ -39,26 +39,13 @@ const leaveTypeClasses: Record<string, string> = {
unpaid: "badge-unpaid",
};
interface LeaveRequest {
id: number;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_note?: string;
created_at: string;
}
export default function LeaveRequests() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: requests = [], isPending } = useQuery(
leaveRequestsOptions(true),
) as { data: LeaveRequest[]; isPending: boolean };
);
const [cancelModal, setCancelModal] = useState<{
open: boolean;
id: number | null;
@@ -82,6 +69,8 @@ export default function LeaveRequests() {
if (result.success) {
setCancelModal({ open: false, id: null });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);

View File

@@ -14,8 +14,14 @@ import { motion, AnimatePresence } from "framer-motion";
import {
offerDetailOptions,
offerCustomersOptions,
offerTemplatesOptions,
scopeTemplatesOptions,
offerNextNumberOptions,
type OfferDetailData,
type OfferItemData,
type OfferSectionData,
type OfferLockInfo,
type OfferOrderInfo,
type ScopeTemplate,
} from "../lib/queries/offers";
import {
@@ -50,7 +56,10 @@ import apiFetch from "../utils/api";
import { formatCurrency } from "../utils/formatters";
import { Skeleton } from "boneyard-js/react";
import OfferDetailFixture from "../fixtures/OfferDetailFixture";
import { companySettingsOptions } from "../lib/queries/settings";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_offer_draft";
@@ -94,11 +103,6 @@ interface Customer {
city?: string;
}
interface OrderInfo {
id: number;
order_number: string;
}
const emptyForm: OfferForm = {
quotation_number: "",
project_code: "",
@@ -206,7 +210,7 @@ function SortableItemRow({
<input
type="number"
value={item.quantity}
onChange={(e) => onUpdate("quantity", e.target.value)}
onChange={(e) => onUpdate("quantity", parseInt(e.target.value, 10))}
className="admin-form-input"
step="1"
readOnly={readOnly}
@@ -225,7 +229,7 @@ function SortableItemRow({
<input
type="number"
value={item.unit_price}
onChange={(e) => onUpdate("unit_price", e.target.value)}
onChange={(e) => onUpdate("unit_price", parseFloat(e.target.value))}
className="admin-form-input"
step="0.01"
readOnly={readOnly}
@@ -318,34 +322,15 @@ export default function OfferDetail() {
...offerCustomersOptions(),
enabled: !isEdit,
});
const { data: templatesData } = useQuery({
...offerTemplatesOptions(),
enabled: !isEdit,
});
const { data: templatesData } = useQuery(scopeTemplatesOptions());
const { data: nextNumberData } = useQuery({
...offerNextNumberOptions(),
enabled: !isEdit,
});
// Derive typed arrays from query data
const customers = (
Array.isArray(customersData)
? customersData
: (customersData as Record<string, unknown>)?.customers
? ((customersData as Record<string, unknown>).customers as Customer[])
: []
) as Customer[];
const scopeTemplates = (
Array.isArray(templatesData) ? templatesData : []
) as Array<{
id: number;
name: string;
description?: string;
scope_template_sections?: Array<{
title?: string;
title_cz?: string;
content?: string;
}>;
}>;
const customers: Customer[] = Array.isArray(customersData)
? customersData
: [];
const scopeTemplates = templatesData ?? [];
const loading = isEdit && offerQuery.isLoading;
const [saving, setSaving] = useState(false);
@@ -396,7 +381,7 @@ export default function OfferDetail() {
});
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null);
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
const [offerStatus, setOfferStatus] = useState<string>("");
const [deleteConfirm, setDeleteConfirm] = useState(false);
@@ -409,14 +394,7 @@ export default function OfferDetail() {
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const companySettings = useQuery(companySettingsOptions()).data as unknown as
| {
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
}
| undefined;
const { data: companySettings } = useQuery(companySettingsOptions());
useEffect(() => {
if (companySettings && !isEdit) {
@@ -452,10 +430,13 @@ export default function OfferDetail() {
}, []);
const isInvalidated = offerStatus === "invalidated";
const isCompleted = orderInfo?.status === "dokoncena";
const isLockedByOther = !!lockedBy;
const readOnly = isInvalidated || isLockedByOther || isCompleted;
const isExpiredNotInvalidated =
isEdit &&
!isInvalidated &&
!isCompleted &&
!orderInfo &&
form.valid_until &&
new Date(form.valid_until) < new Date(new Date().toDateString());
@@ -464,28 +445,26 @@ export default function OfferDetail() {
const formInitializedRef = useRef(false);
useEffect(() => {
if (!offerQuery.data || formInitializedRef.current) return;
const d = offerQuery.data as Record<string, unknown>;
const d = offerQuery.data;
const formData: OfferForm = {
quotation_number: (d.quotation_number as string) || "",
project_code: (d.project_code as string) || "",
customer_id: (d.customer_id as number | null) ?? null,
customer_name: (d.customer_name as string) || "",
created_at: d.created_at ? String(d.created_at).substring(0, 10) : "",
valid_until: d.valid_until ? String(d.valid_until).substring(0, 10) : "",
currency:
(d.currency as string) || companySettings?.default_currency || "CZK",
language: (d.language as string) || "EN",
vat_rate:
(d.vat_rate as number) ?? companySettings?.default_vat_rate ?? 21,
quotation_number: d.quotation_number || "",
project_code: d.project_code || "",
customer_id: d.customer_id ?? null,
customer_name: d.customer_name || "",
created_at: d.created_at ? d.created_at.substring(0, 10) : "",
valid_until: d.valid_until ? d.valid_until.substring(0, 10) : "",
currency: d.currency || companySettings?.default_currency || "CZK",
language: d.language || "EN",
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
apply_vat: !!d.apply_vat,
exchange_rate: (d.exchange_rate as string) || "",
scope_title: (d.scope_title as string) || "",
scope_description: (d.scope_description as string) || "",
exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "",
scope_description: d.scope_description || "",
};
setForm(formData);
const mappedItems =
Array.isArray(d.items) && d.items.length
? (d.items as any[]).map((it: any) => ({
? d.items.map((it) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
@@ -493,7 +472,7 @@ export default function OfferDetail() {
setItems(mappedItems);
const mappedSections =
Array.isArray(d.sections) && d.sections.length
? (d.sections as any[]).map((s: any) => ({
? d.sections.map((s) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
@@ -505,14 +484,15 @@ export default function OfferDetail() {
items: mappedItems,
sections: mappedSections,
});
setOfferStatus((d.status as string) || "");
setOrderInfo((d.order as OrderInfo) ?? null);
setLockedBy((d.locked_by as typeof lockedBy) ?? null);
setOfferStatus(d.status || "");
setOrderInfo(d.order ?? null);
setLockedBy(d.locked_by ?? null);
// Try to acquire lock if not locked by someone else and not invalidated
// Try to acquire lock if not locked by someone else, not invalidated, and not completed
if (
!d.locked_by &&
d.status !== "invalidated" &&
d.order?.status !== "dokoncena" &&
hasPermission("offers.edit")
) {
apiFetch(`${API_BASE}/offers/${id}/lock`, { method: "POST" }).catch(
@@ -533,10 +513,7 @@ export default function OfferDetail() {
// Sync next-number data to form (create mode)
useEffect(() => {
if (isEdit || !nextNumberData) return;
const num =
((nextNumberData as Record<string, unknown>).next_number as string) ||
((nextNumberData as Record<string, unknown>).number as string) ||
"";
const num = nextNumberData.next_number || nextNumberData.number || "";
if (num) {
setForm((prev) => ({ ...prev, quotation_number: num }));
}
@@ -544,7 +521,8 @@ export default function OfferDetail() {
// Heartbeat to keep lock alive + cleanup on unmount
useEffect(() => {
if (!isEdit || !id || isLockedByOther || isInvalidated) return;
if (!isEdit || !id || isLockedByOther || isInvalidated || isCompleted)
return;
heartbeatRef.current = setInterval(() => {
apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }).catch(
@@ -719,6 +697,10 @@ export default function OfferDetail() {
}
}
if (!isEdit && result.data?.id) {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/offers/${result.data.id}`);
}
if (isEdit) {
@@ -730,6 +712,7 @@ export default function OfferDetail() {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
}
} else {
alert.error(result.error || "Nepodařilo se uložit nabídku");
@@ -772,9 +755,10 @@ export default function OfferDetail() {
if (result.success) {
setShowOrderModal(false);
alert.success(result.message || "Objednávka byla vytvořena");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/orders/${result.data.order_id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
@@ -800,6 +784,7 @@ export default function OfferDetail() {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
}
@@ -819,9 +804,10 @@ export default function OfferDetail() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "Nabídka byla smazána");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate("/offers");
} else {
alert.error(result.error || "Nepodařilo se smazat nabídku");
@@ -864,7 +850,7 @@ export default function OfferDetail() {
const getRequiredPerm = () => {
if (!isEdit) return "offers.create";
return isInvalidated ? "offers.view" : "offers.edit";
return isInvalidated || isCompleted ? "offers.view" : "offers.edit";
};
const requiredPerm = getRequiredPerm();
if (!hasPermission(requiredPerm)) return <Forbidden />;
@@ -915,6 +901,17 @@ export default function OfferDetail() {
Zneplatněna
</span>
)}
{isCompleted && (
<span
className="admin-badge admin-badge-success text-xs"
style={{
marginLeft: "0.75rem",
verticalAlign: "middle",
}}
>
Dokončeno
</span>
)}
</h1>
</div>
</div>
@@ -949,7 +946,7 @@ export default function OfferDetail() {
</button>
)}
{isEdit &&
!isInvalidated &&
!readOnly &&
hasPermission("orders.create") &&
!orderInfo && (
<button
@@ -979,7 +976,7 @@ export default function OfferDetail() {
Zneplatnit
</button>
)}
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
@@ -1042,7 +1039,7 @@ export default function OfferDetail() {
{/* Quotation Form */}
<motion.div
className={`admin-editor-section${isInvalidated || isLockedByOther ? " offers-readonly" : ""}`}
className={`admin-editor-section${readOnly ? " offers-readonly" : ""}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
@@ -1069,14 +1066,14 @@ export default function OfferDetail() {
onChange={(e) => updateForm("project_code", e.target.value)}
className="admin-form-input"
placeholder="Volitelný kód projektu"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
<FormField label="Zákazník" error={errors.customer_id} required>
{form.customer_id ? (
<div className="admin-customer-selected">
<span>{form.customer_name}</span>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<button
type="button"
onClick={clearCustomer}
@@ -1112,7 +1109,7 @@ export default function OfferDetail() {
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
{showCustomerDropdown && !isInvalidated && (
<div className="admin-customer-dropdown">
@@ -1145,7 +1142,7 @@ export default function OfferDetail() {
error={errors.created_at}
required
>
{isInvalidated || isLockedByOther ? (
{readOnly ? (
<input
type="text"
value={form.created_at}
@@ -1168,7 +1165,7 @@ export default function OfferDetail() {
error={errors.valid_until}
required
>
{isInvalidated || isLockedByOther ? (
{readOnly ? (
<input
type="text"
value={form.valid_until}
@@ -1197,7 +1194,7 @@ export default function OfferDetail() {
value={form.currency}
onChange={(e) => updateForm("currency", e.target.value)}
className="admin-form-select"
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
>
{(
companySettings?.available_currencies || [
@@ -1218,7 +1215,7 @@ export default function OfferDetail() {
value={form.language}
onChange={(e) => updateForm("language", e.target.value)}
className="admin-form-select"
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
>
<option value="EN">English</option>
<option value="CZ">Čeština</option>
@@ -1235,7 +1232,7 @@ export default function OfferDetail() {
updateForm("vat_rate", parseFloat(e.target.value) || 0)
}
className="admin-form-select flex-1"
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
>
{(
companySettings?.available_vat_rates || [
@@ -1254,7 +1251,7 @@ export default function OfferDetail() {
onChange={(e) =>
updateForm("apply_vat", e.target.checked)
}
disabled={isInvalidated || isLockedByOther}
disabled={readOnly}
/>
<span>Účtovat DPH</span>
</label>
@@ -1268,7 +1265,7 @@ export default function OfferDetail() {
className="admin-form-input"
placeholder="Volitelný"
step="0.0001"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
</div>
@@ -1285,7 +1282,7 @@ export default function OfferDetail() {
<div className="admin-card-body">
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Položky</h3>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<button
onClick={addItem}
className="admin-btn admin-btn-secondary admin-btn-sm"
@@ -1327,9 +1324,7 @@ export default function OfferDetail() {
<table className="admin-table">
<thead>
<tr>
{!isInvalidated && !isLockedByOther && (
<th style={{ width: "2rem" }} />
)}
{!readOnly && <th style={{ width: "2rem" }} />}
<th style={{ width: "2.5rem", textAlign: "center" }}>
#
</th>
@@ -1364,7 +1359,7 @@ export default function OfferDetail() {
>
Celkem
</th>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<th
className="offers-col-del"
style={{ width: "3rem" }}
@@ -1379,7 +1374,7 @@ export default function OfferDetail() {
item={item}
index={index}
currency={form.currency}
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
canDelete={items.length > 1}
onUpdate={(field, value) =>
updateItem(index, field, value)
@@ -1423,7 +1418,7 @@ export default function OfferDetail() {
<div className="admin-card-body">
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Rozsah projektu</h3>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<div
style={{
display: "flex",
@@ -1444,7 +1439,7 @@ export default function OfferDetail() {
);
if (template?.scope_template_sections?.length) {
const newSections =
template.scope_template_sections.map((s: any) => ({
template.scope_template_sections.map((s) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
@@ -1537,7 +1532,7 @@ export default function OfferDetail() {
</span>
)}
</span>
{!isInvalidated && !isLockedByOther && (
{!readOnly && (
<div style={{ display: "flex", gap: "0.25rem" }}>
{idx > 0 && (
<button
@@ -1639,7 +1634,7 @@ export default function OfferDetail() {
}
className="admin-form-input"
placeholder="Název sekce (anglicky)"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
<FormField
@@ -1666,7 +1661,7 @@ export default function OfferDetail() {
}
className="admin-form-input"
placeholder="Název sekce (česky)"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</FormField>
</div>
@@ -1684,7 +1679,7 @@ export default function OfferDetail() {
}
placeholder="Obsah sekce..."
minHeight="120px"
readOnly={isInvalidated || isLockedByOther}
readOnly={readOnly}
/>
</div>
</div>

View File

@@ -33,6 +33,7 @@ interface Quotation {
total: number;
status: string;
order_id?: number;
order_status?: string;
}
interface Draft {
@@ -118,8 +119,13 @@ export default function Offers() {
setDraft(null);
};
const getRowClass = (invalidated: boolean, expired: boolean) => {
const getRowClass = (
invalidated: boolean,
expired: boolean,
completed: boolean,
) => {
if (invalidated) return "offers-invalidated-row";
if (completed) return "offers-completed-row";
if (expired) return "offers-expired-row";
return "";
};
@@ -140,6 +146,9 @@ export default function Offers() {
if (result.success) {
alert.success(result.message || "Nabídka byla duplikována");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se duplikovat nabídku");
}
@@ -168,10 +177,11 @@ export default function Offers() {
if (result.success) {
setOrderModal({ show: false, quotation: null });
alert.success(result.message || "Objednávka byla vytvořena");
navigate(`/orders/${result.data.order_id}`);
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate(`/orders/${result.data.order_id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
}
@@ -199,6 +209,7 @@ export default function Offers() {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se smazat nabídku");
}
@@ -226,6 +237,7 @@ export default function Offers() {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
}
@@ -289,7 +301,7 @@ export default function Offers() {
</p>
</div>
<div className="admin-page-actions">
{hasPermission("settings.manage") && (
{hasPermission("settings.templates") && (
<Link
to="/offers/templates"
className="admin-btn admin-btn-secondary"
@@ -520,16 +532,24 @@ export default function Offers() {
)}
{(quotations as Quotation[]).map((q) => {
const isInvalidated = q.status === "invalidated";
const isCompleted =
!isInvalidated && q.order_status === "dokoncena";
const isExpired =
!isInvalidated &&
!isCompleted &&
!q.order_id &&
q.valid_until &&
new Date(q.valid_until) <
new Date(new Date().toDateString());
const readOnly = isInvalidated || isCompleted;
return (
<tr
key={q.id}
className={getRowClass(isInvalidated, !!isExpired)}
className={getRowClass(
isInvalidated,
!!isExpired,
isCompleted,
)}
>
<td>
<Link
@@ -560,12 +580,10 @@ export default function Offers() {
<Link
to={`/offers/${q.id}`}
className="admin-btn-icon"
title={isInvalidated ? "Zobrazit" : "Upravit"}
aria-label={
isInvalidated ? "Zobrazit" : "Upravit"
}
title={readOnly ? "Zobrazit" : "Upravit"}
aria-label={readOnly ? "Zobrazit" : "Upravit"}
>
{isInvalidated ? (
{readOnly ? (
<svg
width="18"
height="18"
@@ -591,35 +609,34 @@ export default function Offers() {
</svg>
)}
</Link>
{!isInvalidated &&
hasPermission("offers.create") && (
<button
onClick={() => handleDuplicate(q)}
className="admin-btn-icon"
title="Duplikovat"
disabled={duplicating === q.id}
{!readOnly && hasPermission("offers.create") && (
<button
onClick={() => handleDuplicate(q)}
className="admin-btn-icon"
title="Duplikovat"
disabled={duplicating === q.id}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
)}
{!isInvalidated && q.order_id ? (
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
)}
{!readOnly && q.order_id ? (
<Link
to={`/orders/${q.order_id}`}
className="admin-btn-icon accent"
@@ -650,7 +667,7 @@ export default function Offers() {
</svg>
</Link>
) : (
!isInvalidated &&
!readOnly &&
hasPermission("orders.create") && (
<button
onClick={() => {

View File

@@ -7,7 +7,11 @@ import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import { offerCustomersOptions } from "../lib/queries/offers";
import {
offerCustomersOptions,
type Customer,
type CustomField,
} from "../lib/queries/offers";
import { Skeleton } from "boneyard-js/react";
import OffersCustomersFixture from "../fixtures/OffersCustomersFixture";
@@ -31,27 +35,6 @@ const CUSTOMER_FIELD_LABELS: Record<string, string> = {
vat_id: "DIČ",
};
interface Customer {
id: number;
name: string;
street?: string;
city?: string;
postal_code?: string;
country?: string;
company_id?: string;
vat_id?: string;
quotation_count: number;
custom_fields?: CustomField[];
customer_field_order?: string[];
}
interface CustomField {
name: string;
value: string;
showLabel: boolean;
_key?: string;
}
interface CustomerForm {
name: string;
street: string;
@@ -66,9 +49,7 @@ export default function OffersCustomers() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: customers = [], isPending } = useQuery(
offerCustomersOptions(),
) as { data: Customer[]; isPending: boolean };
const { data: customers = [], isPending } = useQuery(offerCustomersOptions());
const [search, setSearch] = useState("");
const [showModal, setShowModal] = useState(false);
@@ -232,6 +213,7 @@ export default function OffersCustomers() {
: "Zákazník byl vytvořen"),
);
queryClient.invalidateQueries({ queryKey: ["offer-customers"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error || "Nepodařilo se uložit zákazníka");
}
@@ -260,6 +242,7 @@ export default function OffersCustomers() {
setDeleteConfirm({ show: false, customer: null });
alert.success(result.message || "Zákazník byl smazán");
queryClient.invalidateQueries({ queryKey: ["offer-customers"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error || "Nepodařilo se smazat zákazníka");
}
@@ -270,7 +253,7 @@ export default function OffersCustomers() {
}
};
if (!hasPermission("offers.view")) return <Forbidden />;
if (!hasPermission("customers.view")) return <Forbidden />;
const filteredCustomers = search
? customers.filter(
@@ -300,7 +283,7 @@ export default function OffersCustomers() {
<h1 className="admin-page-title">Zákazníci</h1>
<p className="admin-page-subtitle">Správa zákazníků pro nabídky</p>
</div>
{hasPermission("offers.create") && (
{hasPermission("customers.create") && (
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
@@ -345,7 +328,7 @@ export default function OffersCustomers() {
? "Žádní zákazníci odpovídající hledání."
: "Zatím nejsou žádní zákazníci."}
</p>
{!search && hasPermission("offers.create") && (
{!search && hasPermission("customers.create") && (
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
@@ -398,7 +381,7 @@ export default function OffersCustomers() {
</td>
<td>
<div className="admin-table-actions">
{hasPermission("offers.edit") && (
{hasPermission("customers.edit") && (
<button
onClick={() => openEditModal(customer)}
className="admin-btn-icon"
@@ -418,7 +401,7 @@ export default function OffersCustomers() {
</svg>
</button>
)}
{hasPermission("offers.delete") && (
{hasPermission("customers.delete") && (
<button
onClick={() =>
setDeleteConfirm({ show: true, customer })

View File

@@ -8,7 +8,13 @@ import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor";
import useModalLock from "../hooks/useModalLock";
import { offerTemplatesOptions } from "../lib/queries/offers";
import {
itemTemplatesOptions,
scopeTemplatesOptions,
type ItemTemplate,
type ScopeTemplate,
type ScopeSection,
} from "../lib/queries/offers";
import { Skeleton } from "boneyard-js/react";
import OffersTemplatesFixture from "../fixtures/OffersTemplatesFixture";
@@ -16,27 +22,6 @@ import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface ItemTemplate {
id: number;
name: string;
description: string;
default_price: number;
category: string;
}
interface ScopeSection {
_key: string;
title: string;
title_cz: string;
content: string;
}
interface ScopeTemplate {
id: number;
name: string;
sections?: ScopeSection[];
}
interface ItemForm {
name: string;
description: string;
@@ -53,7 +38,7 @@ export default function OffersTemplates() {
const { hasPermission } = useAuth();
const [activeTab, setActiveTab] = useState<"items" | "scopes">("items");
if (!hasPermission("settings.manage")) return <Forbidden />;
if (!hasPermission("settings.templates")) return <Forbidden />;
return (
<div>
@@ -96,9 +81,7 @@ export default function OffersTemplates() {
function ItemTemplatesTab() {
const alert = useAlert();
const queryClient = useQueryClient();
const { data: templates = [], isPending } = useQuery(
offerTemplatesOptions("items"),
) as { data: ItemTemplate[]; isPending: boolean };
const { data: templates = [], isPending } = useQuery(itemTemplatesOptions());
const [showModal, setShowModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ItemTemplate | null>(
null,
@@ -157,6 +140,7 @@ function ItemTemplatesTab() {
await new Promise((r) => setTimeout(r, 300));
alert.success(result.message);
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error);
}
@@ -180,6 +164,7 @@ function ItemTemplatesTab() {
setDeleteConfirm({ show: false, template: null });
alert.success(result.message);
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error);
}
@@ -365,7 +350,7 @@ function ItemTemplatesTab() {
onChange={(e) =>
setForm((p) => ({
...p,
default_price: e.target.value,
default_price: parseFloat(e.target.value),
}))
}
className="admin-form-input"
@@ -435,9 +420,7 @@ function ItemTemplatesTab() {
function ScopeTemplatesTab() {
const alert = useAlert();
const queryClient = useQueryClient();
const { data: templates = [], isPending } = useQuery(
offerTemplatesOptions(),
) as { data: ScopeTemplate[]; isPending: boolean };
const { data: templates = [], isPending } = useQuery(scopeTemplatesOptions());
const [showModal, setShowModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ScopeTemplate | null>(
null,
@@ -574,6 +557,7 @@ function ScopeTemplatesTab() {
await new Promise((r) => setTimeout(r, 300));
alert.success(result.message);
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error);
}
@@ -597,6 +581,7 @@ function ScopeTemplatesTab() {
setDeleteConfirm({ show: false, template: null });
alert.success(result.message);
queryClient.invalidateQueries({ queryKey: ["offer-templates"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(result.error);
}

View File

@@ -1,7 +1,14 @@
import { useState, useEffect, useMemo, useRef, type ReactNode } from "react";
import DOMPurify from "dompurify";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { orderDetailOptions } from "../lib/queries/orders";
import {
orderDetailOptions,
type OrderData,
type OrderItem,
type OrderSection,
type OrderInvoice,
type OrderProject,
} from "../lib/queries/orders";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom";
@@ -42,60 +49,6 @@ const TRANSITION_CLASSES: Record<string, string> = {
dokoncena: "admin-btn admin-btn-primary",
};
interface OrderItem {
id?: number;
description: string;
item_description?: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: number | boolean;
}
interface OrderSection {
id?: number;
title: string;
title_cz?: string;
content: string;
}
interface Invoice {
id: number;
invoice_number: string;
}
interface Project {
id: number;
project_number: string;
name: string;
has_nas_folder?: boolean;
}
interface OrderData {
id: number;
order_number: string;
quotation_id: number;
quotation_number: string;
project_code?: string;
customer_name: string;
customer_order_number: string;
currency: string;
created_at: string;
status: string;
notes: string;
attachment_name?: string;
apply_vat: number | boolean;
vat_rate: number;
language?: string;
items: OrderItem[];
sections: OrderSection[];
scope_title?: string;
scope_description?: string;
valid_transitions?: string[];
invoice?: Invoice;
project?: Project;
}
export default function OrderDetail() {
const { id } = useParams();
const alert = useAlert();
@@ -104,7 +57,7 @@ export default function OrderDetail() {
const queryClient = useQueryClient();
const orderQuery = useQuery(orderDetailOptions(id));
const order = orderQuery.data as OrderData | undefined;
const order = orderQuery.data;
const loading = orderQuery.isPending;
const [notes, setNotes] = useState("");
@@ -132,7 +85,7 @@ export default function OrderDetail() {
// Sync order data to local form state on first load
useEffect(() => {
if (orderQuery.data && !formInitializedRef.current) {
const orderData = orderQuery.data as OrderData;
const orderData = orderQuery.data!;
setNotes(orderData.notes || "");
initialNotesRef.current = orderData.notes || "";
formInitializedRef.current = true;
@@ -199,9 +152,10 @@ export default function OrderDetail() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "Stav byl změněn");
queryClient.invalidateQueries({ queryKey: ["orders", id] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se změnit stav");
}
@@ -224,9 +178,10 @@ export default function OrderDetail() {
if (result.success) {
alert.success("Poznámky byly uloženy");
initialNotesRef.current = notes;
queryClient.invalidateQueries({ queryKey: ["orders", id] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se uložit poznámky");
}
@@ -315,9 +270,10 @@ export default function OrderDetail() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "Objednávka byla smazána");
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
navigate("/orders");
} else {
alert.error(result.error || "Nepodařilo se smazat objednávku");
@@ -416,24 +372,26 @@ export default function OrderDetail() {
</Link>
)
)}
<button
onClick={() => setShowConfirmationModal(true)}
className="admin-btn admin-btn-secondary"
disabled={confirmationLoading}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
{hasPermission("orders.export") && (
<button
onClick={() => setShowConfirmationModal(true)}
className="admin-btn admin-btn-secondary"
disabled={confirmationLoading}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
Potvrzení objednávky
</button>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
Potvrzení objednávky
</button>
)}
{hasPermission("orders.edit") &&
order.valid_transitions?.filter((s) => s !== "stornovana")
.length! > 0 &&

View File

@@ -91,6 +91,7 @@ export default function Orders() {
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se smazat objednávku");
}

View File

@@ -5,8 +5,12 @@ import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion";
import { projectDetailOptions } from "../lib/queries/projects";
import { userListOptions } from "../lib/queries/users";
import {
projectDetailOptions,
type ProjectData,
type ProjectNote,
} from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import ProjectDetailFixture from "../fixtures/ProjectDetailFixture";
@@ -35,36 +39,11 @@ function formatNoteDate(dateStr: string) {
return `${day}. ${month}. ${year} ${hours}:${mins}`;
}
interface Note {
id: number;
content: string;
user_name: string;
created_at: string;
}
interface User {
id: number;
name: string;
}
interface ProjectData {
id: number;
project_number: string;
name: string;
status: string;
start_date: string;
end_date: string;
customer_name: string;
responsible_user_id: string;
notes?: string;
order_id?: number;
order_number?: string;
order_status?: string;
quotation_id?: number;
quotation_number?: string;
has_nas_folder?: boolean;
}
interface ProjectForm {
name: string;
status: string;
@@ -99,22 +78,15 @@ export default function ProjectDetail() {
const queryClient = useQueryClient();
const projectQuery = useQuery(projectDetailOptions(id));
const project = projectQuery.data as
| (ProjectData & { project_notes?: Note[] })
| undefined;
const project = projectQuery.data;
const isPending = projectQuery.isPending;
const notes: Note[] = project?.project_notes || [];
const notes: ProjectNote[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions("projects.view"));
const rawUsers = ((usersData as Record<string, unknown>)?.items ??
usersData ??
[]) as any[];
const users: User[] = Array.isArray(rawUsers)
? rawUsers.map((u: any) => ({
id: u.id,
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
}))
: [];
const users: User[] = (usersData ?? []).map((u: ApiUser) => ({
id: u.id,
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
}));
// Reset form sync when navigating to a different project
const formInitialized = useRef(false);
@@ -174,6 +146,8 @@ export default function ProjectDetail() {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(result.error || "Nepodařilo se uložit projekt");
}
@@ -197,6 +171,8 @@ export default function ProjectDetail() {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
navigate("/projects");
setTimeout(() => alert.success("Projekt byl smazán"), 300);
} else {
@@ -223,7 +199,11 @@ export default function ProjectDetail() {
if (result.success) {
setNewNote("");
alert.success("Poznámka byla přidána");
queryClient.invalidateQueries({ queryKey: ["projects", id] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(result.error || "Nepodařilo se přidat poznámku");
}
@@ -246,7 +226,11 @@ export default function ProjectDetail() {
const result = await response.json();
if (result.success) {
alert.success("Poznámka byla smazána");
queryClient.invalidateQueries({ queryKey: ["projects", id] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(result.error || "Nepodařilo se smazat poznámku");
}

View File

@@ -83,6 +83,8 @@ export default function Projects() {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(data.error || "Nepodařilo se smazat projekt");
}
@@ -162,8 +164,7 @@ export default function Projects() {
fontSize: "0.875rem",
}}
>
Vytvořte první projekt tlačítkem výše nebo automaticky při
vytvoření objednávky.
Projekt se vytvoří automaticky při vytvoření objednávky.
</p>
</div>
) : (
@@ -291,7 +292,7 @@ export default function Projects() {
</svg>
</Link>
{!p.order_id &&
hasPermission("projects.create") && (
hasPermission("projects.delete") && (
<button
onClick={() => setDeleteTarget(p)}
className="admin-btn-icon danger"

View File

@@ -11,11 +11,17 @@ import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useModalLock from "../hooks/useModalLock";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { supplierListOptions } from "../lib/queries/common";
import {
receivedInvoiceListOptions,
receivedInvoiceStatsOptions,
type ReceivedInvoice,
type ReceivedStats,
type CurrencyAmount,
} from "../lib/queries/invoices";
import { Skeleton } from "boneyard-js/react";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
@@ -48,37 +54,6 @@ const MONTH_NAMES = [
"prosinec",
];
interface CurrencyAmount {
amount: number;
currency: string;
}
interface ReceivedInvoice {
id: number;
supplier_name: string;
invoice_number: string;
amount: number;
currency: string;
vat_rate: number;
issue_date: string;
due_date: string;
notes: string;
status: string;
file_name?: string;
created_at: string;
}
interface ReceivedStats {
total_month: CurrencyAmount[];
total_month_czk: number | null;
vat_month: CurrencyAmount[];
vat_month_czk: number | null;
unpaid: CurrencyAmount[];
unpaid_czk: number | null;
unpaid_count: number;
month_count: number;
}
interface UploadMeta {
supplier_name: string;
invoice_number: string;
@@ -133,14 +108,9 @@ function formatCzkWithDetail(
return { value: formatMultiCurrency(amounts), detail: null };
}
interface CompanySettings {
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
}
function emptyMeta(settings: CompanySettings | null | undefined): UploadMeta {
function emptyMeta(
settings: CompanySettingsData | null | undefined,
): UploadMeta {
return {
supplier_name: "",
invoice_number: "",
@@ -182,9 +152,7 @@ export default function ReceivedInvoices({
const prevYear = useRef(statsYear);
const { data: supplierNames = [] } = useQuery(supplierListOptions());
const companySettings = useQuery(companySettingsOptions()).data as unknown as
| CompanySettings
| undefined;
const companySettings = useQuery(companySettingsOptions()).data;
// List query — auto-refetches when filters change
const listQuery = useQuery(
@@ -203,11 +171,11 @@ export default function ReceivedInvoices({
);
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
const invoices = (listQuery.data?.data ?? []) as ReceivedInvoice[];
const invoices = listQuery.data?.data ?? [];
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
// Derive stats from query
const stats = (statsQuery.data as unknown as ReceivedStats) ?? null;
const stats = statsQuery.data ?? null;
// Trigger slide animation when stats data changes
useEffect(() => {
@@ -345,8 +313,10 @@ export default function ReceivedInvoices({
setUploadMeta([]);
setUploadErrors({});
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
queryKey: ["invoices"],
});
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(data.error || "Chyba při nahrávání");
}
@@ -416,8 +386,10 @@ export default function ReceivedInvoices({
setEditOpen(false);
setEditInvoice(null);
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
queryKey: ["invoices"],
});
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(data.error || "Chyba při ukládání");
}
@@ -445,8 +417,10 @@ export default function ReceivedInvoices({
alert.success(data.message || "Faktura byla smazána");
setDeleteConfirm({ show: false, invoice: null });
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
queryKey: ["invoices"],
});
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(data.error || "Chyba při mazání");
}
@@ -491,8 +465,10 @@ export default function ReceivedInvoices({
if (data.success) {
alert.success("Faktura označena jako uhrazená");
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
queryKey: ["invoices"],
});
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}

View File

@@ -47,10 +47,12 @@ interface SystemSettingsData {
const MODULE_LABELS: Record<string, string> = {
attendance: "Docházka",
trips: "Kniha jízd",
vehicles: "Vozidla",
offers: "Nabídky",
orders: "Objednávky",
projects: "Projekty",
invoices: "Faktury",
customers: "Zákazníci",
users: "Uživatelé",
settings: "Nastavení",
};
@@ -107,11 +109,11 @@ export default function Settings() {
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const canManage = hasPermission("settings.manage");
const canManage = hasPermission("settings.roles");
// ── TanStack Query: roles, permissions, users ──
const { data: rolesData, isPending: rolesLoading } = useQuery({
queryKey: ["roles"],
queryKey: ["settings", "roles"],
queryFn: async () => {
const [rolesRes, permsRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
@@ -205,39 +207,39 @@ export default function Settings() {
// ── Populate sysForm from query data ──
useEffect(() => {
if (!sysSettingsData || sysFormInitialized) return;
const d = sysSettingsData as Record<string, unknown>;
setSysForm({
break_threshold_hours: (d.break_threshold_hours as number) ?? 6,
break_duration_short: (d.break_duration_short as number) ?? 15,
break_duration_long: (d.break_duration_long as number) ?? 30,
clock_rounding_minutes: (d.clock_rounding_minutes as number) ?? 15,
invoice_alert_email: (d.invoice_alert_email as string) || "",
leave_notify_email: (d.leave_notify_email as string) || "",
smtp_from: (d.smtp_from as string) || "",
smtp_from_name: (d.smtp_from_name as string) || "",
max_login_attempts: (d.max_login_attempts as number) ?? 5,
lockout_minutes: (d.lockout_minutes as number) ?? 15,
max_requests_per_minute: (d.max_requests_per_minute as number) ?? 300,
default_currency: (d.default_currency as string) || "CZK",
default_vat_rate: (d.default_vat_rate as number) ?? 21,
break_threshold_hours: sysSettingsData.break_threshold_hours ?? 6,
break_duration_short: sysSettingsData.break_duration_short ?? 15,
break_duration_long: sysSettingsData.break_duration_long ?? 30,
clock_rounding_minutes: sysSettingsData.clock_rounding_minutes ?? 15,
invoice_alert_email: sysSettingsData.invoice_alert_email || "",
leave_notify_email: sysSettingsData.leave_notify_email || "",
smtp_from: sysSettingsData.smtp_from || "",
smtp_from_name: sysSettingsData.smtp_from_name || "",
max_login_attempts: sysSettingsData.max_login_attempts ?? 5,
lockout_minutes: sysSettingsData.lockout_minutes ?? 15,
max_requests_per_minute: sysSettingsData.max_requests_per_minute ?? 300,
default_currency: sysSettingsData.default_currency || "CZK",
default_vat_rate: sysSettingsData.default_vat_rate ?? 21,
available_vat_rates:
Array.isArray(d.available_vat_rates) && d.available_vat_rates.length > 0
? (d.available_vat_rates as number[])
Array.isArray(sysSettingsData.available_vat_rates) &&
sysSettingsData.available_vat_rates.length > 0
? sysSettingsData.available_vat_rates
: [0, 10, 12, 15, 21],
available_currencies:
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
? (d.available_currencies as string[])
Array.isArray(sysSettingsData.available_currencies) &&
sysSettingsData.available_currencies.length > 0
? sysSettingsData.available_currencies
: ["CZK", "EUR", "USD", "GBP"],
quotation_prefix: (d.quotation_prefix as string) || "NA",
order_type_code: (d.order_type_code as string) || "71",
invoice_type_code: (d.invoice_type_code as string) || "81",
quotation_prefix: sysSettingsData.quotation_prefix || "NA",
order_type_code: sysSettingsData.order_type_code || "71",
invoice_type_code: sysSettingsData.invoice_type_code || "81",
offer_number_pattern:
(d.offer_number_pattern as string) || "{YYYY}/{PREFIX}/{NNN}",
sysSettingsData.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}",
order_number_pattern:
(d.order_number_pattern as string) || "{YY}{CODE}{NNNN}",
sysSettingsData.order_number_pattern || "{YY}{CODE}{NNNN}",
invoice_number_pattern:
(d.invoice_number_pattern as string) || "{YY}{CODE}{NNNN}",
sysSettingsData.invoice_number_pattern || "{YY}{CODE}{NNNN}",
});
setSysFormInitialized(true);
}, [sysSettingsData, sysFormInitialized]);
@@ -261,6 +263,11 @@ export default function Settings() {
if (result.success) {
alert.success(result.message || "Systémová nastavení byla uložena");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
@@ -282,7 +289,9 @@ export default function Settings() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "2FA nastavení uloženo");
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
@@ -401,7 +410,9 @@ export default function Settings() {
result.message ||
(editingRole ? "Role byla aktualizována" : "Role byla vytvořena"),
);
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(result.error || "Nepodařilo se uložit roli");
}
@@ -429,7 +440,9 @@ export default function Settings() {
if (result.success) {
setDeleteConfirm({ show: false, role: null });
alert.success(result.message || "Role byla smazána");
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(result.error || "Nepodařilo se smazat roli");
}

View File

@@ -13,32 +13,16 @@ import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import { tripListOptions, tripVehiclesOptions } from "../lib/queries/trips";
import {
tripListOptions,
tripVehiclesOptions,
type BackendTrip,
type TripVehicle,
} from "../lib/queries/trips";
import { Skeleton } from "boneyard-js/react";
import TripsFixture from "../fixtures/TripsFixture";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string;
spz: string;
name: string;
}
interface Trip {
id: number;
vehicle_id: number | string;
trip_date: string;
start_km: number;
end_km: number;
distance?: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes?: string | null;
users?: { id: number; first_name: string; last_name: string };
vehicles?: { id: number; name: string; spz: string };
}
interface TripForm {
vehicle_id: string;
trip_date: string;
@@ -60,15 +44,12 @@ export default function Trips() {
);
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
const trips = (tripsData ?? []) as Record<string, unknown>[] as Trip[];
const vehicles = (vehiclesData ?? []) as Record<
string,
unknown
>[] as Vehicle[];
const trips = tripsData ?? [];
const vehicles = vehiclesData ?? [];
const [submitting, setSubmitting] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
const [editingTrip, setEditingTrip] = useState<BackendTrip | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
tripId: number | null;
@@ -130,7 +111,7 @@ export default function Trips() {
setShowModal(true);
};
const openEditModal = (trip: Trip) => {
const openEditModal = (trip: BackendTrip) => {
setEditingTrip(trip);
setForm({
vehicle_id: String(trip.vehicle_id),

View File

@@ -10,8 +10,14 @@ import {
tripListOptions,
tripVehiclesOptions,
tripUsersOptions,
type BackendTrip,
type TripVehicle,
type TripUser,
} from "../lib/queries/trips";
import { companySettingsOptions } from "../lib/queries/settings";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
@@ -23,17 +29,6 @@ import { Skeleton } from "boneyard-js/react";
import TripsAdminFixture from "../fixtures/TripsAdminFixture";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string;
spz: string;
name: string;
}
interface UserShort {
id: number | string;
name: string;
}
interface Trip {
id: number;
vehicle_id: number | string;
@@ -49,22 +44,6 @@ interface Trip {
driver_name: string;
}
interface BackendTrip {
id: number;
vehicle_id: number;
user_id: number;
trip_date: string;
start_km: number;
end_km: number;
distance: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes: string | null;
users: { id: number; first_name: string; last_name: string };
vehicles: { id: number; name: string; spz: string };
}
interface EditForm {
vehicle_id: string;
trip_date: string;
@@ -127,15 +106,13 @@ export default function TripsAdmin() {
}>({ show: false, trip: null });
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData as Vehicle[];
const vehicles = vehiclesData;
const { data: tripUsersData = [] } = useQuery(tripUsersOptions());
const tripUsers = tripUsersData as UserShort[];
const tripUsers = tripUsersData;
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName =
((companySettings as Record<string, unknown> | undefined)
?.company_name as string) ?? "";
const companyName = companySettings?.company_name ?? "";
const { data: tripsData, isPending } = useQuery(
tripListOptions({
@@ -146,11 +123,11 @@ export default function TripsAdmin() {
perPage: 100,
}),
);
const trips = ((tripsData ?? []) as BackendTrip[]).map(mapTrip);
const trips = (tripsData ?? []).map(mapTrip);
useModalLock(showEditModal);
if (!hasPermission("trips.admin")) return <Forbidden />;
if (!hasPermission("trips.manage")) return <Forbidden />;
const openEditModal = (trip: Trip) => {
setEditingTrip(trip);

View File

@@ -8,16 +8,15 @@ import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import FormField from "../components/FormField";
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
import {
tripHistoryOptions,
tripVehiclesOptions,
type BackendTrip,
type TripVehicle,
} from "../lib/queries/trips";
import { Skeleton } from "boneyard-js/react";
import TripsHistoryFixture from "../fixtures/TripsHistoryFixture";
interface Vehicle {
id: number | string;
spz: string;
name: string;
}
interface Trip {
id: number;
trip_date: string;
@@ -42,7 +41,7 @@ export default function TripsHistory() {
const [vehicleId, setVehicleId] = useState("");
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData as Vehicle[];
const vehicles = vehiclesData;
const { data: tripsData, isPending } = useQuery(
tripHistoryOptions({
@@ -51,14 +50,21 @@ export default function TripsHistory() {
userId: user?.id,
}),
);
const trips = ((tripsData ?? []) as Record<string, unknown>[]).map((t) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || "",
const trips = (tripsData ?? []).map((t) => ({
id: t.id,
trip_date: t.trip_date,
spz: t.vehicles?.spz ?? "",
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ""} ${(t.users as Record<string, string>).last_name || ""}`.trim()
? `${t.users.first_name} ${t.users.last_name}`.trim()
: "",
distance: ((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})) as Trip[];
route_from: t.route_from,
route_to: t.route_to,
start_km: t.start_km,
end_km: t.end_km,
distance: t.distance ?? t.end_km - t.start_km,
is_business: t.is_business,
notes: t.notes ?? undefined,
}));
const totals = trips.reduce(
(acc, t) => ({

View File

@@ -8,29 +8,17 @@ import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import { userListOptions, roleListOptions } from "../lib/queries/users";
import {
userListOptions,
roleListOptions,
type User,
type Role,
} from "../lib/queries/users";
import UsersFixture from "../fixtures/UsersFixture";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface User {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
role_id: number;
roles?: { id: number; name: string; display_name: string } | null;
is_active: boolean;
}
interface Role {
id: number;
name: string;
display_name: string;
}
interface FormData {
username: string;
email: string;
@@ -46,10 +34,8 @@ export default function Users() {
const alert = useAlert();
const queryClient = useQueryClient();
const { data: usersData, isPending } = useQuery(userListOptions());
const users = ((usersData as Record<string, unknown>)?.items ??
usersData ??
[]) as User[];
const { data: roles = [] } = useQuery(roleListOptions()) as { data: Role[] };
const users = usersData ?? [];
const { data: roles = [] } = useQuery(roleListOptions());
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deleteModal, setDeleteModal] = useState<{
@@ -144,6 +130,11 @@ export default function Users() {
wasEditing ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
);
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(data.error || "Nepodařilo se uložit uživatele");
}
@@ -177,6 +168,11 @@ export default function Users() {
if (data.success) {
closeDeleteModal();
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
alert.success("Uživatel byl smazán");
} else {
alert.error(data.error || "Nepodařilo se smazat uživatele");
@@ -202,6 +198,11 @@ export default function Users() {
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
alert.success(
user.is_active
? "Uživatel byl deaktivován"

View File

@@ -63,7 +63,7 @@ export default function Vehicles() {
useModalLock(showModal);
if (!hasPermission("trips.vehicles")) return <Forbidden />;
if (!hasPermission("vehicles.manage")) return <Forbidden />;
const openCreateModal = () => {
setEditingVehicle(null);
@@ -117,6 +117,7 @@ export default function Vehicles() {
if (result.success) {
setShowModal(false);
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -142,6 +143,7 @@ export default function Vehicles() {
if (result.success) {
setDeleteConfirm({ show: false, vehicle: null });
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -163,6 +165,7 @@ export default function Vehicles() {
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(
vehicle.is_active
? "Vozidlo bylo deaktivováno"