Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2254a13ad0 | ||
|
|
1f5885de84 | ||
|
|
705f58e3d1 | ||
|
|
0330453ad6 | ||
|
|
66b98e2765 | ||
|
|
55648c9a30 |
572
package-lock.json
generated
572
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.6.5",
|
"version": "1.7.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"bones": "boneyard-js build http://localhost:3000",
|
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
@@ -42,7 +41,6 @@
|
|||||||
"@tanstack/react-query": "^5.100.5",
|
"@tanstack/react-query": "^5.100.5",
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jsdom": "^28.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"boneyard-js": "^1.8.1",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;
|
||||||
@@ -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`;
|
||||||
@@ -154,6 +154,7 @@ model invoice_items {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
invoice_id Int
|
invoice_id Int
|
||||||
description String? @db.VarChar(500)
|
description String? @db.VarChar(500)
|
||||||
|
item_description String? @db.Text
|
||||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
@@ -377,10 +378,7 @@ model quotation_items {
|
|||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
is_included_in_total Boolean? @default(true)
|
is_included_in_total Boolean? @default(true)
|
||||||
uuid String? @db.VarChar(36)
|
|
||||||
modified_at DateTime? @db.DateTime(0)
|
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")
|
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
||||||
|
|
||||||
@@index([quotation_id], map: "quotation_id")
|
@@index([quotation_id], map: "quotation_id")
|
||||||
@@ -397,17 +395,13 @@ model quotations {
|
|||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||||
apply_vat Boolean? @default(true)
|
apply_vat Boolean? @default(true)
|
||||||
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
|
||||||
exchange_rate_date DateTime? @db.Date
|
|
||||||
order_id Int?
|
order_id Int?
|
||||||
status String @default("active") @db.VarChar(20)
|
status String @default("active") @db.VarChar(20)
|
||||||
scope_title String? @db.VarChar(500)
|
scope_title String? @db.VarChar(500)
|
||||||
scope_description String? @db.Text
|
scope_description String? @db.Text
|
||||||
locked_by Int?
|
locked_by Int?
|
||||||
locked_at DateTime? @db.DateTime(0)
|
locked_at DateTime? @db.DateTime(0)
|
||||||
uuid String? @db.VarChar(36)
|
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
sync_version Int? @default(0)
|
|
||||||
orders orders[]
|
orders orders[]
|
||||||
projects projects[]
|
projects projects[]
|
||||||
quotation_items quotation_items[]
|
quotation_items quotation_items[]
|
||||||
@@ -492,11 +486,7 @@ model scope_sections {
|
|||||||
title String? @db.VarChar(500)
|
title String? @db.VarChar(500)
|
||||||
title_cz String? @db.VarChar(500)
|
title_cz String? @db.VarChar(500)
|
||||||
content String? @db.Text
|
content String? @db.Text
|
||||||
content_editor_height Int?
|
|
||||||
uuid String? @db.VarChar(36)
|
|
||||||
modified_at DateTime? @db.DateTime(0)
|
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")
|
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
|
||||||
|
|
||||||
@@index([quotation_id], map: "quotation_id")
|
@@index([quotation_id], map: "quotation_id")
|
||||||
|
|||||||
@@ -248,7 +248,13 @@ const PERMISSIONS: {
|
|||||||
name: "settings.company",
|
name: "settings.company",
|
||||||
display_name: "Nastavení společnosti",
|
display_name: "Nastavení společnosti",
|
||||||
module: "settings",
|
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",
|
name: "settings.banking",
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ const menuSections: MenuSection[] = [
|
|||||||
"settings.company",
|
"settings.company",
|
||||||
"settings.banking",
|
"settings.banking",
|
||||||
"settings.roles",
|
"settings.roles",
|
||||||
|
"settings.system",
|
||||||
"settings.templates",
|
"settings.templates",
|
||||||
],
|
],
|
||||||
icon: (
|
icon: (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export interface InvoiceStats {
|
|||||||
export interface InvoiceItem {
|
export interface InvoiceItem {
|
||||||
id?: number;
|
id?: number;
|
||||||
description: string;
|
description: string;
|
||||||
item_description?: string;
|
item_description: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface ScopeSection {
|
|||||||
export interface ScopeTemplate {
|
export interface ScopeTemplate {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
scope_template_sections?: ScopeSection[];
|
scope_template_sections?: ScopeSection[];
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,8 @@ export const offerListOptions = (filters: {
|
|||||||
order?: string;
|
order?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
|
status?: string;
|
||||||
|
customer_id?: number;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["offers", "list", filters],
|
queryKey: ["offers", "list", filters],
|
||||||
@@ -80,6 +83,9 @@ export const offerListOptions = (filters: {
|
|||||||
if (filters.order) params.set("order", filters.order);
|
if (filters.order) params.set("order", filters.order);
|
||||||
if (filters.page) params.set("page", String(filters.page));
|
if (filters.page) params.set("page", String(filters.page));
|
||||||
if (filters.perPage) params.set("per_page", String(filters.perPage));
|
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));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
|
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
@@ -127,9 +133,6 @@ export interface OfferDetailData {
|
|||||||
language: string;
|
language: string;
|
||||||
vat_rate: number;
|
vat_rate: number;
|
||||||
apply_vat: boolean;
|
apply_vat: boolean;
|
||||||
exchange_rate: string;
|
|
||||||
scope_title: string;
|
|
||||||
scope_description: string;
|
|
||||||
items?: OfferItemData[];
|
items?: OfferItemData[];
|
||||||
sections?: OfferSectionData[];
|
sections?: OfferSectionData[];
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -46,33 +46,6 @@
|
|||||||
min-height: 32px;
|
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 */
|
/* Language badges */
|
||||||
.offers-lang-badge {
|
.offers-lang-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -617,13 +590,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Offer draft indicator */
|
/* Offer draft indicator */
|
||||||
.offers-draft-indicator {
|
/* Filters */
|
||||||
|
.admin-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem;
|
gap: 1rem;
|
||||||
font-size: 0.72rem;
|
align-items: flex-start;
|
||||||
font-weight: 500;
|
}
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin-top: 0.2rem;
|
.admin-filter-group {
|
||||||
opacity: 0.8;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ interface CompanyForm {
|
|||||||
country: string;
|
country: string;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
vat_id: string;
|
vat_id: string;
|
||||||
|
offer_number_pattern: string;
|
||||||
|
order_number_pattern: string;
|
||||||
|
invoice_number_pattern: string;
|
||||||
|
quotation_prefix: string;
|
||||||
|
order_type_code: string;
|
||||||
|
invoice_type_code: string;
|
||||||
|
default_currency: string;
|
||||||
|
default_vat_rate: number;
|
||||||
|
available_currencies: string[];
|
||||||
|
available_vat_rates: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BankForm {
|
interface BankForm {
|
||||||
@@ -65,6 +75,8 @@ export default function CompanySettings({
|
|||||||
}: { embedded?: boolean } = {}) {
|
}: { embedded?: boolean } = {}) {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
const canEditCompany = hasPermission("settings.company");
|
||||||
|
const canManageBanking = hasPermission("settings.banking");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
@@ -81,17 +93,21 @@ export default function CompanySettings({
|
|||||||
country: "",
|
country: "",
|
||||||
company_id: "",
|
company_id: "",
|
||||||
vat_id: "",
|
vat_id: "",
|
||||||
|
offer_number_pattern: "",
|
||||||
|
order_number_pattern: "",
|
||||||
|
invoice_number_pattern: "",
|
||||||
|
quotation_prefix: "",
|
||||||
|
order_type_code: "",
|
||||||
|
invoice_type_code: "",
|
||||||
|
default_currency: "CZK",
|
||||||
|
default_vat_rate: 21,
|
||||||
|
available_currencies: ["CZK", "EUR", "USD", "GBP"],
|
||||||
|
available_vat_rates: [0, 10, 12, 15, 21],
|
||||||
});
|
});
|
||||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||||
const [fieldOrder, setFieldOrder] = useState<string[]>([
|
const [fieldOrder, setFieldOrder] = useState<string[]>([
|
||||||
...DEFAULT_FIELD_ORDER,
|
...DEFAULT_FIELD_ORDER,
|
||||||
]);
|
]);
|
||||||
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([
|
|
||||||
"CZK",
|
|
||||||
"EUR",
|
|
||||||
"USD",
|
|
||||||
"GBP",
|
|
||||||
]);
|
|
||||||
const [bankSaving, setBankSaving] = useState(false);
|
const [bankSaving, setBankSaving] = useState(false);
|
||||||
const [editingBank, setEditingBank] = useState<number | null>(null);
|
const [editingBank, setEditingBank] = useState<number | null>(null);
|
||||||
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
|
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
|
||||||
@@ -202,6 +218,24 @@ export default function CompanySettings({
|
|||||||
country: settingsData.country || "",
|
country: settingsData.country || "",
|
||||||
company_id: settingsData.company_id || "",
|
company_id: settingsData.company_id || "",
|
||||||
vat_id: settingsData.vat_id || "",
|
vat_id: settingsData.vat_id || "",
|
||||||
|
offer_number_pattern: settingsData.offer_number_pattern || "",
|
||||||
|
order_number_pattern: settingsData.order_number_pattern || "",
|
||||||
|
invoice_number_pattern: settingsData.invoice_number_pattern || "",
|
||||||
|
quotation_prefix: settingsData.quotation_prefix || "",
|
||||||
|
order_type_code: settingsData.order_type_code || "",
|
||||||
|
invoice_type_code: settingsData.invoice_type_code || "",
|
||||||
|
default_currency: settingsData.default_currency || "CZK",
|
||||||
|
default_vat_rate: settingsData.default_vat_rate ?? 21,
|
||||||
|
available_currencies:
|
||||||
|
Array.isArray(settingsData.available_currencies) &&
|
||||||
|
settingsData.available_currencies.length > 0
|
||||||
|
? settingsData.available_currencies
|
||||||
|
: ["CZK", "EUR", "USD", "GBP"],
|
||||||
|
available_vat_rates:
|
||||||
|
Array.isArray(settingsData.available_vat_rates) &&
|
||||||
|
settingsData.available_vat_rates.length > 0
|
||||||
|
? settingsData.available_vat_rates
|
||||||
|
: [0, 10, 12, 15, 21],
|
||||||
});
|
});
|
||||||
const cf: CustomField[] =
|
const cf: CustomField[] =
|
||||||
Array.isArray(settingsData.custom_fields) &&
|
Array.isArray(settingsData.custom_fields) &&
|
||||||
@@ -221,12 +255,6 @@ export default function CompanySettings({
|
|||||||
} else {
|
} else {
|
||||||
setFieldOrder([...DEFAULT_FIELD_ORDER]);
|
setFieldOrder([...DEFAULT_FIELD_ORDER]);
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
Array.isArray(settingsData.available_currencies) &&
|
|
||||||
settingsData.available_currencies.length > 0
|
|
||||||
) {
|
|
||||||
setAvailableCurrencies(settingsData.available_currencies);
|
|
||||||
}
|
|
||||||
if (settingsData.has_logo) {
|
if (settingsData.has_logo) {
|
||||||
fetchLogo("light");
|
fetchLogo("light");
|
||||||
}
|
}
|
||||||
@@ -406,7 +434,7 @@ export default function CompanySettings({
|
|||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!embedded && !hasPermission("settings.company")) return <Forbidden />;
|
if (!embedded && !canEditCompany) return <Forbidden />;
|
||||||
|
|
||||||
if (settingsLoading) {
|
if (settingsLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -482,6 +510,7 @@ export default function CompanySettings({
|
|||||||
|
|
||||||
<div className="admin-settings-grid">
|
<div className="admin-settings-grid">
|
||||||
{/* Company Info */}
|
{/* Company Info */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -497,7 +526,9 @@ export default function CompanySettings({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.company_name}
|
value={form.company_name}
|
||||||
onChange={(e) => updateField("company_name", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateField("company_name", e.target.value)
|
||||||
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -524,7 +555,9 @@ export default function CompanySettings({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.postal_code}
|
value={form.postal_code}
|
||||||
onChange={(e) => updateField("postal_code", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateField("postal_code", e.target.value)
|
||||||
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -542,7 +575,9 @@ export default function CompanySettings({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.company_id}
|
value={form.company_id}
|
||||||
onChange={(e) => updateField("company_id", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateField("company_id", e.target.value)
|
||||||
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -705,8 +740,10 @@ export default function CompanySettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bank Accounts */}
|
{/* Bank Accounts */}
|
||||||
|
{(canManageBanking || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -754,7 +791,9 @@ export default function CompanySettings({
|
|||||||
>
|
>
|
||||||
<td>{acc.account_name}</td>
|
<td>{acc.account_name}</td>
|
||||||
<td>{acc.bank_name}</td>
|
<td>{acc.bank_name}</td>
|
||||||
<td className="admin-mono">{acc.account_number}</td>
|
<td className="admin-mono">
|
||||||
|
{acc.account_number}
|
||||||
|
</td>
|
||||||
<td className="admin-mono">{acc.iban}</td>
|
<td className="admin-mono">{acc.iban}</td>
|
||||||
<td className="admin-mono">{acc.bic}</td>
|
<td className="admin-mono">{acc.bic}</td>
|
||||||
<td>{acc.currency}</td>
|
<td>{acc.currency}</td>
|
||||||
@@ -825,7 +864,9 @@ export default function CompanySettings({
|
|||||||
className="text-secondary"
|
className="text-secondary"
|
||||||
style={{ margin: "0 0 12px", fontSize: "0.9rem" }}
|
style={{ margin: "0 0 12px", fontSize: "0.9rem" }}
|
||||||
>
|
>
|
||||||
{editingBank !== null ? "Upravit účet" : "Přidat nový účet"}
|
{editingBank !== null
|
||||||
|
? "Upravit účet"
|
||||||
|
: "Přidat nový účet"}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="admin-form">
|
<div className="admin-form">
|
||||||
<div className="admin-form-row">
|
<div className="admin-form-row">
|
||||||
@@ -884,7 +925,7 @@ export default function CompanySettings({
|
|||||||
}
|
}
|
||||||
className="admin-form-select"
|
className="admin-form-select"
|
||||||
>
|
>
|
||||||
{availableCurrencies.map((c) => (
|
{form.available_currencies.map((c) => (
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>
|
||||||
{c}
|
{c}
|
||||||
</option>
|
</option>
|
||||||
@@ -898,7 +939,10 @@ export default function CompanySettings({
|
|||||||
type="text"
|
type="text"
|
||||||
value={bankForm.iban}
|
value={bankForm.iban}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setBankForm((f) => ({ ...f, iban: e.target.value }))
|
setBankForm((f) => ({
|
||||||
|
...f,
|
||||||
|
iban: e.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="CZ65 0800 0000 1920 0014 5399"
|
placeholder="CZ65 0800 0000 1920 0014 5399"
|
||||||
@@ -909,7 +953,10 @@ export default function CompanySettings({
|
|||||||
type="text"
|
type="text"
|
||||||
value={bankForm.bic}
|
value={bankForm.bic}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setBankForm((f) => ({ ...f, bic: e.target.value }))
|
setBankForm((f) => ({
|
||||||
|
...f,
|
||||||
|
bic: e.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="GIBACZPX"
|
placeholder="GIBACZPX"
|
||||||
@@ -959,8 +1006,10 @@ export default function CompanySettings({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* PDF Field Order */}
|
{/* PDF Field Order */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -1030,13 +1079,247 @@ export default function CompanySettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Document Numbering */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-card"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="admin-card-header">
|
||||||
|
<h3 className="admin-card-title">Číslování dokladů</h3>
|
||||||
|
</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 = form[cfg.patternKey] || cfg.defaultPattern;
|
||||||
|
const yyyy = String(new Date().getFullYear());
|
||||||
|
const yy = yyyy.slice(-2);
|
||||||
|
const prefix = cfg.prefixKey
|
||||||
|
? form[cfg.prefixKey] || "NA"
|
||||||
|
: "";
|
||||||
|
const code = cfg.codeKey ? form[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={form[cfg.patternKey]}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(cfg.patternKey, e.target.value)
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder={cfg.defaultPattern}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{cfg.prefixKey && (
|
||||||
|
<FormField label={cfg.prefixLabel || "Prefix"}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form[cfg.prefixKey]}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(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={form[cfg.codeKey]}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Currency & VAT */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
transition={{ duration: 0.25, delay: 0.12 }}
|
||||||
|
>
|
||||||
|
<div className="admin-card-header">
|
||||||
|
<h3 className="admin-card-title">Měna a DPH</h3>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body">
|
||||||
|
<div className="admin-form">
|
||||||
|
<div className="admin-form-row">
|
||||||
|
<FormField label="Výchozí měna">
|
||||||
|
<select
|
||||||
|
value={form.default_currency}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField("default_currency", e.target.value)
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
>
|
||||||
|
{form.available_currencies.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Výchozí sazba DPH (%)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.default_vat_rate}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField("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={form.available_currencies.join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((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={form.available_vat_rates.join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
available_vat_rates: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => !isNaN(n)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="0, 12, 21"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
{(canEditCompany || !embedded) && (
|
||||||
|
<motion.div
|
||||||
|
className="admin-card"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.14 }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">Logo</h3>
|
<h3 className="admin-card-title">Logo</h3>
|
||||||
@@ -1146,9 +1429,10 @@ export default function CompanySettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{embedded && (
|
{embedded && canEditCompany && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import DOMPurify from "dompurify";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { Skeleton } from "boneyard-js/react";
|
|
||||||
import InvoiceDetailFixture from "../fixtures/InvoiceDetailFixture";
|
|
||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
import AdminDatePicker from "../components/AdminDatePicker";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
import ConfirmModal from "../components/ConfirmModal";
|
||||||
@@ -74,6 +72,7 @@ interface InvoiceItem {
|
|||||||
id?: number;
|
id?: number;
|
||||||
_key: string;
|
_key: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
item_description: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
@@ -166,7 +165,10 @@ function SortableInvoiceRow({
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-tertiary text-center fw-500">{index + 1}</td>
|
<td className="text-tertiary text-center fw-500">{index + 1}</td>
|
||||||
<td>
|
<td style={{ verticalAlign: "top" }}>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.description}
|
value={item.description}
|
||||||
@@ -174,6 +176,17 @@ function SortableInvoiceRow({
|
|||||||
className="admin-form-input fw-500"
|
className="admin-form-input fw-500"
|
||||||
placeholder="Popis položky..."
|
placeholder="Popis položky..."
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.item_description}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate(index, "item_description", e.target.value)
|
||||||
|
}
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Podrobný popis (volitelný)"
|
||||||
|
style={{ fontSize: "0.8rem", opacity: 0.8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
@@ -281,6 +294,7 @@ export default function InvoiceDetail() {
|
|||||||
(): InvoiceItem => ({
|
(): InvoiceItem => ({
|
||||||
_key: `inv-${++keyCounterRef.current}`,
|
_key: `inv-${++keyCounterRef.current}`,
|
||||||
description: "",
|
description: "",
|
||||||
|
item_description: "",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
@@ -508,6 +522,7 @@ export default function InvoiceDetail() {
|
|||||||
_key: `inv-${++keyCounterRef.current}`,
|
_key: `inv-${++keyCounterRef.current}`,
|
||||||
id: item.id,
|
id: item.id,
|
||||||
description: item.description || "",
|
description: item.description || "",
|
||||||
|
item_description: item.item_description || "",
|
||||||
quantity: Number(item.quantity) || 1,
|
quantity: Number(item.quantity) || 1,
|
||||||
unit: item.unit || "",
|
unit: item.unit || "",
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
@@ -587,6 +602,7 @@ export default function InvoiceDetail() {
|
|||||||
orderItems.map((item) => ({
|
orderItems.map((item) => ({
|
||||||
_key: `inv-${++keyCounterRef.current}`,
|
_key: `inv-${++keyCounterRef.current}`,
|
||||||
description: (item.description as string) || "",
|
description: (item.description as string) || "",
|
||||||
|
item_description: (item.item_description as string) || "",
|
||||||
quantity: Number(item.quantity) || 1,
|
quantity: Number(item.quantity) || 1,
|
||||||
unit: (item.unit as string) || "",
|
unit: (item.unit as string) || "",
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
@@ -910,12 +926,14 @@ export default function InvoiceDetail() {
|
|||||||
if (isEdit && !invoice) return null;
|
if (isEdit && !invoice) return null;
|
||||||
|
|
||||||
if (isPaid && invoice) {
|
if (isPaid && invoice) {
|
||||||
|
if (!dataReady) {
|
||||||
|
return (
|
||||||
|
<div className="admin-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
|
||||||
name="invoice-detail"
|
|
||||||
loading={!dataReady}
|
|
||||||
fixture={<InvoiceDetailFixture />}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -1124,23 +1142,29 @@ export default function InvoiceDetail() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="fw-500">
|
<td className="fw-500">
|
||||||
{item.description || "\u2014"}
|
{item.description || "\u2014"}
|
||||||
|
{item.item_description && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
opacity: 0.8,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.item_description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
{item.quantity}{" "}
|
{item.quantity}{" "}
|
||||||
{item.unit && (
|
{item.unit && (
|
||||||
<span className="text-tertiary">
|
<span className="text-tertiary">{item.unit}</span>
|
||||||
{item.unit}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
{item.unit || "\u2014"}
|
{item.unit || "\u2014"}
|
||||||
</td>
|
</td>
|
||||||
<td className="admin-mono text-right">
|
<td className="admin-mono text-right">
|
||||||
{formatCurrency(
|
{formatCurrency(item.unit_price, invoice.currency)}
|
||||||
item.unit_price,
|
|
||||||
invoice.currency,
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
{Number(invoice.apply_vat)
|
{Number(invoice.apply_vat)
|
||||||
@@ -1172,14 +1196,12 @@ export default function InvoiceDetail() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{Number(invoice.apply_vat) > 0 &&
|
{Number(invoice.apply_vat) > 0 &&
|
||||||
Object.entries(createTotals.vatByRate).map(
|
Object.entries(createTotals.vatByRate).map(([rate, amount]) => (
|
||||||
([rate, amount]) => (
|
|
||||||
<div key={rate} className="admin-totals-row">
|
<div key={rate} className="admin-totals-row">
|
||||||
<span>DPH {rate}%:</span>
|
<span>DPH {rate}%:</span>
|
||||||
<span>{formatCurrency(amount, invoice.currency)}</span>
|
<span>{formatCurrency(amount, invoice.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
<div className="admin-totals-row admin-totals-total">
|
<div className="admin-totals-row admin-totals-total">
|
||||||
<span>Celkem k úhradě:</span>
|
<span>Celkem k úhradě:</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -1220,19 +1242,20 @@ export default function InvoiceDetail() {
|
|||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// CREATE MODE + EDIT (not paid) — shared form
|
// CREATE MODE + EDIT (not paid) — shared form
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
if (!dataReady) {
|
||||||
|
return (
|
||||||
|
<div className="admin-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
|
||||||
name="invoice-detail"
|
|
||||||
loading={!dataReady}
|
|
||||||
fixture={<InvoiceDetailFixture />}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-page-header"
|
className="admin-page-header"
|
||||||
@@ -1386,11 +1409,7 @@ export default function InvoiceDetail() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField label="Odběratel" error={errors.customer_id} required>
|
||||||
label="Odběratel"
|
|
||||||
error={errors.customer_id}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
{form.customer_id ? (
|
{form.customer_id ? (
|
||||||
<div className="admin-customer-selected">
|
<div className="admin-customer-selected">
|
||||||
<span>{form.customer_name}</span>
|
<span>{form.customer_name}</span>
|
||||||
@@ -1732,19 +1751,15 @@ export default function InvoiceDetail() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{form.apply_vat &&
|
{form.apply_vat &&
|
||||||
Object.entries(createTotals.vatByRate).map(
|
Object.entries(createTotals.vatByRate).map(([rate, amount]) => (
|
||||||
([rate, amount]) => (
|
|
||||||
<div key={rate} className="admin-totals-row">
|
<div key={rate} className="admin-totals-row">
|
||||||
<span>DPH {rate}%:</span>
|
<span>DPH {rate}%:</span>
|
||||||
<span>{formatCurrency(amount, form.currency)}</span>
|
<span>{formatCurrency(amount, form.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
<div className="admin-totals-row admin-totals-total">
|
<div className="admin-totals-row admin-totals-total">
|
||||||
<span>Celkem k úhradě:</span>
|
<span>Celkem k úhradě:</span>
|
||||||
<span>
|
<span>{formatCurrency(createTotals.total, form.currency)}</span>
|
||||||
{formatCurrency(createTotals.total, form.currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1800,6 +1815,5 @@ export default function InvoiceDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ import {
|
|||||||
type CurrencyAmount,
|
type CurrencyAmount,
|
||||||
} from "../lib/queries/invoices";
|
} from "../lib/queries/invoices";
|
||||||
import Pagination from "../components/Pagination";
|
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 ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
@@ -283,13 +280,9 @@ export default function Invoices() {
|
|||||||
|
|
||||||
if (initialLoad) {
|
if (initialLoad) {
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
<div className="admin-loading">
|
||||||
name="invoices"
|
<div className="admin-spinner" />
|
||||||
loading={initialLoad}
|
</div>
|
||||||
fixture={<InvoicesFixture />}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,13 +413,9 @@ export default function Invoices() {
|
|||||||
>
|
>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<Skeleton
|
<div className="admin-loading">
|
||||||
name="invoices-received-kpi"
|
<div className="admin-spinner" />
|
||||||
loading={true}
|
</div>
|
||||||
fixture={<ReceivedInvoicesFixture />}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</Skeleton>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReceivedInvoices
|
<ReceivedInvoices
|
||||||
@@ -445,13 +434,9 @@ export default function Invoices() {
|
|||||||
transition={{ duration: 0.25, delay: 0.1 }}
|
transition={{ duration: 0.25, delay: 0.1 }}
|
||||||
>
|
>
|
||||||
{statsQuery.isPending && !hasLoadedOnce.current ? (
|
{statsQuery.isPending && !hasLoadedOnce.current ? (
|
||||||
<Skeleton
|
<div className="admin-loading">
|
||||||
name="invoices-kpi"
|
<div className="admin-spinner" />
|
||||||
loading={statsQuery.isPending && !hasLoadedOnce.current}
|
</div>
|
||||||
fixture={<InvoicesFixture />}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</Skeleton>
|
|
||||||
) : (
|
) : (
|
||||||
stats && (
|
stats && (
|
||||||
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>
|
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>
|
||||||
@@ -632,6 +617,14 @@ export default function Invoices() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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) ? (
|
{invoices.length === 0 && !(draft && !statusFilter) ? (
|
||||||
<div className="admin-empty-state">
|
<div className="admin-empty-state">
|
||||||
<div className="admin-empty-icon">
|
<div className="admin-empty-icon">
|
||||||
@@ -723,18 +716,23 @@ export default function Invoices() {
|
|||||||
<span className="offers-draft-row-label">
|
<span className="offers-draft-row-label">
|
||||||
Koncept
|
Koncept
|
||||||
{draft.savedAt && (
|
{draft.savedAt && (
|
||||||
<span style={{ fontWeight: 400, opacity: 0.8 }}>
|
<span
|
||||||
|
style={{ fontWeight: 400, opacity: 0.8 }}
|
||||||
|
>
|
||||||
{" · "}
|
{" · "}
|
||||||
{new Date(draft.savedAt).toLocaleTimeString(
|
{new Date(
|
||||||
"cs-CZ",
|
draft.savedAt,
|
||||||
{ hour: "2-digit", minute: "2-digit" },
|
).toLocaleTimeString("cs-CZ", {
|
||||||
)}
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{(draft.form.customer_name as string) || "\u2014"}
|
{(draft.form.customer_name as string) ||
|
||||||
|
"\u2014"}
|
||||||
</td>
|
</td>
|
||||||
<td>{"\u2014"}</td>
|
<td>{"\u2014"}</td>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
@@ -799,7 +797,9 @@ export default function Invoices() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
className={isOverdue ? "offers-expired-row" : ""}
|
className={
|
||||||
|
isOverdue ? "offers-expired-row" : ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<td className="admin-mono">
|
<td className="admin-mono">
|
||||||
<Link
|
<Link
|
||||||
@@ -834,7 +834,10 @@ export default function Invoices() {
|
|||||||
className="admin-mono"
|
className="admin-mono"
|
||||||
style={
|
style={
|
||||||
inv.status === "overdue"
|
inv.status === "overdue"
|
||||||
? { color: "var(--danger)", fontWeight: 600 }
|
? {
|
||||||
|
color: "var(--danger)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -842,7 +845,10 @@ export default function Invoices() {
|
|||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="admin-mono"
|
className="admin-mono"
|
||||||
style={{ textAlign: "right", fontWeight: 500 }}
|
style={{
|
||||||
|
textAlign: "right",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{formatCurrency(inv.total, inv.currency)}
|
{formatCurrency(inv.total, inv.currency)}
|
||||||
</td>
|
</td>
|
||||||
@@ -852,10 +858,14 @@ export default function Invoices() {
|
|||||||
to={`/invoices/${inv.id}`}
|
to={`/invoices/${inv.id}`}
|
||||||
className="admin-btn-icon"
|
className="admin-btn-icon"
|
||||||
title={
|
title={
|
||||||
inv.status === "paid" ? "Detail" : "Upravit"
|
inv.status === "paid"
|
||||||
|
? "Detail"
|
||||||
|
: "Upravit"
|
||||||
}
|
}
|
||||||
aria-label={
|
aria-label={
|
||||||
inv.status === "paid" ? "Detail" : "Upravit"
|
inv.status === "paid"
|
||||||
|
? "Detail"
|
||||||
|
: "Upravit"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{inv.status === "paid" ? (
|
{inv.status === "paid" ? (
|
||||||
@@ -911,8 +921,18 @@ export default function Invoices() {
|
|||||||
>
|
>
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
<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" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
<line
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
x1="16"
|
||||||
|
y1="13"
|
||||||
|
x2="8"
|
||||||
|
y2="13"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="16"
|
||||||
|
y1="17"
|
||||||
|
x2="8"
|
||||||
|
y2="17"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -950,6 +970,8 @@ export default function Invoices() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ import {
|
|||||||
offerCustomersOptions,
|
offerCustomersOptions,
|
||||||
scopeTemplatesOptions,
|
scopeTemplatesOptions,
|
||||||
offerNextNumberOptions,
|
offerNextNumberOptions,
|
||||||
|
itemTemplatesOptions,
|
||||||
|
type ItemTemplate,
|
||||||
type OfferDetailData,
|
type OfferDetailData,
|
||||||
type OfferItemData,
|
type OfferItemData,
|
||||||
type OfferSectionData,
|
type OfferSectionData,
|
||||||
type OfferLockInfo,
|
type OfferLockInfo,
|
||||||
type OfferOrderInfo,
|
type OfferOrderInfo,
|
||||||
type ScopeTemplate,
|
type ScopeTemplate,
|
||||||
|
type Customer,
|
||||||
} from "../lib/queries/offers";
|
} from "../lib/queries/offers";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -54,8 +57,6 @@ import useModalLock from "../hooks/useModalLock";
|
|||||||
import useDebounce from "../hooks/useDebounce";
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { formatCurrency } from "../utils/formatters";
|
import { formatCurrency } from "../utils/formatters";
|
||||||
import { Skeleton } from "boneyard-js/react";
|
|
||||||
import OfferDetailFixture from "../fixtures/OfferDetailFixture";
|
|
||||||
import {
|
import {
|
||||||
companySettingsOptions,
|
companySettingsOptions,
|
||||||
type CompanySettingsData,
|
type CompanySettingsData,
|
||||||
@@ -92,15 +93,6 @@ interface OfferForm {
|
|||||||
language: string;
|
language: string;
|
||||||
vat_rate: number;
|
vat_rate: number;
|
||||||
apply_vat: boolean;
|
apply_vat: boolean;
|
||||||
exchange_rate: string;
|
|
||||||
scope_title: string;
|
|
||||||
scope_description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Customer {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
city?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: OfferForm = {
|
const emptyForm: OfferForm = {
|
||||||
@@ -114,9 +106,6 @@ const emptyForm: OfferForm = {
|
|||||||
language: "EN",
|
language: "EN",
|
||||||
vat_rate: 21,
|
vat_rate: 21,
|
||||||
apply_vat: false,
|
apply_vat: false,
|
||||||
exchange_rate: "",
|
|
||||||
scope_title: "",
|
|
||||||
scope_description: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyScopeSection = (): ScopeSection => ({
|
const emptyScopeSection = (): ScopeSection => ({
|
||||||
@@ -323,6 +312,7 @@ export default function OfferDetail() {
|
|||||||
enabled: !isEdit,
|
enabled: !isEdit,
|
||||||
});
|
});
|
||||||
const { data: templatesData } = useQuery(scopeTemplatesOptions());
|
const { data: templatesData } = useQuery(scopeTemplatesOptions());
|
||||||
|
const { data: itemTemplates } = useQuery(itemTemplatesOptions());
|
||||||
const { data: nextNumberData } = useQuery({
|
const { data: nextNumberData } = useQuery({
|
||||||
...offerNextNumberOptions(),
|
...offerNextNumberOptions(),
|
||||||
enabled: !isEdit,
|
enabled: !isEdit,
|
||||||
@@ -354,13 +344,6 @@ export default function OfferDetail() {
|
|||||||
language: (draft.form.language as string) || emptyForm.language,
|
language: (draft.form.language as string) || emptyForm.language,
|
||||||
vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
|
vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
|
||||||
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
|
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
|
||||||
exchange_rate:
|
|
||||||
(draft.form.exchange_rate as string) || emptyForm.exchange_rate,
|
|
||||||
scope_title:
|
|
||||||
(draft.form.scope_title as string) || emptyForm.scope_title,
|
|
||||||
scope_description:
|
|
||||||
(draft.form.scope_description as string) ||
|
|
||||||
emptyForm.scope_description,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return emptyForm;
|
return emptyForm;
|
||||||
@@ -433,13 +416,7 @@ export default function OfferDetail() {
|
|||||||
const isCompleted = orderInfo?.status === "dokoncena";
|
const isCompleted = orderInfo?.status === "dokoncena";
|
||||||
const isLockedByOther = !!lockedBy;
|
const isLockedByOther = !!lockedBy;
|
||||||
const readOnly = isInvalidated || isLockedByOther || isCompleted;
|
const readOnly = isInvalidated || isLockedByOther || isCompleted;
|
||||||
const isExpiredNotInvalidated =
|
const canInvalidate = isEdit && !isInvalidated && !isCompleted && !orderInfo;
|
||||||
isEdit &&
|
|
||||||
!isInvalidated &&
|
|
||||||
!isCompleted &&
|
|
||||||
!orderInfo &&
|
|
||||||
form.valid_until &&
|
|
||||||
new Date(form.valid_until) < new Date(new Date().toDateString());
|
|
||||||
|
|
||||||
// Sync offer detail data to form state on first load (edit mode)
|
// Sync offer detail data to form state on first load (edit mode)
|
||||||
const formInitializedRef = useRef(false);
|
const formInitializedRef = useRef(false);
|
||||||
@@ -457,9 +434,6 @@ export default function OfferDetail() {
|
|||||||
language: d.language || "EN",
|
language: d.language || "EN",
|
||||||
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
|
vat_rate: d.vat_rate ?? companySettings?.default_vat_rate ?? 21,
|
||||||
apply_vat: !!d.apply_vat,
|
apply_vat: !!d.apply_vat,
|
||||||
exchange_rate: d.exchange_rate || "",
|
|
||||||
scope_title: d.scope_title || "",
|
|
||||||
scope_description: d.scope_description || "",
|
|
||||||
};
|
};
|
||||||
setForm(formData);
|
setForm(formData);
|
||||||
const mappedItems =
|
const mappedItems =
|
||||||
@@ -592,7 +566,6 @@ export default function OfferDetail() {
|
|||||||
language: data.form.language ?? "EN",
|
language: data.form.language ?? "EN",
|
||||||
vat_rate: data.form.vat_rate ?? 21,
|
vat_rate: data.form.vat_rate ?? 21,
|
||||||
apply_vat: data.form.apply_vat ?? false,
|
apply_vat: data.form.apply_vat ?? false,
|
||||||
exchange_rate: data.form.exchange_rate ?? "",
|
|
||||||
},
|
},
|
||||||
items: data.items,
|
items: data.items,
|
||||||
sections: data.sections,
|
sections: data.sections,
|
||||||
@@ -855,12 +828,14 @@ export default function OfferDetail() {
|
|||||||
const requiredPerm = getRequiredPerm();
|
const requiredPerm = getRequiredPerm();
|
||||||
if (!hasPermission(requiredPerm)) return <Forbidden />;
|
if (!hasPermission(requiredPerm)) return <Forbidden />;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="admin-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
|
||||||
name="offer-detail"
|
|
||||||
loading={loading}
|
|
||||||
fixture={<OfferDetailFixture />}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -968,7 +943,7 @@ export default function OfferDetail() {
|
|||||||
Objednávka {orderInfo.order_number}
|
Objednávka {orderInfo.order_number}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isExpiredNotInvalidated && hasPermission("offers.edit") && (
|
{canInvalidate && hasPermission("offers.edit") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setInvalidateConfirm(true)}
|
onClick={() => setInvalidateConfirm(true)}
|
||||||
className="admin-btn admin-btn-secondary"
|
className="admin-btn admin-btn-secondary"
|
||||||
@@ -1160,11 +1135,7 @@ export default function OfferDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField label="Platnost do" error={errors.valid_until} required>
|
||||||
label="Platnost do"
|
|
||||||
error={errors.valid_until}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1235,9 +1206,7 @@ export default function OfferDetail() {
|
|||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
{(
|
{(
|
||||||
companySettings?.available_vat_rates || [
|
companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
|
||||||
0, 10, 12, 15, 21,
|
|
||||||
]
|
|
||||||
).map((r) => (
|
).map((r) => (
|
||||||
<option key={r} value={r}>
|
<option key={r} value={r}>
|
||||||
{r}%
|
{r}%
|
||||||
@@ -1248,26 +1217,13 @@ export default function OfferDetail() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={form.apply_vat}
|
checked={form.apply_vat}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateForm("apply_vat", e.target.checked)}
|
||||||
updateForm("apply_vat", e.target.checked)
|
|
||||||
}
|
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
<span>Účtovat DPH</span>
|
<span>Účtovat DPH</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Směnný kurz">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={form.exchange_rate}
|
|
||||||
onChange={(e) => updateForm("exchange_rate", e.target.value)}
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Volitelný"
|
|
||||||
step="0.0001"
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -1283,12 +1239,59 @@ export default function OfferDetail() {
|
|||||||
<div className="admin-card-header flex-between">
|
<div className="admin-card-header flex-between">
|
||||||
<h3 className="admin-card-title">Položky</h3>
|
<h3 className="admin-card-title">Položky</h3>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{itemTemplates && itemTemplates.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="admin-form-select"
|
||||||
|
style={{ width: "auto", minWidth: "160px" }}
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(e) => {
|
||||||
|
const templateId = Number(e.target.value);
|
||||||
|
if (!templateId) return;
|
||||||
|
const template = itemTemplates.find(
|
||||||
|
(t: ItemTemplate) => t.id === templateId,
|
||||||
|
);
|
||||||
|
if (template) {
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
_key: `item-${++itemKeyCounter.current}`,
|
||||||
|
description: template.name || "",
|
||||||
|
item_description: template.description || "",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: template.default_price || 0,
|
||||||
|
is_included_in_total: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
alert.success(
|
||||||
|
`Načtena šablona položky "${template.name}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Ze šablony...</option>
|
||||||
|
{itemTemplates.map((t: ItemTemplate) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={addItem}
|
onClick={addItem}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||||
>
|
>
|
||||||
+ Přidat položku
|
+ Přidat položku
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errors.items && (
|
{errors.items && (
|
||||||
@@ -1329,16 +1332,10 @@ export default function OfferDetail() {
|
|||||||
#
|
#
|
||||||
</th>
|
</th>
|
||||||
<th className="offers-col-desc">Popis</th>
|
<th className="offers-col-desc">Popis</th>
|
||||||
<th
|
<th className="offers-col-qty" style={{ width: "5rem" }}>
|
||||||
className="offers-col-qty"
|
|
||||||
style={{ width: "5rem" }}
|
|
||||||
>
|
|
||||||
Množství
|
Množství
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th className="offers-col-unit" style={{ width: "5rem" }}>
|
||||||
className="offers-col-unit"
|
|
||||||
style={{ width: "5rem" }}
|
|
||||||
>
|
|
||||||
Jednotka
|
Jednotka
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
@@ -1445,13 +1442,6 @@ export default function OfferDetail() {
|
|||||||
content: s.content || "",
|
content: s.content || "",
|
||||||
}));
|
}));
|
||||||
setSections((prev) => [...prev, ...newSections]);
|
setSections((prev) => [...prev, ...newSections]);
|
||||||
if (template.description) {
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
scope_description:
|
|
||||||
template.description || prev.scope_description,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
alert.success(`Načtena šablona "${template.name}"`);
|
alert.success(`Načtena šablona "${template.name}"`);
|
||||||
}
|
}
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
@@ -1522,9 +1512,7 @@ export default function OfferDetail() {
|
|||||||
{(form.language === "CZ"
|
{(form.language === "CZ"
|
||||||
? section.title_cz
|
? section.title_cz
|
||||||
: section.title) && (
|
: section.title) && (
|
||||||
<span
|
<span style={{ fontWeight: 400, marginLeft: "0.5rem" }}>
|
||||||
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
|
|
||||||
>
|
|
||||||
—{" "}
|
—{" "}
|
||||||
{form.language === "CZ"
|
{form.language === "CZ"
|
||||||
? section.title_cz || section.title
|
? section.title_cz || section.title
|
||||||
@@ -1835,6 +1823,5 @@ export default function OfferDetail() {
|
|||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,14 @@ import { Link, useNavigate } from "react-router-dom";
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
import ConfirmModal from "../components/ConfirmModal";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { Skeleton } from "boneyard-js/react";
|
|
||||||
import OffersFixture from "../fixtures/OffersFixture";
|
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||||
import SortIcon from "../components/SortIcon";
|
import SortIcon from "../components/SortIcon";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import { offerListOptions } from "../lib/queries/offers";
|
import { offerListOptions, offerCustomersOptions } from "../lib/queries/offers";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import useModalLock from "../hooks/useModalLock";
|
||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
@@ -22,6 +20,19 @@ import FormField from "../components/FormField";
|
|||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
const DRAFT_KEY = "boha_offer_draft";
|
const DRAFT_KEY = "boha_offer_draft";
|
||||||
|
|
||||||
|
const STATUS_FILTERS = [
|
||||||
|
{ value: "", label: "Vše" },
|
||||||
|
{ value: "active", label: "Aktivní" },
|
||||||
|
{ value: "ordered", label: "Objednaná" },
|
||||||
|
{ value: "invalidated", label: "Zneplatněná" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORDER_FILTERS = [
|
||||||
|
{ value: "", label: "Vše" },
|
||||||
|
{ value: "yes", label: "S objednávkou" },
|
||||||
|
{ value: "no", label: "Bez objednávky" },
|
||||||
|
];
|
||||||
|
|
||||||
interface Quotation {
|
interface Quotation {
|
||||||
id: number;
|
id: number;
|
||||||
quotation_number: string;
|
quotation_number: string;
|
||||||
@@ -57,6 +68,11 @@ export default function Offers() {
|
|||||||
useTableSort("quotation_number");
|
useTableSort("quotation_number");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [customerFilter, setCustomerFilter] = useState<number | "">("");
|
||||||
|
const [orderFilter, setOrderFilter] = useState("");
|
||||||
|
|
||||||
|
const { data: customers } = useQuery(offerCustomersOptions());
|
||||||
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -107,7 +123,15 @@ export default function Offers() {
|
|||||||
isPending,
|
isPending,
|
||||||
isFetching,
|
isFetching,
|
||||||
} = usePaginatedQuery<Quotation>(
|
} = usePaginatedQuery<Quotation>(
|
||||||
offerListOptions({ search, sort, order, page }),
|
offerListOptions({
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
page,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
customer_id: customerFilter || undefined,
|
||||||
|
has_order: orderFilter || undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const discardDraft = () => {
|
const discardDraft = () => {
|
||||||
@@ -279,8 +303,15 @@ export default function Offers() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="admin-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Skeleton name="offers" loading={isPending} fixture={<OffersFixture />}>
|
|
||||||
<div>
|
<div>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-page-header"
|
className="admin-page-header"
|
||||||
@@ -339,11 +370,32 @@ export default function Offers() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="admin-tabs mb-6">
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
className={`admin-tab ${statusFilter === f.value ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(f.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
transition={{ duration: 0.25, delay: 0.15 }}
|
||||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<div className="admin-card-body">
|
||||||
@@ -360,6 +412,54 @@ export default function Offers() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-filters mb-4">
|
||||||
|
<div className="admin-filter-group">
|
||||||
|
<label className="admin-filter-label">Zákazník</label>
|
||||||
|
<select
|
||||||
|
value={customerFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerFilter(
|
||||||
|
e.target.value ? Number(e.target.value) : "",
|
||||||
|
);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="admin-form-select"
|
||||||
|
>
|
||||||
|
<option value="">Všichni</option>
|
||||||
|
{customers?.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="admin-filter-group">
|
||||||
|
<label className="admin-filter-label">Objednávka</label>
|
||||||
|
<select
|
||||||
|
value={orderFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOrderFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="admin-form-select"
|
||||||
|
>
|
||||||
|
{ORDER_FILTERS.map((f) => (
|
||||||
|
<option key={f.value} value={f.value}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={`${statusFilter}-${customerFilter}-${orderFilter}-${search}-${page}-${quotations.length}`}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
{quotations.length === 0 && !draft ? (
|
{quotations.length === 0 && !draft ? (
|
||||||
<div className="admin-empty-state">
|
<div className="admin-empty-state">
|
||||||
<div className="admin-empty-icon">
|
<div className="admin-empty-icon">
|
||||||
@@ -609,7 +709,8 @@ export default function Offers() {
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
{!readOnly && hasPermission("offers.create") && (
|
{!readOnly &&
|
||||||
|
hasPermission("offers.create") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDuplicate(q)}
|
onClick={() => handleDuplicate(q)}
|
||||||
className="admin-btn-icon"
|
className="admin-btn-icon"
|
||||||
@@ -702,15 +803,26 @@ export default function Offers() {
|
|||||||
>
|
>
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
<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" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
<line x1="12" y1="11" x2="12" y2="17" />
|
<line
|
||||||
<line x1="9" y1="14" x2="15" y2="14" />
|
x1="12"
|
||||||
|
y1="11"
|
||||||
|
x2="12"
|
||||||
|
y2="17"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="9"
|
||||||
|
y1="14"
|
||||||
|
x2="15"
|
||||||
|
y2="14"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{isExpired &&
|
{!isInvalidated &&
|
||||||
!isInvalidated &&
|
!isCompleted &&
|
||||||
|
!q.order_id &&
|
||||||
hasPermission("offers.edit") && (
|
hasPermission("offers.edit") && (
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -817,6 +929,8 @@ export default function Offers() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -997,6 +1111,5 @@ export default function Offers() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ interface Role {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
role_permissions?: unknown[];
|
role_permissions?: unknown[];
|
||||||
|
user_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleForm {
|
interface RoleForm {
|
||||||
@@ -109,38 +110,44 @@ export default function Settings() {
|
|||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
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({
|
const { data: rolesData, isPending: rolesLoading } = useQuery({
|
||||||
queryKey: ["settings", "roles"],
|
queryKey: ["settings", "roles"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const [rolesRes, permsRes, usersRes] = await Promise.all([
|
const [rolesRes, permsRes] = await Promise.all([
|
||||||
apiFetch(`${API_BASE}/roles`),
|
apiFetch(`${API_BASE}/roles`),
|
||||||
apiFetch(`${API_BASE}/roles/permissions`),
|
apiFetch(`${API_BASE}/roles/permissions`),
|
||||||
apiFetch(`${API_BASE}/users`),
|
|
||||||
]);
|
]);
|
||||||
const rolesResult = await rolesRes.json();
|
const rolesResult = await rolesRes.json();
|
||||||
const permsResult = await permsRes.json();
|
const permsResult = await permsRes.json();
|
||||||
const usersResult = await usersRes.json();
|
|
||||||
if (!rolesResult.success)
|
if (!rolesResult.success)
|
||||||
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
|
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
|
||||||
if (!permsResult.success)
|
if (!permsResult.success)
|
||||||
throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění");
|
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 {
|
return {
|
||||||
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
|
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
|
||||||
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
|
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
|
||||||
users: Array.isArray(usersResult.data) ? usersResult.data : [],
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: canManage,
|
enabled: canManageRoles,
|
||||||
});
|
});
|
||||||
|
|
||||||
const roles = rolesData?.roles ?? [];
|
const roles = rolesData?.roles ?? [];
|
||||||
const users = rolesData?.users ?? [];
|
|
||||||
|
|
||||||
// Group permissions by module
|
// Group permissions by module
|
||||||
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
|
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
|
||||||
const perms: Permission[] = rolesData?.permissions ?? [];
|
const perms: Permission[] = rolesData?.permissions ?? [];
|
||||||
@@ -158,27 +165,32 @@ export default function Settings() {
|
|||||||
useQuery(require2FAOptions());
|
useQuery(require2FAOptions());
|
||||||
const require2FA = totpData?.require_2fa ?? false;
|
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 [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tabParam = searchParams.get("tab");
|
const tabParam = searchParams.get("tab");
|
||||||
|
const defaultTab = availableTabs[0] ?? "roles";
|
||||||
const activeTab = (
|
const activeTab = (
|
||||||
tabParam === "system"
|
tabParam === "roles" && availableTabs.includes("roles")
|
||||||
|
? "roles"
|
||||||
|
: tabParam === "system" && availableTabs.includes("system")
|
||||||
? "system"
|
? "system"
|
||||||
: tabParam === "firma"
|
: tabParam === "firma" && availableTabs.includes("firma")
|
||||||
? "firma"
|
? "firma"
|
||||||
: "security"
|
: defaultTab
|
||||||
) as "security" | "system" | "firma";
|
) as "roles" | "system" | "firma";
|
||||||
const setActiveTab = (tab: "security" | "system" | "firma") =>
|
const setActiveTab = (tab: "roles" | "system" | "firma") =>
|
||||||
setSearchParams({ tab }, { replace: true });
|
setSearchParams({ tab }, { replace: true });
|
||||||
|
|
||||||
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
|
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
|
||||||
...companySettingsOptions(),
|
...companySettingsOptions(),
|
||||||
enabled: canManage && (activeTab === "system" || activeTab === "security"),
|
enabled:
|
||||||
|
(canManageRoles || canManageSystem) &&
|
||||||
|
(activeTab === "system" || activeTab === "roles"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: systemInfo } = useQuery({
|
const { data: systemInfo } = useQuery({
|
||||||
...systemInfoOptions(),
|
...systemInfoOptions(),
|
||||||
enabled: canManage && activeTab === "system",
|
enabled: canManageSystem && activeTab === "system",
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Local state ──
|
// ── Local state ──
|
||||||
@@ -247,7 +259,7 @@ export default function Settings() {
|
|||||||
useModalLock(showModal);
|
useModalLock(showModal);
|
||||||
|
|
||||||
// ── Early return after all hooks ──
|
// ── Early return after all hooks ──
|
||||||
if (!canManage) {
|
if (!canAccessSettings) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +465,7 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rolesLoading) {
|
if (canManageRoles && rolesLoading) {
|
||||||
return (
|
return (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
name="settings"
|
name="settings"
|
||||||
@@ -516,36 +528,42 @@ export default function Settings() {
|
|||||||
? "Systémová nastavení"
|
? "Systémová nastavení"
|
||||||
: activeTab === "firma"
|
: activeTab === "firma"
|
||||||
? "Informace o firmě"
|
? "Informace o firmě"
|
||||||
: "Zabezpečení a správa rolí"}
|
: "Role"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{canManage && (
|
{availableTabs.length > 0 && (
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
|
||||||
|
{availableTabs.includes("roles") && (
|
||||||
<button
|
<button
|
||||||
className={`admin-btn admin-btn-sm ${activeTab === "security" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
className={`admin-btn admin-btn-sm ${activeTab === "roles" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("security")}
|
onClick={() => setActiveTab("roles")}
|
||||||
>
|
>
|
||||||
Zabezpečení a role
|
Role
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{availableTabs.includes("system") && (
|
||||||
<button
|
<button
|
||||||
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("system")}
|
onClick={() => setActiveTab("system")}
|
||||||
>
|
>
|
||||||
Systémová nastavení
|
Systémová nastavení
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{availableTabs.includes("firma") && (
|
||||||
<button
|
<button
|
||||||
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("firma")}
|
onClick={() => setActiveTab("firma")}
|
||||||
>
|
>
|
||||||
Firma
|
Firma
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Security Settings */}
|
{/* Security Settings */}
|
||||||
{activeTab === "security" && canManage && (
|
{activeTab === "system" && canManageSystem && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -629,7 +647,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Login Security */}
|
{/* Login Security */}
|
||||||
{activeTab === "security" && canManage && (
|
{activeTab === "system" && canManageSystem && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -686,7 +704,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Roles Table */}
|
{/* Roles Table */}
|
||||||
{activeTab === "security" && canManage && (
|
{activeTab === "roles" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -765,11 +783,7 @@ export default function Settings() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="admin-badge admin-badge-secondary">
|
<span className="admin-badge admin-badge-secondary">
|
||||||
{
|
{role.user_count ?? 0}
|
||||||
users.filter(
|
|
||||||
(u: { role_id: number }) => u.role_id === role.id,
|
|
||||||
).length
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -799,27 +813,16 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
className="admin-btn-icon danger"
|
className="admin-btn-icon danger"
|
||||||
title={
|
title={
|
||||||
users.filter(
|
(role.user_count ?? 0) > 0
|
||||||
(u: { role_id: number }) =>
|
|
||||||
u.role_id === role.id,
|
|
||||||
).length > 0
|
|
||||||
? "Nelze smazat roli s přiřazenými uživateli"
|
? "Nelze smazat roli s přiřazenými uživateli"
|
||||||
: "Smazat"
|
: "Smazat"
|
||||||
}
|
}
|
||||||
aria-label={
|
aria-label={
|
||||||
users.filter(
|
(role.user_count ?? 0) > 0
|
||||||
(u: { role_id: number }) =>
|
|
||||||
u.role_id === role.id,
|
|
||||||
).length > 0
|
|
||||||
? "Nelze smazat roli s přiřazenými uživateli"
|
? "Nelze smazat roli s přiřazenými uživateli"
|
||||||
: "Smazat"
|
: "Smazat"
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={(role.user_count ?? 0) > 0}
|
||||||
users.filter(
|
|
||||||
(u: { role_id: number }) =>
|
|
||||||
u.role_id === role.id,
|
|
||||||
).length > 0
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -846,7 +849,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* System Settings Tab */}
|
{/* System Settings Tab */}
|
||||||
{activeTab === "system" && canManage && (
|
{activeTab === "system" && (
|
||||||
<>
|
<>
|
||||||
{sysSettingsLoading && !sysFormInitialized ? (
|
{sysSettingsLoading && !sysFormInitialized ? (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
@@ -1052,260 +1055,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Section 5: Číslování dokladů */}
|
{/* Section 5: Informace o aplikaci */}
|
||||||
<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 */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -1615,7 +1365,7 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Firma tab */}
|
{/* Firma tab */}
|
||||||
{activeTab === "firma" && canManage && <CompanySettings embedded />}
|
{activeTab === "firma" && <CompanySettings embedded />}
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from "../../config/database";
|
|||||||
import {
|
import {
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
|
requireAnyPermission,
|
||||||
optionalAuth,
|
optionalAuth,
|
||||||
} from "../../middleware/auth";
|
} from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
@@ -333,7 +334,7 @@ export default async function companySettingsRoutes(
|
|||||||
// GET /api/admin/company-settings/system-info
|
// GET /api/admin/company-settings/system-info
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/system-info",
|
"/system-info",
|
||||||
{ preHandler: requirePermission("settings.company") },
|
{ preHandler: requirePermission("settings.system") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const pkg = require("../../../package.json") as { version: string };
|
const pkg = require("../../../package.json") as { version: string };
|
||||||
@@ -404,7 +405,7 @@ export default async function companySettingsRoutes(
|
|||||||
|
|
||||||
fastify.put(
|
fastify.put(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.company") },
|
{ preHandler: requireAnyPermission("settings.company", "settings.system") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
|
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
|
|||||||
@@ -168,6 +168,24 @@ function buildAddressLines(
|
|||||||
|
|
||||||
/* ── Translations ────────────────────────────────────────────────── */
|
/* ── Translations ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const paymentMethodMap: Record<string, Record<string, string>> = {
|
||||||
|
cs: {
|
||||||
|
"Bank transfer": "Příkazem",
|
||||||
|
Cash: "Hotově",
|
||||||
|
"Cash on delivery": "Dobírka",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
Příkazem: "Bank transfer",
|
||||||
|
Hotově: "Cash",
|
||||||
|
Dobírka: "Cash on delivery",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function translatePaymentMethod(value: string | null, lang: string): string {
|
||||||
|
if (!value) return "";
|
||||||
|
return paymentMethodMap[lang]?.[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
const translations: Record<string, Record<string, string>> = {
|
const translations: Record<string, Record<string, string>> = {
|
||||||
cs: {
|
cs: {
|
||||||
title: "Faktura",
|
title: "Faktura",
|
||||||
@@ -181,6 +199,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
var_symbol: "Variabilní s.:",
|
var_symbol: "Variabilní s.:",
|
||||||
const_symbol: "Konstantní s.:",
|
const_symbol: "Konstantní s.:",
|
||||||
order_no: "Objednávka č.:",
|
order_no: "Objednávka č.:",
|
||||||
|
order_date: "Objednávka ze dne:",
|
||||||
issue_date: "Datum vystavení:",
|
issue_date: "Datum vystavení:",
|
||||||
due_date: "Datum splatnosti:",
|
due_date: "Datum splatnosti:",
|
||||||
tax_date: "Datum uskutečnění plnění:",
|
tax_date: "Datum uskutečnění plnění:",
|
||||||
@@ -213,6 +232,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
stamp: "Razítko:",
|
stamp: "Razítko:",
|
||||||
ico: "IČ: ",
|
ico: "IČ: ",
|
||||||
dic: "DIČ: ",
|
dic: "DIČ: ",
|
||||||
|
cnb_rate: "Přepočet kurzem ČNB ke dni",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
title: "Invoice",
|
title: "Invoice",
|
||||||
@@ -226,6 +246,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
var_symbol: "Variable symbol:",
|
var_symbol: "Variable symbol:",
|
||||||
const_symbol: "Constant symbol:",
|
const_symbol: "Constant symbol:",
|
||||||
order_no: "Order No.:",
|
order_no: "Order No.:",
|
||||||
|
order_date: "Order date:",
|
||||||
issue_date: "Issue date:",
|
issue_date: "Issue date:",
|
||||||
due_date: "Due date:",
|
due_date: "Due date:",
|
||||||
tax_date: "Tax point date:",
|
tax_date: "Tax point date:",
|
||||||
@@ -257,6 +278,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
stamp: "Stamp:",
|
stamp: "Stamp:",
|
||||||
ico: "Reg. No.: ",
|
ico: "Reg. No.: ",
|
||||||
dic: "Tax ID: ",
|
dic: "Tax ID: ",
|
||||||
|
cnb_rate: "CNB exchange rate as of",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -450,10 +472,11 @@ export default async function invoicesPdfRoutes(
|
|||||||
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||||
const lineTotal = lineSubtotal + lineVat;
|
const lineTotal = lineSubtotal + lineVat;
|
||||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||||
|
const subDesc = item.item_description || "";
|
||||||
|
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="row-num">${i + 1}</td>
|
<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="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||||
<td class="right">${formatNum(unitPrice)}</td>
|
<td class="right">${formatNum(unitPrice)}</td>
|
||||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
<td class="right">${formatNum(lineSubtotal)}</td>
|
||||||
@@ -693,6 +716,11 @@ export default async function invoicesPdfRoutes(
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
|
.item-subdesc {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #646464;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
table.items tbody td.total-cell { font-weight: 700; }
|
table.items tbody td.total-cell { font-weight: 700; }
|
||||||
|
|
||||||
/* Soucet + total - styl z nabidek */
|
/* Soucet + total - styl z nabidek */
|
||||||
@@ -917,9 +945,9 @@ ${indentCSS}
|
|||||||
<div class="info-row"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.issue_date))}</span></div>
|
<div class="info-row"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.issue_date))}</span></div>
|
||||||
<div class="info-row"><span class="lbl">${escapeHtml(t.due_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.due_date))}</span></div>
|
<div class="info-row"><span class="lbl">${escapeHtml(t.due_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.due_date))}</span></div>
|
||||||
<div class="info-row"><span class="lbl">${escapeHtml(t.tax_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.tax_date))}</span></div>
|
<div class="info-row"><span class="lbl">${escapeHtml(t.tax_date)}</span> <span class="val">${escapeHtml(formatDate(invoice.tax_date))}</span></div>
|
||||||
<div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(invoice.payment_method)}</span></div>
|
<div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(translatePaymentMethod(invoice.payment_method, lang))}</span></div>
|
||||||
${orderNumber ? `<div class="info-row"><span class="lbl">${lang === "cs" ? "Objednávka č.:" : "Order no.:"}</span> <span class="val">${orderNumber}</span></div>` : ""}
|
${orderNumber ? `<div class="info-row"><span class="lbl">${escapeHtml(t.order_no)}</span> <span class="val">${orderNumber}</span></div>` : ""}
|
||||||
${orderDate ? `<div class="info-row"><span class="lbl">${lang === "cs" ? "Objednávka ze dne:" : "Order date:"}</span> <span class="val">${escapeHtml(orderDate)}</span></div>` : ""}
|
${orderDate ? `<div class="info-row"><span class="lbl">${escapeHtml(t.order_date)}</span> <span class="val">${escapeHtml(orderDate)}</span></div>` : ""}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1002,7 +1030,7 @@ ${indentCSS}
|
|||||||
? `<tfoot>
|
? `<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" style="font-size:0.7em; color:#666; padding-top:6px; text-align:left;">
|
<td colspan="4" style="font-size:0.7em; color:#666; padding-top:6px; text-align:left;">
|
||||||
Přepočet kurzem ČNB ke dni ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK
|
${escapeHtml(t.cnb_rate)} ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>`
|
</tfoot>`
|
||||||
|
|||||||
@@ -186,7 +186,6 @@ function buildAddressLines(
|
|||||||
|
|
||||||
const TRANSLATIONS: Record<string, Record<string, string>> = {
|
const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||||
title: { EN: "PRICE QUOTATION", CZ: "CENOV\u00C1 NAB\u00CDDKA" },
|
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" },
|
valid_until: { EN: "Valid until", CZ: "Platnost do" },
|
||||||
customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" },
|
customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" },
|
||||||
supplier: { EN: "Supplier", CZ: "Dodavatel" },
|
supplier: { EN: "Supplier", CZ: "Dodavatel" },
|
||||||
@@ -199,7 +198,6 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
|
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
|
||||||
vat: { EN: "VAT", CZ: "DPH" },
|
vat: { EN: "VAT", CZ: "DPH" },
|
||||||
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
|
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" },
|
ico: { EN: "ID", CZ: "I\u010CO" },
|
||||||
dic: { EN: "VAT ID", CZ: "DI\u010C" },
|
dic: { EN: "VAT ID", CZ: "DI\u010C" },
|
||||||
page: { EN: "Page", CZ: "Strana" },
|
page: { EN: "Page", CZ: "Strana" },
|
||||||
@@ -262,8 +260,6 @@ export default async function offersPdfRoutes(
|
|||||||
const vatRate = Number(quotation.vat_rate) || 21;
|
const vatRate = Number(quotation.vat_rate) || 21;
|
||||||
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
||||||
const totalToPay = subtotal + vatAmount;
|
const totalToPay = subtotal + vatAmount;
|
||||||
const exchangeRate = Number(quotation.exchange_rate) || 0;
|
|
||||||
|
|
||||||
let hasScopeContent = false;
|
let hasScopeContent = false;
|
||||||
for (const s of quotation.scope_sections) {
|
for (const s of quotation.scope_sections) {
|
||||||
if ((s.content || "").trim() || (s.title || "").trim()) {
|
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="label">${escapeHtml(t("total_to_pay"))}</span>
|
||||||
<span class="value">${formatCurrency(totalToPay, currency)}</span>
|
<span class="value">${formatCurrency(totalToPay, currency)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
if (exchangeRate > 0) {
|
|
||||||
totalsHtml += `<div class="exchange-rate">${escapeHtml(t("exchange_rate"))}: ${formatNum(exchangeRate, 4)}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||||
|
|
||||||
let scopeHtml = "";
|
let scopeHtml = "";
|
||||||
@@ -578,12 +570,6 @@ ${indentCSS}
|
|||||||
border-bottom: 2.5pt solid #de3a3a;
|
border-bottom: 2.5pt solid #de3a3a;
|
||||||
padding-bottom: 1mm;
|
padding-bottom: 1mm;
|
||||||
}
|
}
|
||||||
.totals .exchange-rate {
|
|
||||||
text-align: right;
|
|
||||||
font-size: 7.5pt;
|
|
||||||
color: #969696;
|
|
||||||
margin-top: 3mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Scope sections ---- */
|
/* ---- Scope sections ---- */
|
||||||
.scope-page {
|
.scope-page {
|
||||||
|
|||||||
@@ -14,18 +14,29 @@ export default async function rolesRoutes(
|
|||||||
"/",
|
"/",
|
||||||
{ preHandler: requirePermission("settings.roles") },
|
{ preHandler: requirePermission("settings.roles") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const roles = await prisma.roles.findMany({
|
const [roles, userCounts] = await Promise.all([
|
||||||
|
prisma.roles.findMany({
|
||||||
include: {
|
include: {
|
||||||
role_permissions: {
|
role_permissions: {
|
||||||
include: { permissions: true },
|
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) => ({
|
const data = roles.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
permissions: r.role_permissions.map((rp) => rp.permissions),
|
permissions: r.role_permissions.map((rp) => rp.permissions),
|
||||||
|
user_count: countMap.get(r.id) || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return success(reply, data);
|
return success(reply, data);
|
||||||
@@ -38,7 +49,7 @@ export default async function rolesRoutes(
|
|||||||
{ preHandler: requirePermission("settings.roles") },
|
{ preHandler: requirePermission("settings.roles") },
|
||||||
async (_request, reply) => {
|
async (_request, reply) => {
|
||||||
const permissions = await prisma.permissions.findMany({
|
const permissions = await prisma.permissions.findMany({
|
||||||
orderBy: { module: "asc" },
|
orderBy: [{ module: "asc" }, { id: "asc" }],
|
||||||
});
|
});
|
||||||
return success(reply, permissions);
|
return success(reply, permissions);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export default async function totpRoutes(
|
|||||||
fastify.post(
|
fastify.post(
|
||||||
"/required",
|
"/required",
|
||||||
{
|
{
|
||||||
preHandler: [requireAuth, requirePermission("settings.company")],
|
preHandler: [requireAuth, requirePermission("settings.system")],
|
||||||
bodyLimit: 10240,
|
bodyLimit: 10240,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const InvoiceItemSchema = z.object({
|
const InvoiceItemSchema = z.object({
|
||||||
description: z.string().nullish(),
|
description: z.string().nullish(),
|
||||||
|
item_description: z.string().nullish(),
|
||||||
quantity: z
|
quantity: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => {
|
.transform((v) => {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const CreateQuotationSchema = z.object({
|
|||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
|
.refine((v) => !Number.isNaN(v), { message: "Musí být platné číslo" })
|
||||||
.nullish(),
|
.nullish(),
|
||||||
|
created_at: z.string().nullish(),
|
||||||
valid_until: z.string().nullish(),
|
valid_until: z.string().nullish(),
|
||||||
currency: z.string().optional().default("CZK"),
|
currency: z.string().optional().default("CZK"),
|
||||||
language: z.string().optional().default("cs"),
|
language: z.string().optional().default("cs"),
|
||||||
@@ -59,16 +60,10 @@ export const CreateQuotationSchema = z.object({
|
|||||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||||
.optional()
|
.optional()
|
||||||
.default(true),
|
.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
|
status: z
|
||||||
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
|
.enum(["active", "ordered", "invalidated"])
|
||||||
.optional()
|
.optional()
|
||||||
.default("nova"),
|
.default("active"),
|
||||||
scope_title: z.string().nullish(),
|
scope_title: z.string().nullish(),
|
||||||
scope_description: z.string().nullish(),
|
scope_description: z.string().nullish(),
|
||||||
items: z.array(QuotationItemSchema).optional(),
|
items: z.array(QuotationItemSchema).optional(),
|
||||||
@@ -82,6 +77,7 @@ export const UpdateQuotationSchema = z.object({
|
|||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||||
.optional(),
|
.optional(),
|
||||||
|
created_at: z.union([z.string(), z.null()]).optional(),
|
||||||
valid_until: z.union([z.string(), z.null()]).optional(),
|
valid_until: z.union([z.string(), z.null()]).optional(),
|
||||||
currency: z.string().optional(),
|
currency: z.string().optional(),
|
||||||
language: z.string().optional(),
|
language: z.string().optional(),
|
||||||
@@ -93,14 +89,7 @@ export const UpdateQuotationSchema = z.object({
|
|||||||
apply_vat: z
|
apply_vat: z
|
||||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||||
.optional(),
|
.optional(),
|
||||||
exchange_rate: z
|
status: z.enum(["active", "ordered", "invalidated"]).optional(),
|
||||||
.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(),
|
|
||||||
scope_title: z.string().nullish(),
|
scope_title: z.string().nullish(),
|
||||||
scope_description: z.string().nullish(),
|
scope_description: z.string().nullish(),
|
||||||
items: z.array(QuotationItemSchema).optional(),
|
items: z.array(QuotationItemSchema).optional(),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const ALLOWED_SORT_FIELDS = [
|
|||||||
|
|
||||||
interface InvoiceItemInput {
|
interface InvoiceItemInput {
|
||||||
description?: string;
|
description?: string;
|
||||||
|
item_description?: string;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
unit_price?: number;
|
unit_price?: number;
|
||||||
@@ -374,6 +375,7 @@ export async function createInvoice(body: Record<string, any>) {
|
|||||||
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
|
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
|
||||||
invoice_id: invoice.id,
|
invoice_id: invoice.id,
|
||||||
description: item.description ?? null,
|
description: item.description ?? null,
|
||||||
|
item_description: item.item_description ?? null,
|
||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
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) => ({
|
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
|
||||||
invoice_id: id,
|
invoice_id: id,
|
||||||
description: item.description ?? null,
|
description: item.description ?? null,
|
||||||
|
item_description: item.item_description ?? null,
|
||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
unit_price: item.unit_price ?? 0,
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ export async function createOffer(body: Record<string, any>) {
|
|||||||
quotation_number: quotationNumber,
|
quotation_number: quotationNumber,
|
||||||
project_code: body.project_code ? String(body.project_code) : null,
|
project_code: body.project_code ? String(body.project_code) : null,
|
||||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||||
|
created_at: body.created_at
|
||||||
|
? new Date(String(body.created_at))
|
||||||
|
: undefined,
|
||||||
valid_until: body.valid_until
|
valid_until: body.valid_until
|
||||||
? new Date(String(body.valid_until))
|
? new Date(String(body.valid_until))
|
||||||
: null,
|
: null,
|
||||||
@@ -184,7 +187,6 @@ export async function createOffer(body: Record<string, any>) {
|
|||||||
language: body.language ? String(body.language) : "cs",
|
language: body.language ? String(body.language) : "cs",
|
||||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||||
apply_vat: body.apply_vat !== false,
|
apply_vat: body.apply_vat !== false,
|
||||||
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
|
|
||||||
status: body.status ? String(body.status) : "active",
|
status: body.status ? String(body.status) : "active",
|
||||||
scope_title: body.scope_title ? String(body.scope_title) : null,
|
scope_title: body.scope_title ? String(body.scope_title) : null,
|
||||||
scope_description: body.scope_description
|
scope_description: body.scope_description
|
||||||
@@ -240,6 +242,12 @@ export async function updateOffer(id: number, body: Record<string, any>) {
|
|||||||
const data = {
|
const data = {
|
||||||
customer_id:
|
customer_id:
|
||||||
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
||||||
|
created_at:
|
||||||
|
body.created_at !== undefined
|
||||||
|
? body.created_at
|
||||||
|
? new Date(String(body.created_at))
|
||||||
|
: null
|
||||||
|
: undefined,
|
||||||
valid_until:
|
valid_until:
|
||||||
body.valid_until !== undefined
|
body.valid_until !== undefined
|
||||||
? body.valid_until
|
? body.valid_until
|
||||||
@@ -255,8 +263,6 @@ export async function updateOffer(id: number, body: Record<string, any>) {
|
|||||||
body.apply_vat === 1 ||
|
body.apply_vat === 1 ||
|
||||||
body.apply_vat === "1"
|
body.apply_vat === "1"
|
||||||
: undefined,
|
: undefined,
|
||||||
exchange_rate:
|
|
||||||
body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
|
|
||||||
status: body.status !== undefined ? String(body.status) : undefined,
|
status: body.status !== undefined ? String(body.status) : undefined,
|
||||||
project_code:
|
project_code:
|
||||||
body.project_code !== undefined
|
body.project_code !== undefined
|
||||||
@@ -354,7 +360,6 @@ export async function duplicateOffer(id: number) {
|
|||||||
language: original.language,
|
language: original.language,
|
||||||
vat_rate: original.vat_rate,
|
vat_rate: original.vat_rate,
|
||||||
apply_vat: original.apply_vat,
|
apply_vat: original.apply_vat,
|
||||||
exchange_rate: original.exchange_rate,
|
|
||||||
status: "active",
|
status: "active",
|
||||||
scope_title: original.scope_title,
|
scope_title: original.scope_title,
|
||||||
scope_description: original.scope_description,
|
scope_description: original.scope_description,
|
||||||
|
|||||||
@@ -206,7 +206,6 @@ export async function createOrderFromQuotation(
|
|||||||
language: quotation.language || "cs",
|
language: quotation.language || "cs",
|
||||||
vat_rate: quotation.vat_rate ?? 21.0,
|
vat_rate: quotation.vat_rate ?? 21.0,
|
||||||
apply_vat: quotation.apply_vat ?? true,
|
apply_vat: quotation.apply_vat ?? true,
|
||||||
exchange_rate: quotation.exchange_rate ?? 1.0,
|
|
||||||
scope_title: quotation.scope_title,
|
scope_title: quotation.scope_title,
|
||||||
scope_description: quotation.scope_description,
|
scope_description: quotation.scope_description,
|
||||||
attachment_data: attachmentBuffer
|
attachment_data: attachmentBuffer
|
||||||
|
|||||||
Reference in New Issue
Block a user