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:
@@ -116,7 +116,7 @@ const menuSections: MenuSection[] = [
|
||||
{
|
||||
path: "/attendance/admin",
|
||||
label: "Správa",
|
||||
permission: "attendance.admin",
|
||||
permission: "attendance.manage",
|
||||
matchPrefix: "/attendance/admin",
|
||||
matchAlso: ["/attendance/create", "/attendance/location"],
|
||||
icon: (
|
||||
@@ -198,7 +198,7 @@ const menuSections: MenuSection[] = [
|
||||
{
|
||||
path: "/trips/admin",
|
||||
label: "Správa",
|
||||
permission: "trips.admin",
|
||||
permission: "trips.manage",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -221,7 +221,7 @@ const menuSections: MenuSection[] = [
|
||||
{
|
||||
path: "/vehicles",
|
||||
label: "Vozidla",
|
||||
permission: "trips.vehicles",
|
||||
permission: "vehicles.manage",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -315,7 +315,7 @@ const menuSections: MenuSection[] = [
|
||||
{
|
||||
path: "/offers/customers",
|
||||
label: "Zákazníci",
|
||||
permission: "offers.view",
|
||||
permission: "customers.view",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -356,7 +356,12 @@ const menuSections: MenuSection[] = [
|
||||
{
|
||||
path: "/settings",
|
||||
label: "Nastavení",
|
||||
permission: "settings.manage",
|
||||
permission: [
|
||||
"settings.company",
|
||||
"settings.banking",
|
||||
"settings.roles",
|
||||
"settings.templates",
|
||||
],
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -89,6 +89,21 @@ export default function DashProfile({
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
if (dataToSave.new_password && !dataToSave.current_password) {
|
||||
alert.error("Pro změnu hesla zadejte aktuální heslo");
|
||||
return;
|
||||
}
|
||||
if (dataToSave.current_password && !dataToSave.new_password) {
|
||||
alert.error("Pro změnu hesla zadejte nové heslo");
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip empty password fields so Zod doesn't reject ""
|
||||
if (!dataToSave.current_password)
|
||||
delete (dataToSave as any).current_password;
|
||||
if (!dataToSave.new_password) delete (dataToSave as any).new_password;
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/profile`, {
|
||||
method: "PUT",
|
||||
@@ -329,9 +344,24 @@ export default function DashProfile({
|
||||
className="admin-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Aktuální heslo</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.current_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
current_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte aktuální heslo"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Nové heslo (ponechte prázdné pro zachování stávajícího)
|
||||
Nové heslo
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -343,27 +373,9 @@ export default function DashProfile({
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Pro změnu hesla, zadejte nové heslo"
|
||||
/>
|
||||
</div>
|
||||
{formData.new_password && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">
|
||||
Aktuální heslo
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.current_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
current_password: e.target.value,
|
||||
})
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="Zadejte aktuální heslo pro potvrzení"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
interface ApiCallResult<T> {
|
||||
data: T | null;
|
||||
ok: boolean;
|
||||
response: Response | null;
|
||||
}
|
||||
|
||||
export default function useApiCall() {
|
||||
const alert = useAlert();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const call = useCallback(
|
||||
async <T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
errorMsg = "Chyba při načítání dat",
|
||||
): Promise<ApiCallResult<T>> => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const { signal: _, ...restOptions } = options;
|
||||
const response = await apiFetch(url, {
|
||||
...restOptions,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
alert.error(data.error || errorMsg);
|
||||
return { data: null, ok: false, response };
|
||||
}
|
||||
return { data: data.data as T, ok: true, response };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return { data: null, ok: false, response: null };
|
||||
}
|
||||
alert.error(errorMsg);
|
||||
return { data: null, ok: false, response: null };
|
||||
}
|
||||
},
|
||||
[alert],
|
||||
);
|
||||
|
||||
return { call };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import apiFetch from "../utils/api";
|
||||
import {
|
||||
calcProjectMinutesTotal,
|
||||
@@ -461,6 +462,7 @@ function buildPrintHtml(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
const queryClient = useQueryClient();
|
||||
// ---- Core state ----
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [month, setMonth] = useState(() => {
|
||||
@@ -771,6 +773,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
|
||||
if (result.success) {
|
||||
setShowCreateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
@@ -842,6 +845,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
|
||||
if (result.success) {
|
||||
setShowBulkModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
@@ -976,6 +980,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
|
||||
if (result.success) {
|
||||
setShowEditModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
@@ -1005,6 +1010,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, record: null });
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
await fetchData(false);
|
||||
alert.success(
|
||||
result.message || result.data?.message || "Záznam smazán",
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import apiFetch from "../utils/api";
|
||||
import useDebounce from "./useDebounce";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface PaginationData {
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface UseListDataOptions {
|
||||
dataKey?: string;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
extraParams?: Record<string, string>;
|
||||
errorMsg?: string;
|
||||
}
|
||||
|
||||
export default function useListData<T = unknown>(
|
||||
endpoint: string,
|
||||
options: UseListDataOptions = {},
|
||||
) {
|
||||
const {
|
||||
dataKey,
|
||||
search = "",
|
||||
sort,
|
||||
order,
|
||||
page = 1,
|
||||
perPage = 25,
|
||||
extraParams = {},
|
||||
errorMsg = "Nepodařilo se načíst data",
|
||||
} = options;
|
||||
const alert = useAlert();
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [pagination, setPagination] = useState<PaginationData | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const extraParamsKey = Object.entries(extraParams)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("&");
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
});
|
||||
if (debouncedSearch) params.set("search", debouncedSearch);
|
||||
if (sort) params.set("sort", sort);
|
||||
if (order) params.set("order", order);
|
||||
Object.entries(extraParams).forEach(([k, v]) => {
|
||||
if (v) params.set(k, v);
|
||||
});
|
||||
|
||||
const url = endpoint.startsWith("/")
|
||||
? `${endpoint}?${params}`
|
||||
: `${API_BASE}/${endpoint}?${params}`;
|
||||
const response = await apiFetch(url, { signal: controller.signal });
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const data = dataKey
|
||||
? result.data[dataKey]
|
||||
: Array.isArray(result.data)
|
||||
? result.data
|
||||
: result.data?.items || [];
|
||||
setItems(data || []);
|
||||
const pag =
|
||||
result.pagination ||
|
||||
(!Array.isArray(result.data) && result.data?.pagination) ||
|
||||
null;
|
||||
setPagination(
|
||||
pag || {
|
||||
total: data?.length ?? 0,
|
||||
page,
|
||||
per_page: perPage,
|
||||
total_pages: 1,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
alert.error(result.error || errorMsg);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
if (!mountedRef.current) return;
|
||||
alert.error(errorMsg);
|
||||
} finally {
|
||||
if (!mountedRef.current) return;
|
||||
setLoading(false);
|
||||
setInitialLoad(false);
|
||||
}
|
||||
}, [
|
||||
endpoint,
|
||||
debouncedSearch,
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
perPage,
|
||||
dataKey,
|
||||
extraParamsKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchData();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
items,
|
||||
setItems,
|
||||
loading,
|
||||
initialLoad,
|
||||
pagination,
|
||||
refetch: fetchData,
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,94 @@ export interface LocationRecord {
|
||||
departure_address?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectLogEntry {
|
||||
id?: number;
|
||||
project_id?: number;
|
||||
project_name?: string;
|
||||
started_at?: string;
|
||||
ended_at?: string | null;
|
||||
hours?: string | number | null;
|
||||
minutes?: string | number | null;
|
||||
}
|
||||
|
||||
export 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?: ProjectLogEntry[];
|
||||
}
|
||||
|
||||
export interface BalanceEntry {
|
||||
name: string;
|
||||
vacation_total: number;
|
||||
vacation_used: number;
|
||||
vacation_remaining: number;
|
||||
sick_used: number;
|
||||
}
|
||||
|
||||
export interface UserShort {
|
||||
id: number | string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BalancesData {
|
||||
users: UserShort[];
|
||||
balances: Record<string, BalanceEntry>;
|
||||
}
|
||||
|
||||
export interface FundUserData {
|
||||
name: string;
|
||||
worked: number;
|
||||
covered: number;
|
||||
overtime: number;
|
||||
missing: number;
|
||||
}
|
||||
|
||||
export interface MonthFundData {
|
||||
month_name: string;
|
||||
fund: number;
|
||||
fund_to_date: number;
|
||||
business_days: number;
|
||||
users?: Record<string, FundUserData>;
|
||||
}
|
||||
|
||||
export interface FundData {
|
||||
months: Record<string, MonthFundData>;
|
||||
holidays: unknown[];
|
||||
users: UserShort[];
|
||||
balances: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProjectUser {
|
||||
user_id: number;
|
||||
user_name: string;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
export interface ProjectEntry {
|
||||
project_id: number | null;
|
||||
project_number?: string;
|
||||
project_name?: string;
|
||||
hours: number;
|
||||
users: ProjectUser[];
|
||||
}
|
||||
|
||||
export interface MonthProjectData {
|
||||
month_name: string;
|
||||
projects: ProjectEntry[];
|
||||
}
|
||||
|
||||
export interface ProjectData {
|
||||
months: Record<string, MonthProjectData>;
|
||||
}
|
||||
|
||||
export const attendanceHistoryOptions = (filters: {
|
||||
month?: string;
|
||||
userId?: number;
|
||||
@@ -44,7 +132,7 @@ export const attendanceHistoryOptions = (filters: {
|
||||
params.set("limit", "1000");
|
||||
if (filters.month) params.set("month", filters.month);
|
||||
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||
return jsonQuery<Record<string, unknown>[]>(
|
||||
return jsonQuery<AttendanceRecord[]>(
|
||||
`/api/admin/attendance?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
@@ -54,7 +142,7 @@ export const attendanceBalancesOptions = (year: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["attendance", "balances", year],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>>(
|
||||
jsonQuery<BalancesData>(
|
||||
`/api/admin/attendance?action=balances&year=${year}`,
|
||||
),
|
||||
});
|
||||
@@ -63,16 +151,14 @@ export const attendanceWorkFundOptions = (year: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["attendance", "workfund", year],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>>(
|
||||
`/api/admin/attendance?action=workfund&year=${year}`,
|
||||
),
|
||||
jsonQuery<FundData>(`/api/admin/attendance?action=workfund&year=${year}`),
|
||||
});
|
||||
|
||||
export const attendanceProjectReportOptions = (year: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["attendance", "project-report", year],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>>(
|
||||
jsonQuery<ProjectData>(
|
||||
`/api/admin/attendance?action=project_report&year=${year}`,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface BankAccount {
|
||||
id: number;
|
||||
account_name: string;
|
||||
bank_name: string;
|
||||
account_number: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
currency: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export const bankAccountsOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["bank-accounts"],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>[]>("/api/admin/bank-accounts"),
|
||||
queryFn: () => jsonQuery<BankAccount[]>("/api/admin/bank-accounts"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,89 @@ export interface InvoiceStats {
|
||||
vat_month_czk: number;
|
||||
}
|
||||
|
||||
export interface InvoiceItem {
|
||||
id?: number;
|
||||
description: string;
|
||||
item_description?: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface InvoiceDetail {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
customer_id: number | null;
|
||||
customer_name: string;
|
||||
customer?: {
|
||||
company_id?: string;
|
||||
vat_id?: string;
|
||||
};
|
||||
status: string;
|
||||
issue_date: string;
|
||||
due_date: string;
|
||||
taxable_date?: string;
|
||||
tax_date?: string;
|
||||
currency: string;
|
||||
language: string;
|
||||
vat_rate: number;
|
||||
apply_vat: boolean;
|
||||
exchange_rate: string;
|
||||
notes?: string;
|
||||
payment_method?: string;
|
||||
variable_symbol?: string;
|
||||
constant_symbol?: string;
|
||||
issued_by?: string | null;
|
||||
paid_date?: string;
|
||||
billing_text?: string;
|
||||
bank_name?: string;
|
||||
bank_swift?: string;
|
||||
bank_iban?: string;
|
||||
bank_account?: string;
|
||||
bank_account_id?: number | null;
|
||||
items?: InvoiceItem[];
|
||||
subtotal: number;
|
||||
vat_amount: number;
|
||||
total: number;
|
||||
order?: {
|
||||
id: number;
|
||||
order_number: string;
|
||||
status?: string;
|
||||
} | null;
|
||||
order_id?: number;
|
||||
order_number?: string;
|
||||
has_pdf?: boolean;
|
||||
valid_transitions?: string[];
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export const invoiceListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
@@ -93,7 +176,7 @@ export const receivedInvoiceListOptions = (filters: {
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
const qs = params.toString();
|
||||
return paginatedJsonQuery(
|
||||
return paginatedJsonQuery<ReceivedInvoice>(
|
||||
`/api/admin/received-invoices${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
@@ -112,7 +195,7 @@ export const receivedInvoiceStatsOptions = (month: number, year: number) =>
|
||||
queryOptions({
|
||||
queryKey: ["invoices", "received", "stats", month, year],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>>(
|
||||
jsonQuery<ReceivedStats>(
|
||||
`/api/admin/received-invoices/stats?month=${month}&year=${year}`,
|
||||
),
|
||||
});
|
||||
@@ -120,7 +203,6 @@ export const receivedInvoiceStatsOptions = (month: number, year: number) =>
|
||||
export const invoiceDetailOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["invoices", id],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>>(`/api/admin/invoices/${id}`),
|
||||
queryFn: () => jsonQuery<InvoiceDetail>(`/api/admin/invoices/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export 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 const leaveRequestsOptions = (mine = true) =>
|
||||
queryOptions({
|
||||
queryKey: ["leave-requests", mine ? "mine" : "all"],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>>(
|
||||
jsonQuery<LeaveRequest[]>(
|
||||
`/api/admin/leave-requests${mine ? "?mine=1" : ""}`,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,22 +1,67 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface ItemTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
default_price: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface ScopeSection {
|
||||
_key: string;
|
||||
title: string;
|
||||
title_cz: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ScopeTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
scope_template_sections?: ScopeSection[];
|
||||
}
|
||||
|
||||
export interface CustomField {
|
||||
name: string;
|
||||
value: string;
|
||||
showLabel: boolean;
|
||||
_key?: string;
|
||||
}
|
||||
|
||||
export 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[];
|
||||
}
|
||||
|
||||
export const offerCustomersOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offer-customers"],
|
||||
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/customers"),
|
||||
queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
|
||||
export const offerTemplatesOptions = (action?: string) =>
|
||||
export const itemTemplatesOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offer-templates", action ?? "all"],
|
||||
queryFn: () => {
|
||||
const url = action
|
||||
? `/api/admin/offers-templates?action=${action}`
|
||||
: "/api/admin/offers-templates";
|
||||
return jsonQuery<Record<string, unknown>[]>(url);
|
||||
},
|
||||
queryKey: ["offer-templates", "items"],
|
||||
queryFn: () =>
|
||||
jsonQuery<ItemTemplate[]>("/api/admin/offers-templates?action=items"),
|
||||
});
|
||||
|
||||
export const scopeTemplatesOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["offer-templates", "scopes"],
|
||||
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"),
|
||||
});
|
||||
|
||||
export const offerListOptions = (filters: {
|
||||
@@ -40,11 +85,62 @@ export const offerListOptions = (filters: {
|
||||
},
|
||||
});
|
||||
|
||||
export interface OfferItemData {
|
||||
id?: number;
|
||||
description: string;
|
||||
item_description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface OfferSectionData {
|
||||
title: string;
|
||||
title_cz: string;
|
||||
content: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface OfferLockInfo {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
export interface OfferOrderInfo {
|
||||
id: number;
|
||||
order_number: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface OfferDetailData {
|
||||
id: number;
|
||||
quotation_number: string;
|
||||
project_code: string;
|
||||
customer_id: number | null;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
valid_until: string;
|
||||
currency: string;
|
||||
language: string;
|
||||
vat_rate: number;
|
||||
apply_vat: boolean;
|
||||
exchange_rate: string;
|
||||
scope_title: string;
|
||||
scope_description: string;
|
||||
items?: OfferItemData[];
|
||||
sections?: OfferSectionData[];
|
||||
status: string;
|
||||
order: OfferOrderInfo | null;
|
||||
locked_by: OfferLockInfo | null;
|
||||
}
|
||||
|
||||
export const offerDetailOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["offers", id],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>>(`/api/admin/offers/${id}`),
|
||||
queryFn: () => jsonQuery<OfferDetailData>(`/api/admin/offers/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface OrderItem {
|
||||
id?: number;
|
||||
description: string;
|
||||
item_description?: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: number | boolean;
|
||||
}
|
||||
|
||||
export interface OrderSection {
|
||||
id?: number;
|
||||
title: string;
|
||||
title_cz?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface OrderInvoice {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
}
|
||||
|
||||
export interface OrderProject {
|
||||
id: number;
|
||||
project_number: string;
|
||||
name: string;
|
||||
has_nas_folder?: boolean;
|
||||
}
|
||||
|
||||
export 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?: OrderInvoice;
|
||||
project?: OrderProject;
|
||||
}
|
||||
|
||||
export const orderListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
@@ -25,7 +79,6 @@ export const orderListOptions = (filters: {
|
||||
export const orderDetailOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["orders", id],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>>(`/api/admin/orders/${id}`),
|
||||
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,32 @@ import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
||||
import apiFetch from "../../utils/api";
|
||||
|
||||
export interface ProjectNote {
|
||||
id: number;
|
||||
content: string;
|
||||
user_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export 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;
|
||||
project_notes?: ProjectNote[];
|
||||
}
|
||||
|
||||
export const projectListOptions = (filters: {
|
||||
search?: string;
|
||||
sort?: string;
|
||||
@@ -26,8 +52,7 @@ export const projectListOptions = (filters: {
|
||||
export const projectDetailOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["projects", id],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>>(`/api/admin/projects/${id}`),
|
||||
queryFn: () => jsonQuery<ProjectData>(`/api/admin/projects/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,63 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface CompanySettingsCustomField {
|
||||
name: string;
|
||||
value: string;
|
||||
showLabel: boolean;
|
||||
}
|
||||
|
||||
export interface CompanySettingsData {
|
||||
// Company info
|
||||
company_name?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
company_id?: string;
|
||||
vat_id?: string;
|
||||
ico?: string;
|
||||
dic?: string;
|
||||
has_logo?: boolean;
|
||||
has_logo_dark?: boolean;
|
||||
custom_fields?: CompanySettingsCustomField[];
|
||||
supplier_field_order?: string[];
|
||||
|
||||
// System settings
|
||||
require_2fa?: boolean;
|
||||
default_currency?: string;
|
||||
default_vat_rate?: number;
|
||||
available_currencies?: string[];
|
||||
available_vat_rates?: number[];
|
||||
break_threshold_hours?: number;
|
||||
break_duration_short?: number;
|
||||
break_duration_long?: number;
|
||||
clock_rounding_minutes?: number;
|
||||
invoice_alert_email?: string;
|
||||
leave_notify_email?: string;
|
||||
smtp_from?: string;
|
||||
smtp_from_name?: string;
|
||||
max_login_attempts?: number;
|
||||
lockout_minutes?: number;
|
||||
max_requests_per_minute?: number;
|
||||
|
||||
// Numbering
|
||||
quotation_prefix?: string;
|
||||
order_type_code?: string;
|
||||
invoice_type_code?: string;
|
||||
offer_number_pattern?: string;
|
||||
order_number_pattern?: string;
|
||||
invoice_number_pattern?: string;
|
||||
|
||||
// Misc
|
||||
app_version?: string;
|
||||
}
|
||||
|
||||
export const companySettingsOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["company-settings"],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>>("/api/admin/company-settings"),
|
||||
jsonQuery<CompanySettingsData>("/api/admin/company-settings"),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export interface TripVehicle {
|
||||
id: number;
|
||||
spz: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TripUser {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export const tripListOptions = (filters: {
|
||||
month?: number;
|
||||
year?: number;
|
||||
@@ -32,24 +59,21 @@ export const tripListOptions = (filters: {
|
||||
if (filters.page) params.set("page", String(filters.page));
|
||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
||||
const qs = params.toString();
|
||||
return jsonQuery<Record<string, unknown>[]>(
|
||||
`/api/admin/trips${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const tripVehiclesOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["trips", "vehicles"],
|
||||
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/vehicles"),
|
||||
queryFn: () => jsonQuery<TripVehicle[]>("/api/admin/vehicles"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
|
||||
export const tripUsersOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["trips", "users"],
|
||||
queryFn: () =>
|
||||
jsonQuery<Record<string, unknown>[]>("/api/admin/trips/users"),
|
||||
queryFn: () => jsonQuery<TripUser[]>("/api/admin/trips/users"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
|
||||
@@ -75,8 +99,6 @@ export const tripHistoryOptions = (filters: {
|
||||
params.set("vehicle_id", String(filters.vehicleId));
|
||||
if (filters.userId) params.set("user_id", String(filters.userId));
|
||||
const qs = params.toString();
|
||||
return jsonQuery<Record<string, unknown>[]>(
|
||||
`/api/admin/trips${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export const userListOptions = (permission?: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["users", { permission }],
|
||||
@@ -8,13 +25,13 @@ export const userListOptions = (permission?: string) =>
|
||||
const url = permission
|
||||
? `/api/admin/users?permission=${encodeURIComponent(permission)}&limit=100`
|
||||
: "/api/admin/users?limit=100";
|
||||
return jsonQuery<Record<string, unknown>>(url);
|
||||
return jsonQuery<User[]>(url);
|
||||
},
|
||||
});
|
||||
|
||||
export const roleListOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["roles"],
|
||||
queryFn: () => jsonQuery<Record<string, unknown>[]>("/api/admin/roles"),
|
||||
queryFn: () => jsonQuery<Role[]>("/api/admin/roles"),
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
|
||||
@@ -601,6 +601,14 @@
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Completed offer */
|
||||
.offers-completed-row {
|
||||
background: var(
|
||||
--row-completed,
|
||||
color-mix(in srgb, var(--success) 8%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
/* Read-only form (invalidated offer detail) */
|
||||
.offers-readonly input[readonly],
|
||||
.offers-readonly select:disabled {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("-");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user