diff --git a/CLAUDE.md b/CLAUDE.md index e41c97d..ed00f7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,10 +75,14 @@ npm test # vitest run (single pass) npm run test:watch # vitest watch # Database -npx prisma migrate dev # Apply migrations (dev) -npx prisma migrate deploy # Apply migrations (prod) -npx prisma generate # Regenerate Prisma client after schema changes -npx prisma studio # DB browser GUI +npx prisma migrate dev # Create migration from schema changes + apply to dev +npx prisma migrate dev --name # Named migration +npx prisma migrate deploy # Apply pending migrations to production +npx prisma generate # Regenerate Prisma client after schema changes +npx prisma studio # DB browser GUI +npx prisma db seed # (Re)seed the dev database +npx prisma migrate diff --from-url --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy +npx prisma migrate resolve --applied # Mark migration as applied without running SQL ``` **Do not start the dev server.** The user manages it separately. @@ -248,6 +252,24 @@ When adding new features, add tests in `src/__tests__/`. Name test files ` +``` + +--- + ## Known Issues & Gotchas 1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization. @@ -276,7 +361,7 @@ When adding new features, add tests in `src/__tests__/`. Name test files ` p.id)); + const staleIds = existingAssignments.filter( + (a) => !validIds.has(a.permission_id), + ); + if (staleIds.length > 0) { + for (const s of staleIds) { + await prisma.role_permissions.delete({ + where: { + role_id_permission_id: { + role_id: adminRole.id, + permission_id: s.permission_id, + }, + }, + }); + } + console.log(` Removed ${staleIds.length} stale role_permissions`); + } + + // Add missing assignments + const existingIds = new Set(existingAssignments.map((a) => a.permission_id)); + let added = 0; + for (const perm of allPerms) { + if (!existingIds.has(perm.id)) { + await prisma.role_permissions.create({ + data: { role_id: adminRole.id, permission_id: perm.id }, + }); + added++; + } + } + if (added > 0) console.log(` Added ${added} role_permission assignments`); + console.log(` Admin role now has all ${allPerms.length} permissions`); + + // 4. Ensure admin user exists + let adminUser = await prisma.users.findFirst({ + where: { username: "admin" }, + }); + if (!adminUser) { + const hash = bcrypt.hashSync("admin", 10); + adminUser = await prisma.users.create({ + data: { + username: "admin", + email: "admin@boha.local", + password_hash: hash, + first_name: "Admin", + last_name: "BOHA", + role_id: adminRole.id, + is_active: true, + }, + }); + console.log(" Created admin user (admin / admin)"); + } else if (adminUser.role_id !== adminRole.id) { + await prisma.users.update({ + where: { id: adminUser.id }, + data: { role_id: adminRole.id }, + }); + console.log(" Updated admin user role_id to admin role"); + } else { + console.log(" Admin user already exists with correct role"); + } + + // 5. Create default company_settings if not exists + const existingSettings = await prisma.company_settings.findFirst(); + if (!existingSettings) { + await prisma.company_settings.create({ + data: { + company_name: "BOHA s.r.o.", + default_currency: "CZK", + default_vat_rate: 21.0, + available_vat_rates: JSON.stringify([0, 10, 15, 21]), + available_currencies: JSON.stringify(["CZK", "EUR"]), + offer_number_pattern: "NAB-{YYYY}-{NNN}", + order_number_pattern: "OBJ-{YYYY}-{NNN}", + invoice_number_pattern: "FV-{YYYY}-{NNN}", + break_threshold_hours: 6.0, + break_duration_short: 15, + break_duration_long: 30, + clock_rounding_minutes: 15, + require_2fa: false, + max_login_attempts: 5, + lockout_minutes: 15, + max_requests_per_minute: 300, + }, + }); + console.log(" Created default company_settings"); + } else { + console.log(" company_settings already exists"); + } + + // 6. Initialize number_sequences for current year + const year = new Date().getFullYear(); + const types = ["quotation", "order", "invoice"]; + for (const type of types) { + const existing = await prisma.number_sequences.findFirst({ + where: { type, year }, + }); + if (!existing) { + await prisma.number_sequences.create({ + data: { type, year, last_number: 0 }, + }); + console.log(` Created number_sequence: ${type}/${year}`); + } + } + console.log(" number_sequences ready"); + + console.log( + "\nSeed complete — " + + PERMISSIONS.length + + " permissions, 1 admin role, company_settings + number_sequences ready.", + ); +} + +main() + .catch((e) => { + console.error("Seed failed:", e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/admin/components/Sidebar.tsx b/src/admin/components/Sidebar.tsx index aa9f80e..ece8a76 100644 --- a/src/admin/components/Sidebar.tsx +++ b/src/admin/components/Sidebar.tsx @@ -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: ( { 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" /> +
+ + + setFormData({ + ...formData, + current_password: e.target.value, + }) + } + className="admin-form-input" + placeholder="Pro změnu hesla, zadejte aktuální heslo" + /> +
- {formData.new_password && ( -
- - - setFormData({ - ...formData, - current_password: e.target.value, - }) - } - className="admin-form-input" - placeholder="Zadejte aktuální heslo pro potvrzení" - /> -
- )}
diff --git a/src/admin/hooks/useApiCall.ts b/src/admin/hooks/useApiCall.ts deleted file mode 100644 index 11939e8..0000000 --- a/src/admin/hooks/useApiCall.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback, useRef } from "react"; -import { useAlert } from "../context/AlertContext"; -import apiFetch from "../utils/api"; - -interface ApiCallResult { - data: T | null; - ok: boolean; - response: Response | null; -} - -export default function useApiCall() { - const alert = useAlert(); - const abortRef = useRef(null); - - const call = useCallback( - async ( - url: string, - options: RequestInit = {}, - errorMsg = "Chyba při načítání dat", - ): Promise> => { - 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 }; -} diff --git a/src/admin/hooks/useAttendanceAdmin.ts b/src/admin/hooks/useAttendanceAdmin.ts index ca60076..d87c561 100644 --- a/src/admin/hooks/useAttendanceAdmin.ts +++ b/src/admin/hooks/useAttendanceAdmin.ts @@ -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", diff --git a/src/admin/hooks/useListData.ts b/src/admin/hooks/useListData.ts deleted file mode 100644 index c7d6182..0000000 --- a/src/admin/hooks/useListData.ts +++ /dev/null @@ -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; - errorMsg?: string; -} - -export default function useListData( - 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([]); - const [loading, setLoading] = useState(true); - const [initialLoad, setInitialLoad] = useState(true); - const [pagination, setPagination] = useState(null); - const abortRef = useRef(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, - }; -} diff --git a/src/admin/lib/queries/attendance.ts b/src/admin/lib/queries/attendance.ts index 7a2acd8..06459a6 100644 --- a/src/admin/lib/queries/attendance.ts +++ b/src/admin/lib/queries/attendance.ts @@ -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; +} + +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; +} + +export interface FundData { + months: Record; + holidays: unknown[]; + users: UserShort[]; + balances: Record; +} + +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; +} + 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[]>( + return jsonQuery( `/api/admin/attendance?${params.toString()}`, ); }, @@ -54,7 +142,7 @@ export const attendanceBalancesOptions = (year: number) => queryOptions({ queryKey: ["attendance", "balances", year], queryFn: () => - jsonQuery>( + jsonQuery( `/api/admin/attendance?action=balances&year=${year}`, ), }); @@ -63,16 +151,14 @@ export const attendanceWorkFundOptions = (year: number) => queryOptions({ queryKey: ["attendance", "workfund", year], queryFn: () => - jsonQuery>( - `/api/admin/attendance?action=workfund&year=${year}`, - ), + jsonQuery(`/api/admin/attendance?action=workfund&year=${year}`), }); export const attendanceProjectReportOptions = (year: number) => queryOptions({ queryKey: ["attendance", "project-report", year], queryFn: () => - jsonQuery>( + jsonQuery( `/api/admin/attendance?action=project_report&year=${year}`, ), }); diff --git a/src/admin/lib/queries/common.ts b/src/admin/lib/queries/common.ts index 5164eac..898f309 100644 --- a/src/admin/lib/queries/common.ts +++ b/src/admin/lib/queries/common.ts @@ -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[]>("/api/admin/bank-accounts"), + queryFn: () => jsonQuery("/api/admin/bank-accounts"), staleTime: 2 * 60_000, }); diff --git a/src/admin/lib/queries/invoices.ts b/src/admin/lib/queries/invoices.ts index 8519cd9..f566db6 100644 --- a/src/admin/lib/queries/invoices.ts +++ b/src/admin/lib/queries/invoices.ts @@ -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( `/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>( + jsonQuery( `/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>(`/api/admin/invoices/${id}`), + queryFn: () => jsonQuery(`/api/admin/invoices/${id}`), enabled: !!id, }); diff --git a/src/admin/lib/queries/leave.ts b/src/admin/lib/queries/leave.ts index 3ba59e0..d29b037 100644 --- a/src/admin/lib/queries/leave.ts +++ b/src/admin/lib/queries/leave.ts @@ -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>( + jsonQuery( `/api/admin/leave-requests${mine ? "?mine=1" : ""}`, ), }); diff --git a/src/admin/lib/queries/offers.ts b/src/admin/lib/queries/offers.ts index 571920b..8a0d6ab 100644 --- a/src/admin/lib/queries/offers.ts +++ b/src/admin/lib/queries/offers.ts @@ -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>("/api/admin/customers"), + queryFn: () => jsonQuery("/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[]>(url); - }, + queryKey: ["offer-templates", "items"], + queryFn: () => + jsonQuery("/api/admin/offers-templates?action=items"), + }); + +export const scopeTemplatesOptions = () => + queryOptions({ + queryKey: ["offer-templates", "scopes"], + queryFn: () => jsonQuery("/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>(`/api/admin/offers/${id}`), + queryFn: () => jsonQuery(`/api/admin/offers/${id}`), enabled: !!id, }); diff --git a/src/admin/lib/queries/orders.ts b/src/admin/lib/queries/orders.ts index 7ef80bd..8b70bd1 100644 --- a/src/admin/lib/queries/orders.ts +++ b/src/admin/lib/queries/orders.ts @@ -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>(`/api/admin/orders/${id}`), + queryFn: () => jsonQuery(`/api/admin/orders/${id}`), enabled: !!id, }); diff --git a/src/admin/lib/queries/projects.ts b/src/admin/lib/queries/projects.ts index bde7f14..acef564 100644 --- a/src/admin/lib/queries/projects.ts +++ b/src/admin/lib/queries/projects.ts @@ -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>(`/api/admin/projects/${id}`), + queryFn: () => jsonQuery(`/api/admin/projects/${id}`), enabled: !!id, }); diff --git a/src/admin/lib/queries/settings.ts b/src/admin/lib/queries/settings.ts index a48fefc..4e6437a 100644 --- a/src/admin/lib/queries/settings.ts +++ b/src/admin/lib/queries/settings.ts @@ -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>("/api/admin/company-settings"), + jsonQuery("/api/admin/company-settings"), staleTime: 5 * 60_000, }); diff --git a/src/admin/lib/queries/trips.ts b/src/admin/lib/queries/trips.ts index 95a9c55..2a4fd1e 100644 --- a/src/admin/lib/queries/trips.ts +++ b/src/admin/lib/queries/trips.ts @@ -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[]>( - `/api/admin/trips${qs ? `?${qs}` : ""}`, - ); + return jsonQuery(`/api/admin/trips${qs ? `?${qs}` : ""}`); }, }); export const tripVehiclesOptions = () => queryOptions({ queryKey: ["trips", "vehicles"], - queryFn: () => jsonQuery[]>("/api/admin/vehicles"), + queryFn: () => jsonQuery("/api/admin/vehicles"), staleTime: 2 * 60_000, }); export const tripUsersOptions = () => queryOptions({ queryKey: ["trips", "users"], - queryFn: () => - jsonQuery[]>("/api/admin/trips/users"), + queryFn: () => jsonQuery("/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[]>( - `/api/admin/trips${qs ? `?${qs}` : ""}`, - ); + return jsonQuery(`/api/admin/trips${qs ? `?${qs}` : ""}`); }, }); diff --git a/src/admin/lib/queries/users.ts b/src/admin/lib/queries/users.ts index 32c1eb9..9e3ce42 100644 --- a/src/admin/lib/queries/users.ts +++ b/src/admin/lib/queries/users.ts @@ -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>(url); + return jsonQuery(url); }, }); export const roleListOptions = () => queryOptions({ queryKey: ["roles"], - queryFn: () => jsonQuery[]>("/api/admin/roles"), + queryFn: () => jsonQuery("/api/admin/roles"), staleTime: 2 * 60_000, }); diff --git a/src/admin/offers.css b/src/admin/offers.css index 563175d..eb14e36 100644 --- a/src/admin/offers.css +++ b/src/admin/offers.css @@ -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 { diff --git a/src/admin/pages/Attendance.tsx b/src/admin/pages/Attendance.tsx index 697725d..da626c0 100644 --- a/src/admin/pages/Attendance.tsx +++ b/src/admin/pages/Attendance.tsx @@ -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() { - {hasPermission("attendance.admin") && ( + {hasPermission("attendance.manage") && ( ; + if (!hasPermission("attendance.manage")) return ; // Show skeleton only on initial load (no data yet), not on filter changes const isInitialLoad = diff --git a/src/admin/pages/AttendanceBalances.tsx b/src/admin/pages/AttendanceBalances.tsx index a6901c1..6271b9a 100644 --- a/src/admin/pages/AttendanceBalances.tsx +++ b/src/admin/pages/AttendanceBalances.tsx @@ -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; -} - -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; -} - -interface FundData { - months: Record; - holidays: unknown[]; - users: UserShort[]; - balances: Record; -} - -interface ProjectData { - months: Record; -} - 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<{ diff --git a/src/admin/pages/AttendanceCreate.tsx b/src/admin/pages/AttendanceCreate.tsx index 6eec4d7..2439c0d 100644 --- a/src/admin/pages/AttendanceCreate.tsx +++ b/src/admin/pages/AttendanceCreate.tsx @@ -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(() => { @@ -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 ; + if (!hasPermission("attendance.manage")) return ; return ( | undefined) - ?.company_name as string) || ""; + const companyName = companySettings?.company_name || ""; const printRef = useRef(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("-"); diff --git a/src/admin/pages/AttendanceLocation.tsx b/src/admin/pages/AttendanceLocation.tsx index 525670e..507f542 100644 --- a/src/admin/pages/AttendanceLocation.tsx +++ b/src/admin/pages/AttendanceLocation.tsx @@ -154,7 +154,7 @@ export default function AttendanceLocation() { return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`; }; - if (!hasPermission("attendance.admin")) return ; + if (!hasPermission("attendance.manage")) return ; if (!record) { return null; diff --git a/src/admin/pages/CompanySettings.tsx b/src/admin/pages/CompanySettings.tsx index 441acc6..c075af0 100644 --- a/src/admin/pages/CompanySettings.tsx +++ b/src/admin/pages/CompanySettings.tsx @@ -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; 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 ; + if (!embedded && !hasPermission("settings.company")) return ; if (settingsLoading) { return ( diff --git a/src/admin/pages/Dashboard.tsx b/src/admin/pages/Dashboard.tsx index 784a605..53a4e3a 100644 --- a/src/admin/pages/Dashboard.tsx +++ b/src/admin/pages/Dashboard.tsx @@ -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() { )} - {hasPermission("attendance.admin") && ( + {hasPermission("attendance.manage") && ( )} diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 6fa9b1d..ed851f3 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -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[]; - valid_transitions?: string[]; -} - // Sortable row for create mode function SortableInvoiceRow({ item, @@ -226,7 +179,7 @@ function SortableInvoiceRow({ 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({ 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(() => { - const data = customersQuery.data; - if (!data) return []; - if (Array.isArray(data)) return data as Customer[]; - const obj = data as Record; - 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; + 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[] | 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"); diff --git a/src/admin/pages/Invoices.tsx b/src/admin/pages/Invoices.tsx index a735300..1cc2077 100644 --- a/src/admin/pages/Invoices.tsx +++ b/src/admin/pages/Invoices.tsx @@ -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"); } diff --git a/src/admin/pages/LeaveApproval.tsx b/src/admin/pages/LeaveApproval.tsx index a9e65ab..f5a6004 100644 --- a/src/admin/pages/LeaveApproval.tsx +++ b/src/admin/pages/LeaveApproval.tsx @@ -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); diff --git a/src/admin/pages/LeaveRequests.tsx b/src/admin/pages/LeaveRequests.tsx index f124de8..e7d5414 100644 --- a/src/admin/pages/LeaveRequests.tsx +++ b/src/admin/pages/LeaveRequests.tsx @@ -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 = { 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); diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index ca9fe5f..1ab5743 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -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({ 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({ 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)?.customers - ? ((customersData as Record).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(null); + const [orderInfo, setOrderInfo] = useState(null); const [offerStatus, setOfferStatus] = useState(""); const [deleteConfirm, setDeleteConfirm] = useState(false); @@ -409,14 +394,7 @@ export default function OfferDetail() { const [orderAttachment, setOrderAttachment] = useState(null); const [pdfLoading, setPdfLoading] = useState(false); const blobTimeoutsRef = useRef[]>([]); - 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; + 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).next_number as string) || - ((nextNumberData as Record).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 ; @@ -915,6 +901,17 @@ export default function OfferDetail() { Zneplatněna )} + {isCompleted && ( + + Dokončeno + + )}
@@ -949,7 +946,7 @@ export default function OfferDetail() { )} {isEdit && - !isInvalidated && + !readOnly && hasPermission("orders.create") && !orderInfo && ( + )} + {!readOnly && q.order_id ? ( ) : ( - !isInvalidated && + !readOnly && hasPermission("orders.create") && ( )} - {hasPermission("offers.delete") && ( + {hasPermission("customers.delete") && ( + + + + + Potvrzení objednávky + + )} {hasPermission("orders.edit") && order.valid_transitions?.filter((s) => s !== "stornovana") .length! > 0 && diff --git a/src/admin/pages/Orders.tsx b/src/admin/pages/Orders.tsx index 7b3ae50..387da0a 100644 --- a/src/admin/pages/Orders.tsx +++ b/src/admin/pages/Orders.tsx @@ -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"); } diff --git a/src/admin/pages/ProjectDetail.tsx b/src/admin/pages/ProjectDetail.tsx index f687113..dcea432 100644 --- a/src/admin/pages/ProjectDetail.tsx +++ b/src/admin/pages/ProjectDetail.tsx @@ -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)?.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"); } diff --git a/src/admin/pages/Projects.tsx b/src/admin/pages/Projects.tsx index 7fd1691..98f2901 100644 --- a/src/admin/pages/Projects.tsx +++ b/src/admin/pages/Projects.tsx @@ -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.

) : ( @@ -291,7 +292,7 @@ export default function Projects() { {!p.order_id && - hasPermission("projects.create") && ( + hasPermission("projects.delete") && (