Compare commits

...

4 Commits

Author SHA1 Message Date
BOHA
1f5885de84 v1.6.9: bump version
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:20:37 +02:00
BOHA
705f58e3d1 v1.6.9: cleanup offer dead columns, add item template picker to offer form
- Remove dead DB columns: uuid, sync_version from quotations/quotation_items/scope_sections; is_deleted from quotation_items/scope_sections; content_editor_height from scope_sections
- Remove unused scope_title/scope_description from offer form state
- Remove dead CSS: offers-template-menu, offers-draft-indicator, exchange-rate
- Remove dead scope_title translation key from offers PDF
- Fix duplicate Customer interface in OfferDetail.tsx (use shared type)
- Add missing title field to ScopeTemplate interface
- Add item template picker dropdown next to "Přidat položku" button

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:20:22 +02:00
BOHA
0330453ad6 v1.6.8: remove offer exchange rate, add invoice item description, offer filters, table animations
- Remove exchange_rate and exchange_rate_date from quotations (schema, service, PDF, form)
- Add item_description field to invoice_items (schema, migration, service, form, PDF)
- Add offer status/customer/order filters with tab-based UI
- Clean up offer statuses to active/ordered/invalidated
- Allow invalidate action for non-ordered offers (not just expired)
- Add fade animations on offers and invoices table data changes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:58:51 +02:00
BOHA
66b98e2765 v1.6.7: add settings.system permission, move numbering/VAT to company tab, rename security tab to roles
- New settings.system permission with migration (idempotent INSERT)
- requireAnyPermission helper for route guards accepting multiple perms
- Move document numbering + currency/VAT cards from system tab to CompanySettings
- Rename security tab to roles, add canManageSystem alongside canManageCompany
- TOTP required endpoint and system-info now use settings.system
- Roles list now includes user_count
- Sidebar Settings link includes settings.system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:03:31 +02:00
30 changed files with 4210 additions and 4048 deletions

572
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.6.6",
"version": "1.6.9",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -18,7 +18,6 @@
"db:studio": "prisma studio",
"test": "vitest run",
"test:watch": "vitest",
"bones": "boneyard-js build http://localhost:3000",
"seed": "tsx prisma/seed.ts"
},
"prisma": {
@@ -42,7 +41,6 @@
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"boneyard-js": "^1.8.1",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
"dotenv": "^17.3.1",

View File

@@ -0,0 +1,4 @@
-- Insert the settings.system permission (also available via seed)
INSERT INTO `permissions` (`name`, `display_name`, `module`, `description`)
VALUES ('settings.system', 'Systémová nastavení', 'settings', 'Spravovat systémová nastavení, 2FA, e-maily, limity')
ON DUPLICATE KEY UPDATE `name` = `name`;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;

View File

@@ -0,0 +1,14 @@
-- AlterTable: quotation_items
ALTER TABLE `quotation_items` DROP COLUMN `uuid`;
ALTER TABLE `quotation_items` DROP COLUMN `is_deleted`;
ALTER TABLE `quotation_items` DROP COLUMN `sync_version`;
-- AlterTable: quotations
ALTER TABLE `quotations` DROP COLUMN `uuid`;
ALTER TABLE `quotations` DROP COLUMN `sync_version`;
-- AlterTable: scope_sections
ALTER TABLE `scope_sections` DROP COLUMN `content_editor_height`;
ALTER TABLE `scope_sections` DROP COLUMN `uuid`;
ALTER TABLE `scope_sections` DROP COLUMN `is_deleted`;
ALTER TABLE `scope_sections` DROP COLUMN `sync_version`;

View File

@@ -153,8 +153,9 @@ model customers {
model invoice_items {
id Int @id @default(autoincrement())
invoice_id Int
description String? @db.VarChar(500)
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
@@ -377,10 +378,7 @@ model quotation_items {
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
is_included_in_total Boolean? @default(true)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
@@index([quotation_id], map: "quotation_id")
@@ -397,17 +395,13 @@ model quotations {
language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
exchange_rate_date DateTime? @db.Date
order_id Int?
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
sync_version Int? @default(0)
orders orders[]
projects projects[]
quotation_items quotation_items[]
@@ -492,11 +486,7 @@ model scope_sections {
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
content_editor_height Int?
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
@@index([quotation_id], map: "quotation_id")

View File

@@ -248,7 +248,13 @@ const PERMISSIONS: {
name: "settings.company",
display_name: "Nastavení společnosti",
module: "settings",
description: "Upravovat údaje o společnosti, logo, 2FA",
description: "Upravovat údaje o společnosti, logo",
},
{
name: "settings.system",
display_name: "Systémová nastavení",
module: "settings",
description: "Spravovat systémová nastavení, 2FA, e-maily, limity",
},
{
name: "settings.banking",

View File

@@ -360,6 +360,7 @@ const menuSections: MenuSection[] = [
"settings.company",
"settings.banking",
"settings.roles",
"settings.system",
"settings.templates",
],
icon: (

View File

@@ -34,7 +34,7 @@ export interface InvoiceStats {
export interface InvoiceItem {
id?: number;
description: string;
item_description?: string;
item_description: string;
quantity: number;
unit: string;
unit_price: number;

View File

@@ -19,6 +19,7 @@ export interface ScopeSection {
export interface ScopeTemplate {
id: number;
name: string;
title?: string;
description?: string;
scope_template_sections?: ScopeSection[];
}
@@ -70,6 +71,9 @@ export const offerListOptions = (filters: {
order?: string;
page?: number;
perPage?: number;
status?: string;
customer_id?: number;
has_order?: string;
}) =>
queryOptions({
queryKey: ["offers", "list", filters],
@@ -80,6 +84,10 @@ export const offerListOptions = (filters: {
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.customer_id)
params.set("customer_id", String(filters.customer_id));
if (filters.has_order) params.set("has_order", filters.has_order);
const qs = params.toString();
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
},
@@ -127,9 +135,6 @@ export interface OfferDetailData {
language: string;
vat_rate: number;
apply_vat: boolean;
exchange_rate: string;
scope_title: string;
scope_description: string;
items?: OfferItemData[];
sections?: OfferSectionData[];
status: string;

View File

@@ -46,33 +46,6 @@
min-height: 32px;
}
/* Template dropdown menu */
.offers-template-menu {
position: absolute;
top: 100%;
right: 0;
z-index: 100;
min-width: 200px;
max-height: 250px;
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-top: 0.25rem;
}
.offers-template-menu-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
transition: background var(--transition);
}
.offers-template-menu-item:hover {
background: var(--bg-secondary);
}
/* Language badges */
.offers-lang-badge {
display: inline-flex;
@@ -617,13 +590,32 @@
}
/* Offer draft indicator */
.offers-draft-indicator {
/* Filters */
.admin-filters {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-tertiary);
margin-top: 0.2rem;
opacity: 0.8;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}
.admin-filter-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.admin-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-filter-group .admin-tabs {
flex-wrap: wrap;
}
.admin-filter-group .admin-form-select {
min-width: 10rem;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -20,9 +20,6 @@ import {
type CurrencyAmount,
} from "../lib/queries/invoices";
import Pagination from "../components/Pagination";
import { Skeleton } from "boneyard-js/react";
import InvoicesFixture from "../fixtures/InvoicesFixture";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
const API_BASE = "/api/admin";
@@ -283,13 +280,9 @@ export default function Invoices() {
if (initialLoad) {
return (
<Skeleton
name="invoices"
loading={initialLoad}
fixture={<InvoicesFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -420,13 +413,9 @@ export default function Invoices() {
>
<Suspense
fallback={
<Skeleton
name="invoices-received-kpi"
loading={true}
fixture={<ReceivedInvoicesFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
}
>
<ReceivedInvoices
@@ -445,13 +434,9 @@ export default function Invoices() {
transition={{ duration: 0.25, delay: 0.1 }}
>
{statsQuery.isPending && !hasLoadedOnce.current ? (
<Skeleton
name="invoices-kpi"
loading={statsQuery.isPending && !hasLoadedOnce.current}
fixture={<InvoicesFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
stats && (
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>
@@ -632,245 +617,143 @@ export default function Invoices() {
/>
</div>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<p>Zatím nejsou žádné faktury.</p>
{hasPermission("invoices.create") && (
<p
className="text-tertiary"
style={{ fontSize: "0.875rem" }}
>
Vytvořte první fakturu tlačítkem výše.
</p>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("invoice_number")}
<AnimatePresence mode="wait">
<motion.div
key={`${statusFilter}-${search}-${statsMonth}-${statsYear}-${page}-${invoices.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
Číslo{" "}
<SortIcon
column="invoice_number"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
<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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<p>Zatím nejsou žádné faktury.</p>
{hasPermission("invoices.create") && (
<p
className="text-tertiary"
style={{ fontSize: "0.875rem" }}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("issue_date")}
>
Vystaveno{" "}
<SortIcon
column="issue_date"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("due_date")}
>
Splatnost{" "}
<SortIcon
column="due_date"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && !statusFilter && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span style={{ fontWeight: 400, opacity: 0.8 }}>
{" · "}
{new Date(draft.savedAt).toLocaleTimeString(
"cs-CZ",
{ hour: "2-digit", minute: "2-digit" },
Vytvořte první fakturu tlačítkem výše.
</p>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("invoice_number")}
>
Číslo{" "}
<SortIcon
column="invoice_number"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("issue_date")}
>
Vystaveno{" "}
<SortIcon
column="issue_date"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("due_date")}
>
Splatnost{" "}
<SortIcon
column="due_date"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && !statusFilter && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span
style={{ fontWeight: 400, opacity: 0.8 }}
>
{" · "}
{new Date(
draft.savedAt,
).toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
</span>
)}
</span>
</td>
<td>
{(draft.form.customer_name as string) || "\u2014"}
</td>
<td>{"\u2014"}</td>
<td className="admin-mono">
{draft.form.issue_date
? formatDate(draft.form.issue_date as string)
: "\u2014"}
</td>
<td className="admin-mono">
{draft.form.due_date
? formatDate(draft.form.due_date as string)
: "\u2014"}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link
to="/invoices/new"
className="admin-btn-icon"
title="Pokračovat v konceptu"
aria-label="Pokračovat v konceptu"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</Link>
<button
onClick={discardDraft}
className="admin-btn-icon danger"
title="Zahodit koncept"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)}
{invoices.map((inv) => {
const isOverdue =
inv.status === "overdue" ||
(inv.status === "issued" &&
inv.due_date &&
new Date(inv.due_date) <
new Date(new Date().toDateString()));
return (
<tr
key={inv.id}
className={isOverdue ? "offers-expired-row" : ""}
>
<td className="admin-mono">
<Link
to={`/invoices/${inv.id}`}
className="link-accent"
>
{inv.invoice_number}
</Link>
</td>
<td>{inv.customer_name || "\u2014"}</td>
<td>
{inv.status === "paid" ? (
<span
className={`admin-badge ${STATUS_CLASSES[inv.status]}`}
>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ""}`}
style={{ cursor: "pointer" }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">
{formatDate(inv.issue_date)}
</td>
<td
className="admin-mono"
style={
inv.status === "overdue"
? { color: "var(--danger)", fontWeight: 600 }
: undefined
}
>
{formatDate(inv.due_date)}
</td>
<td
className="admin-mono"
style={{ textAlign: "right", fontWeight: 500 }}
>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/invoices/${inv.id}`}
className="admin-btn-icon"
title={
inv.status === "paid" ? "Detail" : "Upravit"
}
aria-label={
inv.status === "paid" ? "Detail" : "Upravit"
}
>
{inv.status === "paid" ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
</td>
<td>
{(draft.form.customer_name as string) ||
"\u2014"}
</td>
<td>{"\u2014"}</td>
<td className="admin-mono">
{draft.form.issue_date
? formatDate(draft.form.issue_date as string)
: "\u2014"}
</td>
<td className="admin-mono">
{draft.form.due_date
? formatDate(draft.form.due_date as string)
: "\u2014"}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link
to="/invoices/new"
className="admin-btn-icon"
title="Pokračovat v konceptu"
aria-label="Pokračovat v konceptu"
>
<svg
width="18"
height="18"
@@ -882,51 +765,11 @@ export default function Invoices() {
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
</Link>
{hasPermission("invoices.export") && (
</Link>
<button
onClick={() => handlePdf(inv)}
className="admin-btn-icon"
title="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<svg
width="18"
height="18"
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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
)}
</button>
)}
{hasPermission("invoices.delete") && (
<button
onClick={() =>
setDeleteConfirm({
show: true,
invoice: inv,
})
}
onClick={discardDraft}
className="admin-btn-icon danger"
title="Smazat"
title="Zahodit koncept"
>
<svg
width="18"
@@ -940,16 +783,195 @@ export default function Invoices() {
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</td>
</tr>
)}
{invoices.map((inv) => {
const isOverdue =
inv.status === "overdue" ||
(inv.status === "issued" &&
inv.due_date &&
new Date(inv.due_date) <
new Date(new Date().toDateString()));
return (
<tr
key={inv.id}
className={
isOverdue ? "offers-expired-row" : ""
}
>
<td className="admin-mono">
<Link
to={`/invoices/${inv.id}`}
className="link-accent"
>
{inv.invoice_number}
</Link>
</td>
<td>{inv.customer_name || "\u2014"}</td>
<td>
{inv.status === "paid" ? (
<span
className={`admin-badge ${STATUS_CLASSES[inv.status]}`}
>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ""}`}
style={{ cursor: "pointer" }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">
{formatDate(inv.issue_date)}
</td>
<td
className="admin-mono"
style={
inv.status === "overdue"
? {
color: "var(--danger)",
fontWeight: 600,
}
: undefined
}
>
{formatDate(inv.due_date)}
</td>
<td
className="admin-mono"
style={{
textAlign: "right",
fontWeight: 500,
}}
>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/invoices/${inv.id}`}
className="admin-btn-icon"
title={
inv.status === "paid"
? "Detail"
: "Upravit"
}
aria-label={
inv.status === "paid"
? "Detail"
: "Upravit"
}
>
{inv.status === "paid" ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
</Link>
{hasPermission("invoices.export") && (
<button
onClick={() => handlePdf(inv)}
className="admin-btn-icon"
title="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<svg
width="18"
height="18"
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" />
<line
x1="16"
y1="13"
x2="8"
y2="13"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
/>
</svg>
)}
</button>
)}
{hasPermission("invoices.delete") && (
<button
onClick={() =>
setDeleteConfirm({
show: true,
invoice: inv,
})
}
className="admin-btn-icon danger"
title="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</motion.div>
</AnimatePresence>
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,7 @@ interface Role {
description: string | null;
permissions: Permission[];
role_permissions?: unknown[];
user_count?: number;
}
interface RoleForm {
@@ -109,38 +110,44 @@ export default function Settings() {
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const canManage = hasPermission("settings.roles");
const canManageRoles = hasPermission("settings.roles");
const canManageCompany = hasPermission("settings.company");
const canManageSystem = hasPermission("settings.system");
const canManageBanking = hasPermission("settings.banking");
const canAccessSettings =
canManageRoles || canManageCompany || canManageSystem || canManageBanking;
// ── TanStack Query: roles, permissions, users ──
const availableTabs = useMemo(() => {
const tabs: Array<"roles" | "system" | "firma"> = [];
if (canManageRoles) tabs.push("roles");
if (canManageSystem) tabs.push("system");
if (canManageCompany || canManageBanking) tabs.push("firma");
return tabs;
}, [canManageRoles, canManageCompany, canManageSystem, canManageBanking]);
// ── TanStack Query: roles, permissions ──
const { data: rolesData, isPending: rolesLoading } = useQuery({
queryKey: ["settings", "roles"],
queryFn: async () => {
const [rolesRes, permsRes, usersRes] = await Promise.all([
const [rolesRes, permsRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/users`),
]);
const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json();
const usersResult = await usersRes.json();
if (!rolesResult.success)
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
if (!permsResult.success)
throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění");
if (!usersResult.success)
throw new Error(usersResult.error || "Nepodařilo se načíst uživatele");
return {
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
users: Array.isArray(usersResult.data) ? usersResult.data : [],
};
},
enabled: canManage,
enabled: canManageRoles,
});
const roles = rolesData?.roles ?? [];
const users = rolesData?.users ?? [];
// Group permissions by module
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
const perms: Permission[] = rolesData?.permissions ?? [];
@@ -158,27 +165,32 @@ export default function Settings() {
useQuery(require2FAOptions());
const require2FA = totpData?.require_2fa ?? false;
// ── TanStack Query: system settings (lazy, only on system/security tab) ──
// ── TanStack Query: system settings (lazy, only on system/roles tab) ──
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get("tab");
const defaultTab = availableTabs[0] ?? "roles";
const activeTab = (
tabParam === "system"
? "system"
: tabParam === "firma"
? "firma"
: "security"
) as "security" | "system" | "firma";
const setActiveTab = (tab: "security" | "system" | "firma") =>
tabParam === "roles" && availableTabs.includes("roles")
? "roles"
: tabParam === "system" && availableTabs.includes("system")
? "system"
: tabParam === "firma" && availableTabs.includes("firma")
? "firma"
: defaultTab
) as "roles" | "system" | "firma";
const setActiveTab = (tab: "roles" | "system" | "firma") =>
setSearchParams({ tab }, { replace: true });
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
...companySettingsOptions(),
enabled: canManage && (activeTab === "system" || activeTab === "security"),
enabled:
(canManageRoles || canManageSystem) &&
(activeTab === "system" || activeTab === "roles"),
});
const { data: systemInfo } = useQuery({
...systemInfoOptions(),
enabled: canManage && activeTab === "system",
enabled: canManageSystem && activeTab === "system",
});
// ── Local state ──
@@ -247,7 +259,7 @@ export default function Settings() {
useModalLock(showModal);
// ── Early return after all hooks ──
if (!canManage) {
if (!canAccessSettings) {
return <Navigate to="/" replace />;
}
@@ -453,7 +465,7 @@ export default function Settings() {
}
};
if (rolesLoading) {
if (canManageRoles && rolesLoading) {
return (
<Skeleton
name="settings"
@@ -516,36 +528,42 @@ export default function Settings() {
? "Systémová nastavení"
: activeTab === "firma"
? "Informace o firmě"
: "Zabezpečení a správa rolí"}
: "Role"}
</p>
</div>
</motion.div>
{canManage && (
{availableTabs.length > 0 && (
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
<button
className={`admin-btn admin-btn-sm ${activeTab === "security" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("security")}
>
Zabezpečení a role
</button>
<button
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("system")}
>
Systémová nastavení
</button>
<button
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("firma")}
>
Firma
</button>
{availableTabs.includes("roles") && (
<button
className={`admin-btn admin-btn-sm ${activeTab === "roles" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("roles")}
>
Role
</button>
)}
{availableTabs.includes("system") && (
<button
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("system")}
>
Systémová nastavení
</button>
)}
{availableTabs.includes("firma") && (
<button
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("firma")}
>
Firma
</button>
)}
</div>
)}
{/* Security Settings */}
{activeTab === "security" && canManage && (
{activeTab === "system" && canManageSystem && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -629,7 +647,7 @@ export default function Settings() {
)}
{/* Login Security */}
{activeTab === "security" && canManage && (
{activeTab === "system" && canManageSystem && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -686,7 +704,7 @@ export default function Settings() {
)}
{/* Roles Table */}
{activeTab === "security" && canManage && (
{activeTab === "roles" && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -765,11 +783,7 @@ export default function Settings() {
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{
users.filter(
(u: { role_id: number }) => u.role_id === role.id,
).length
}
{role.user_count ?? 0}
</span>
</td>
<td>
@@ -799,27 +813,16 @@ export default function Settings() {
}
className="admin-btn-icon danger"
title={
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
(role.user_count ?? 0) > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
aria-label={
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
(role.user_count ?? 0) > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
disabled={
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
}
disabled={(role.user_count ?? 0) > 0}
>
<svg
width="16"
@@ -846,7 +849,7 @@ export default function Settings() {
)}
{/* System Settings Tab */}
{activeTab === "system" && canManage && (
{activeTab === "system" && (
<>
{sysSettingsLoading && !sysFormInitialized ? (
<Skeleton
@@ -1052,260 +1055,7 @@ export default function Settings() {
</div>
</motion.div>
{/* Section 5: Číslování dokladů */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.28 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">Číslování dokladů</h2>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div
style={{
padding: "0.75rem",
background: "var(--bg-tertiary)",
borderRadius: 8,
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "1rem",
}}
>
<strong>Dostupné zástupné znaky:</strong>{" "}
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2
číslice), <code>{"{PREFIX}"}</code> prefix nabídky,{" "}
<code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "}
pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4
číslice), <code>{"{NNNNN}"}</code> pořadí (5 číslic)
</div>
{[
{
label: "Nabídky",
patternKey: "offer_number_pattern" as const,
defaultPattern: "{YYYY}/{PREFIX}/{NNN}",
prefixKey: "quotation_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
{
label: "Objednávky a projekty",
patternKey: "order_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "order_type_code" as const,
codeLabel: "Typový kód",
},
{
label: "Faktury",
patternKey: "invoice_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "invoice_type_code" as const,
codeLabel: "Typový kód",
},
].map((cfg, idx) => {
const pattern =
sysForm[cfg.patternKey] || cfg.defaultPattern;
const yyyy = String(new Date().getFullYear());
const yy = yyyy.slice(-2);
const prefix = cfg.prefixKey
? sysForm[cfg.prefixKey] || "NA"
: "";
const code = cfg.codeKey
? sysForm[cfg.codeKey] || ""
: "";
const preview = pattern.replace(
/\{(\w+)\}/g,
(m: string, k: string) => {
if (k === "YYYY") return yyyy;
if (k === "YY") return yy;
if (k === "PREFIX") return prefix;
if (k === "CODE") return code;
if (/^N+$/.test(k))
return "1".padStart(k.length, "0");
return m;
},
);
return (
<div key={cfg.patternKey}>
{idx > 0 && (
<hr
style={{
border: "none",
borderTop: "1px solid var(--border-color)",
margin: "1rem 0",
}}
/>
)}
<div
className="fw-600 text-md"
style={{
marginBottom: "0.5rem",
}}
>
{cfg.label}
</div>
<div className="admin-form-row">
<FormField label="Formát">
<input
type="text"
value={sysForm[cfg.patternKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.patternKey]: e.target.value,
}))
}
className="admin-form-input"
placeholder={cfg.defaultPattern}
/>
</FormField>
{cfg.prefixKey && (
<FormField label="Prefix">
<input
type="text"
value={sysForm[cfg.prefixKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.prefixKey!]: e.target.value,
}))
}
className="admin-form-input"
style={{ maxWidth: 100 }}
/>
</FormField>
)}
{cfg.codeKey && (
<FormField label={cfg.codeLabel || "Kód"}>
<input
type="text"
value={sysForm[cfg.codeKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.codeKey!]: e.target.value,
}))
}
className="admin-form-input"
style={{ maxWidth: 100 }}
/>
</FormField>
)}
</div>
<small
style={{
color: "var(--text-tertiary)",
fontSize: "0.8rem",
}}
>
Ukázka:{" "}
<strong style={{ color: "var(--text-primary)" }}>
{preview}
</strong>
</small>
</div>
);
})}
</div>
</div>
</motion.div>
{/* Section 6: Měna a DPH */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.3 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">Měna a DPH</h2>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Výchozí měna">
<select
value={sysForm.default_currency}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
default_currency: e.target.value,
}))
}
className="admin-form-input"
>
{sysForm.available_currencies.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="Výchozí sazba DPH (%)">
<input
type="number"
value={sysForm.default_vat_rate}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
default_vat_rate: Number(e.target.value),
}))
}
className="admin-form-input"
min={0}
step={1}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Dostupné měny">
<input
type="text"
value={sysForm.available_currencies.join(", ")}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
available_currencies: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}))
}
className="admin-form-input"
placeholder="CZK, EUR, USD"
/>
</FormField>
<FormField label="Dostupné sazby DPH (%)">
<input
type="text"
value={sysForm.available_vat_rates.join(", ")}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
available_vat_rates: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map(Number)
.filter((n) => !isNaN(n)),
}))
}
className="admin-form-input"
placeholder="0, 12, 21"
/>
</FormField>
</div>
</div>
</div>
</motion.div>
{/* Section 6: Informace o aplikaci */}
{/* Section 5: Informace o aplikaci */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -1615,7 +1365,7 @@ export default function Settings() {
)}
{/* Firma tab */}
{activeTab === "firma" && canManage && <CompanySettings embedded />}
{activeTab === "firma" && <CompanySettings embedded />}
{/* Create/Edit Modal */}
<AnimatePresence>

View File

@@ -54,3 +54,25 @@ export function requirePermission(...permissionNames: string[]) {
}
};
}
export function requireAnyPermission(...permissionNames: string[]) {
return async (
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> => {
await requireAuth(request, reply);
if (reply.sent) return;
const authData = request.authData!;
// Admin has all permissions
if (authData.roleName === "admin") return;
const hasAny = permissionNames.some((p) =>
authData.permissions.includes(p),
);
if (!hasAny) {
return error(reply, "Nedostatečná oprávnění", 403);
}
};
}

View File

@@ -3,6 +3,7 @@ import prisma from "../../config/database";
import {
requireAuth,
requirePermission,
requireAnyPermission,
optionalAuth,
} from "../../middleware/auth";
import { logAudit } from "../../services/audit";
@@ -333,7 +334,7 @@ export default async function companySettingsRoutes(
// GET /api/admin/company-settings/system-info
fastify.get(
"/system-info",
{ preHandler: requirePermission("settings.company") },
{ preHandler: requirePermission("settings.system") },
async (request, reply) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require("../../../package.json") as { version: string };
@@ -404,7 +405,7 @@ export default async function companySettingsRoutes(
fastify.put(
"/",
{ preHandler: requirePermission("settings.company") },
{ preHandler: requireAnyPermission("settings.company", "settings.system") },
async (request, reply) => {
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);

View File

@@ -450,10 +450,11 @@ export default async function invoicesPdfRoutes(
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const subDesc = item.item_description || "";
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td>
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>
@@ -693,6 +694,11 @@ export default async function invoicesPdfRoutes(
font-weight: 600;
color: #1a1a1a;
}
.item-subdesc {
font-size: 9pt;
color: #646464;
margin-top: 2px;
}
table.items tbody td.total-cell { font-weight: 700; }
/* Soucet + total - styl z nabidek */

View File

@@ -186,7 +186,6 @@ function buildAddressLines(
const TRANSLATIONS: Record<string, Record<string, string>> = {
title: { EN: "PRICE QUOTATION", CZ: "CENOV\u00C1 NAB\u00CDDKA" },
scope_title: { EN: "SCOPE OF THE PROJECT", CZ: "ROZSAH PROJEKTU" },
valid_until: { EN: "Valid until", CZ: "Platnost do" },
customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" },
supplier: { EN: "Supplier", CZ: "Dodavatel" },
@@ -199,7 +198,6 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
vat: { EN: "VAT", CZ: "DPH" },
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
exchange_rate: { EN: "Exchange rate", CZ: "Sm\u011Bnn\u00FD kurz" },
ico: { EN: "ID", CZ: "I\u010CO" },
dic: { EN: "VAT ID", CZ: "DI\u010C" },
page: { EN: "Page", CZ: "Strana" },
@@ -262,8 +260,6 @@ export default async function offersPdfRoutes(
const vatRate = Number(quotation.vat_rate) || 21;
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
const totalToPay = subtotal + vatAmount;
const exchangeRate = Number(quotation.exchange_rate) || 0;
let hasScopeContent = false;
for (const s of quotation.scope_sections) {
if ((s.content || "").trim() || (s.title || "").trim()) {
@@ -331,10 +327,6 @@ export default async function offersPdfRoutes(
<span class="label">${escapeHtml(t("total_to_pay"))}</span>
<span class="value">${formatCurrency(totalToPay, currency)}</span>
</div>`;
if (exchangeRate > 0) {
totalsHtml += `<div class="exchange-rate">${escapeHtml(t("exchange_rate"))}: ${formatNum(exchangeRate, 4)}</div>`;
}
const quotationNumber = escapeHtml(quotation.quotation_number);
let scopeHtml = "";
@@ -578,12 +570,6 @@ ${indentCSS}
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .exchange-rate {
text-align: right;
font-size: 7.5pt;
color: #969696;
margin-top: 3mm;
}
/* ---- Scope sections ---- */
.scope-page {

View File

@@ -42,6 +42,7 @@ export default async function quotationsRoutes(
search,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
has_order: query.has_order ? String(query.has_order) : undefined,
});
return paginated(

View File

@@ -14,18 +14,29 @@ export default async function rolesRoutes(
"/",
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
const roles = await prisma.roles.findMany({
include: {
role_permissions: {
include: { permissions: true },
const [roles, userCounts] = await Promise.all([
prisma.roles.findMany({
include: {
role_permissions: {
include: { permissions: true },
},
},
},
orderBy: { id: "asc" },
});
orderBy: { id: "asc" },
}),
prisma.users.groupBy({
by: ["role_id"],
_count: { id: true },
}),
]);
const countMap = new Map(
userCounts.map((u) => [u.role_id, Number(u._count.id)]),
);
const data = roles.map((r) => ({
...r,
permissions: r.role_permissions.map((rp) => rp.permissions),
user_count: countMap.get(r.id) || 0,
}));
return success(reply, data);
@@ -38,7 +49,7 @@ export default async function rolesRoutes(
{ preHandler: requirePermission("settings.roles") },
async (_request, reply) => {
const permissions = await prisma.permissions.findMany({
orderBy: [{ module: "asc" }, { id: "asc"}]
orderBy: [{ module: "asc" }, { id: "asc" }],
});
return success(reply, permissions);
},

View File

@@ -207,7 +207,7 @@ export default async function totpRoutes(
fastify.post(
"/required",
{
preHandler: [requireAuth, requirePermission("settings.company")],
preHandler: [requireAuth, requirePermission("settings.system")],
bodyLimit: 10240,
},
async (request, reply) => {

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
const InvoiceItemSchema = z.object({
description: z.string().nullish(),
item_description: z.string().nullish(),
quantity: z
.union([z.number(), z.string()])
.transform((v) => {

View File

@@ -59,16 +59,10 @@ export const CreateQuotationSchema = z.object({
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional()
.default(true),
exchange_rate: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional()
.default(1.0),
status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.enum(["active", "ordered", "invalidated"])
.optional()
.default("nova"),
.default("active"),
scope_title: z.string().nullish(),
scope_description: z.string().nullish(),
items: z.array(QuotationItemSchema).optional(),
@@ -93,14 +87,7 @@ export const UpdateQuotationSchema = z.object({
apply_vat: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional(),
exchange_rate: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(),
status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.optional(),
status: z.enum(["active", "ordered", "invalidated"]).optional(),
scope_title: z.string().nullish(),
scope_description: z.string().nullish(),
items: z.array(QuotationItemSchema).optional(),

View File

@@ -23,6 +23,7 @@ const ALLOWED_SORT_FIELDS = [
interface InvoiceItemInput {
description?: string;
item_description?: string;
quantity?: number;
unit?: string;
unit_price?: number;
@@ -374,6 +375,7 @@ export async function createInvoice(body: Record<string, any>) {
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
invoice_id: invoice.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
@@ -471,6 +473,7 @@ export async function updateInvoice(id: number, body: Record<string, any>) {
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
invoice_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,

View File

@@ -44,6 +44,7 @@ interface ListOffersParams {
search: string;
status?: string;
customer_id?: number;
has_order?: string;
}
function enrichQuotation(q: any) {
@@ -72,13 +73,24 @@ function enrichQuotation(q: any) {
}
export async function listOffers(params: ListOffersParams) {
const { page, limit, skip, sort, order, search, status, customer_id } =
params;
const {
page,
limit,
skip,
sort,
order,
search,
status,
customer_id,
has_order,
} = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (has_order === "yes") where.order_id = { not: null };
if (has_order === "no") where.order_id = null;
if (search) {
where.OR = [
{ quotation_number: { contains: search } },
@@ -184,7 +196,6 @@ export async function createOffer(body: Record<string, any>) {
language: body.language ? String(body.language) : "cs",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
status: body.status ? String(body.status) : "active",
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description
@@ -255,8 +266,6 @@ export async function updateOffer(id: number, body: Record<string, any>) {
body.apply_vat === 1 ||
body.apply_vat === "1"
: undefined,
exchange_rate:
body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
status: body.status !== undefined ? String(body.status) : undefined,
project_code:
body.project_code !== undefined
@@ -354,7 +363,6 @@ export async function duplicateOffer(id: number) {
language: original.language,
vat_rate: original.vat_rate,
apply_vat: original.apply_vat,
exchange_rate: original.exchange_rate,
status: "active",
scope_title: original.scope_title,
scope_description: original.scope_description,

View File

@@ -206,7 +206,6 @@ export async function createOrderFromQuotation(
language: quotation.language || "cs",
vat_rate: quotation.vat_rate ?? 21.0,
apply_vat: quotation.apply_vat ?? true,
exchange_rate: quotation.exchange_rate ?? 1.0,
scope_title: quotation.scope_title,
scope_description: quotation.scope_description,
attachment_data: attachmentBuffer