Compare commits
6 Commits
4e534471d9
...
v2.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
396a1d37ec | ||
|
|
ca1f07671f | ||
|
|
638264fc7c | ||
|
|
f68a4dafc4 | ||
|
|
700fb47bbc | ||
|
|
c2746d78c9 |
10
CLAUDE.md
10
CLAUDE.md
@@ -520,13 +520,19 @@ The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. The
|
|||||||
2. `npm run build`
|
2. `npm run build`
|
||||||
3. Commit and tag (`git tag -a vX.Y.Z`)
|
3. Commit and tag (`git tag -a vX.Y.Z`)
|
||||||
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
||||||
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
|
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts`
|
||||||
|
(⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
|
||||||
|
without it, `prisma generate`/`migrate deploy` on prod have no datasource)
|
||||||
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
||||||
- Path: `/var/www/app-ts`
|
- Path: `/var/www/app-ts`
|
||||||
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
|
- Remove old files: `rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json`
|
||||||
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
||||||
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
||||||
- Install dependencies: `npm install --omit=dev`
|
- Install dependencies: `npm install --omit=dev`
|
||||||
|
- Regenerate the Prisma client: `npx prisma generate` — **MANDATORY**.
|
||||||
|
`npm install` skips regeneration when dependencies didn't change, leaving a
|
||||||
|
stale client that still selects dropped/renamed columns → P2022 500s in
|
||||||
|
prod (bit the v2.4.0 supplier release).
|
||||||
- Apply Prisma migrations: `npx prisma migrate deploy`
|
- Apply Prisma migrations: `npx prisma migrate deploy`
|
||||||
- Restart: `pm2 restart app-ts --update-env`
|
- Restart: `pm2 restart app-ts --update-env`
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.0",
|
"version": "2.4.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.0",
|
"version": "2.4.2",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.0",
|
"version": "2.4.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` ADD COLUMN `order_text` VARCHAR(500) NULL;
|
||||||
|
|
||||||
@@ -381,6 +381,7 @@ model issued_orders {
|
|||||||
delivery_terms String? @db.VarChar(500)
|
delivery_terms String? @db.VarChar(500)
|
||||||
payment_terms String? @db.VarChar(500)
|
payment_terms String? @db.VarChar(500)
|
||||||
issued_by String? @db.VarChar(255)
|
issued_by String? @db.VarChar(255)
|
||||||
|
order_text String? @db.VarChar(500)
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
internal_notes String? @db.Text
|
internal_notes String? @db.Text
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
|||||||
@@ -170,6 +170,23 @@ describe("createIssuedOrder", () => {
|
|||||||
const res = await createIssuedOrder({ supplier_id: 99999999 });
|
const res = await createIssuedOrder({ supplier_id: 99999999 });
|
||||||
expect("error" in res && res.error).toBe("supplier_not_found");
|
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists order_text on create, updates and clears it on update", async () => {
|
||||||
|
const order = await mkIssued({ order_text: "Objednáváme dle smlouvy:" });
|
||||||
|
let row = await prisma.issued_orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(row!.order_text).toBe("Objednáváme dle smlouvy:");
|
||||||
|
|
||||||
|
await updateIssuedOrder(order.id, { order_text: "Jiný text:" });
|
||||||
|
row = await prisma.issued_orders.findUnique({ where: { id: order.id } });
|
||||||
|
expect(row!.order_text).toBe("Jiný text:");
|
||||||
|
|
||||||
|
// null clears back to the PDF default.
|
||||||
|
await updateIssuedOrder(order.id, { order_text: null });
|
||||||
|
row = await prisma.issued_orders.findUnique({ where: { id: order.id } });
|
||||||
|
expect(row!.order_text).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updateIssuedOrder status transitions", () => {
|
describe("updateIssuedOrder status transitions", () => {
|
||||||
@@ -507,6 +524,58 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
expect(html).not.toContain("alert(1)");
|
expect(html).not.toContain("alert(1)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the custom order_text heading when set, default when not", () => {
|
||||||
|
const custom = renderIssuedOrderHtml(
|
||||||
|
{ ...order, order_text: "Objednáváme dle nabídky č. 123:" },
|
||||||
|
items,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(custom).toContain("Objednáváme dle nabídky č. 123:");
|
||||||
|
expect(custom).not.toContain("Objednáváme si u Vás:");
|
||||||
|
|
||||||
|
const fallback = renderIssuedOrderHtml(
|
||||||
|
order,
|
||||||
|
items,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(fallback).toContain("Objednáváme si u Vás:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with VAT: keeps the VAT columns and the DPH cell holds ONLY the line VAT", () => {
|
||||||
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||||
|
expect(html).toContain("%DPH");
|
||||||
|
expect(html).toContain(">DPH<");
|
||||||
|
// 2 × 100 @ 21 %: DPH cell = 42,00 (VAT only), Celkem cell = 242,00.
|
||||||
|
expect(html).toContain('<td class="right">42,00</td>');
|
||||||
|
expect(html).toContain('<td class="right total-cell">242,00</td>');
|
||||||
|
expect(html).not.toContain("Celkem bez DPH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without VAT: hides the VAT columns and labels the total 'Celkem bez DPH'", () => {
|
||||||
|
const html = renderIssuedOrderHtml(
|
||||||
|
{ ...order, apply_vat: false },
|
||||||
|
items,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("%DPH");
|
||||||
|
expect(html).not.toContain(">DPH<");
|
||||||
|
// No per-line VAT cell, line total = netto.
|
||||||
|
expect(html).not.toContain('<td class="right">42,00</td>');
|
||||||
|
expect(html).toContain('<td class="right total-cell">200,00</td>');
|
||||||
|
expect(html).toContain("Celkem bez DPH");
|
||||||
|
// The subtotal detail row is dropped (it would duplicate the grand total).
|
||||||
|
expect(html).not.toContain("Mezisoučet");
|
||||||
|
});
|
||||||
|
|
||||||
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
|
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
|
||||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||||
// Footer: Vystavil <name> from authData. No e-mail line.
|
// Footer: Vystavil <name> from authData. No e-mail line.
|
||||||
|
|||||||
@@ -1048,6 +1048,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1120,6 +1124,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowBulkModal(false);
|
setShowBulkModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1255,6 +1263,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1285,6 +1297,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setDeleteConfirm({ show: false, record: null });
|
setDeleteConfirm({ show: false, record: null });
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
alert.success(
|
alert.success(
|
||||||
result.message || result.data?.message || "Záznam smazán",
|
result.message || result.data?.message || "Záznam smazán",
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export const dashboardOptions = () =>
|
|||||||
queryKey: ["dashboard"],
|
queryKey: ["dashboard"],
|
||||||
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
|
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
|
// The dashboard aggregates MANY domains (attendance, offers, invoices,
|
||||||
|
// orders, projects, leave). Mutations in those domains can't all be
|
||||||
|
// expected to invalidate ["dashboard"], so always refetch on mount —
|
||||||
|
// navigating back to the dashboard must never show pre-mutation data.
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
// require2FAOptions lives in ./settings.ts (the single definition consumers
|
// require2FAOptions lives in ./settings.ts (the single definition consumers
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface IssuedOrderDetail extends IssuedOrder {
|
|||||||
delivery_terms: string | null;
|
delivery_terms: string | null;
|
||||||
payment_terms: string | null;
|
payment_terms: string | null;
|
||||||
issued_by: string | null;
|
issued_by: string | null;
|
||||||
|
order_text: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
internal_notes: string | null;
|
internal_notes: string | null;
|
||||||
items: IssuedOrderItem[];
|
items: IssuedOrderItem[];
|
||||||
|
|||||||
@@ -274,7 +274,8 @@ export default function Attendance() {
|
|||||||
>({
|
>({
|
||||||
url: () => `${API_BASE}/attendance`,
|
url: () => `${API_BASE}/attendance`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance"],
|
// dashboard included: the punch-button state + presence cards live there.
|
||||||
|
invalidate: ["attendance", "dashboard"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
|
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
|
||||||
@@ -300,7 +301,8 @@ export default function Attendance() {
|
|||||||
>({
|
>({
|
||||||
url: () => `${API_BASE}/leave-requests`,
|
url: () => `${API_BASE}/leave-requests`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance", "leave-requests", "leave", "users"],
|
// dashboard included: approvers see the pending-requests KPI there.
|
||||||
|
invalidate: ["attendance", "leave-requests", "leave", "users", "dashboard"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function AttendanceCreate() {
|
|||||||
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
|
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
|
||||||
url: () => `${API_BASE}/attendance`,
|
url: () => `${API_BASE}/attendance`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance", "users"],
|
invalidate: ["attendance", "users", "dashboard"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateForm>(() => {
|
const [form, setForm] = useState<CreateForm>(() => {
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ interface OrderForm {
|
|||||||
delivery_terms: string;
|
delivery_terms: string;
|
||||||
payment_terms: string;
|
payment_terms: string;
|
||||||
issued_by: string;
|
issued_by: string;
|
||||||
|
order_text: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
internal_notes: string;
|
internal_notes: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -520,6 +521,7 @@ export default function IssuedOrderDetail() {
|
|||||||
delivery_terms: "",
|
delivery_terms: "",
|
||||||
payment_terms: "",
|
payment_terms: "",
|
||||||
issued_by: user?.fullName || "",
|
issued_by: user?.fullName || "",
|
||||||
|
order_text: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
internal_notes: "",
|
internal_notes: "",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
@@ -621,6 +623,7 @@ export default function IssuedOrderDetail() {
|
|||||||
delivery_terms: d.delivery_terms || "",
|
delivery_terms: d.delivery_terms || "",
|
||||||
payment_terms: d.payment_terms || "",
|
payment_terms: d.payment_terms || "",
|
||||||
issued_by: d.issued_by || "",
|
issued_by: d.issued_by || "",
|
||||||
|
order_text: d.order_text || "",
|
||||||
notes: d.notes || "",
|
notes: d.notes || "",
|
||||||
internal_notes: d.internal_notes || "",
|
internal_notes: d.internal_notes || "",
|
||||||
status: d.status,
|
status: d.status,
|
||||||
@@ -779,6 +782,7 @@ export default function IssuedOrderDetail() {
|
|||||||
delivery_terms: form.delivery_terms,
|
delivery_terms: form.delivery_terms,
|
||||||
payment_terms: form.payment_terms,
|
payment_terms: form.payment_terms,
|
||||||
issued_by: form.issued_by,
|
issued_by: form.issued_by,
|
||||||
|
order_text: form.order_text || null,
|
||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
internal_notes: form.internal_notes,
|
internal_notes: form.internal_notes,
|
||||||
items: items
|
items: items
|
||||||
@@ -1419,6 +1423,16 @@ export default function IssuedOrderDetail() {
|
|||||||
|
|
||||||
{/* Notes & terms */}
|
{/* Notes & terms */}
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<Field label="Text objednávky (na PDF)">
|
||||||
|
<TextField
|
||||||
|
value={form.order_text}
|
||||||
|
disabled={!editable}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, order_text: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field label="Dodací podmínky">
|
<Field label="Dodací podmínky">
|
||||||
<TextField
|
<TextField
|
||||||
value={form.delivery_terms}
|
value={form.delivery_terms}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export default function LeaveApproval() {
|
|||||||
>({
|
>({
|
||||||
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["leave-requests", "leave", "attendance", "users"],
|
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setApproveModal({ open: false, request: null });
|
setApproveModal({ open: false, request: null });
|
||||||
alert.success("Žádost byla schválena");
|
alert.success("Žádost byla schválena");
|
||||||
@@ -174,7 +174,7 @@ export default function LeaveApproval() {
|
|||||||
>({
|
>({
|
||||||
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["leave-requests", "leave", "attendance", "users"],
|
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setRejectModal({ open: false, request: null });
|
setRejectModal({ open: false, request: null });
|
||||||
setRejectNote("");
|
setRejectNote("");
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
buyer: "Odběratel",
|
buyer: "Odběratel",
|
||||||
issue_date: "Datum vystavení:",
|
issue_date: "Datum vystavení:",
|
||||||
delivery_date: "Požadované dodání:",
|
delivery_date: "Požadované dodání:",
|
||||||
billing: "Objednáváme u Vás:",
|
billing: "Objednáváme si u Vás:",
|
||||||
col_no: "Č.",
|
col_no: "Č.",
|
||||||
col_desc: "Popis",
|
col_desc: "Popis",
|
||||||
col_qty: "Množství",
|
col_qty: "Množství",
|
||||||
@@ -207,6 +207,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
subtotal: "Mezisoučet:",
|
subtotal: "Mezisoučet:",
|
||||||
vat_label: "DPH",
|
vat_label: "DPH",
|
||||||
total: "Celkem",
|
total: "Celkem",
|
||||||
|
total_no_vat: "Celkem bez DPH",
|
||||||
amounts_in: "Částky jsou uvedeny v",
|
amounts_in: "Částky jsou uvedeny v",
|
||||||
notes: "Poznámky",
|
notes: "Poznámky",
|
||||||
delivery_terms: "Dodací podmínky:",
|
delivery_terms: "Dodací podmínky:",
|
||||||
@@ -234,6 +235,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
subtotal: "Subtotal:",
|
subtotal: "Subtotal:",
|
||||||
vat_label: "VAT",
|
vat_label: "VAT",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
|
total_no_vat: "Total excl. VAT",
|
||||||
amounts_in: "Amounts are in",
|
amounts_in: "Amounts are in",
|
||||||
notes: "Notes",
|
notes: "Notes",
|
||||||
delivery_terms: "Delivery terms:",
|
delivery_terms: "Delivery terms:",
|
||||||
@@ -255,6 +257,8 @@ interface IssuedOrderPdfData {
|
|||||||
delivery_terms: string | null;
|
delivery_terms: string | null;
|
||||||
payment_terms: string | null;
|
payment_terms: string | null;
|
||||||
issued_by: string | null;
|
issued_by: string | null;
|
||||||
|
// Editable heading above the items table; null → t.billing default.
|
||||||
|
order_text?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IssuedOrderPdfItem {
|
interface IssuedOrderPdfItem {
|
||||||
@@ -336,14 +340,19 @@ export function renderIssuedOrderHtml(
|
|||||||
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
|
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
|
||||||
: ""
|
: ""
|
||||||
}`;
|
}`;
|
||||||
|
// Without "Uplatnit DPH" the VAT columns are dropped entirely (the
|
||||||
|
// header does the same) instead of printing meaningless 0% / 0.00.
|
||||||
|
const vatCells = applyVat
|
||||||
|
? `
|
||||||
|
<td class="center">${Math.floor(rate)}%</td>
|
||||||
|
<td class="right">${formatNum(lineVat)}</td>`
|
||||||
|
: "";
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="row-num">${i + 1}</td>
|
<td class="row-num">${i + 1}</td>
|
||||||
<td class="desc">${descHtml}</td>
|
<td class="desc">${descHtml}</td>
|
||||||
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
|
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.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>${vatCells}
|
||||||
<td class="center">${applyVat ? Math.floor(rate) : 0}%</td>
|
|
||||||
<td class="right">${formatNum(lineVat)}</td>
|
|
||||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
@@ -764,18 +773,22 @@ ${indentCSS}
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Polozky -->
|
<!-- Polozky -->
|
||||||
<div class="billing-label">${escapeHtml(t.billing)}</div>
|
<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
|
||||||
<table class="items">
|
<table class="items">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
|
||||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
|
||||||
|
applyVat
|
||||||
|
? `
|
||||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>`
|
||||||
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
|
: ""
|
||||||
|
}
|
||||||
|
<th class="right" style="width:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -783,17 +796,21 @@ ${indentCSS}
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Soucty -->
|
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
|
||||||
<div class="totals-wrapper">
|
<div class="totals-wrapper">
|
||||||
<div class="totals">
|
<div class="totals">${
|
||||||
|
applyVat
|
||||||
|
? `
|
||||||
<div class="detail-rows">
|
<div class="detail-rows">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||||
</div>${vatDetailHtml}
|
</div>${vatDetailHtml}
|
||||||
</div>
|
</div>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
<div class="grand">
|
<div class="grand">
|
||||||
<span class="label">${escapeHtml(t.total)}</span>
|
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
|
||||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
subtotal: "Mezisoučet:",
|
subtotal: "Mezisoučet:",
|
||||||
vat_label: "DPH",
|
vat_label: "DPH",
|
||||||
total: "Celkem",
|
total: "Celkem",
|
||||||
|
total_no_vat: "Celkem bez DPH",
|
||||||
amounts_in: "Částky jsou uvedeny v",
|
amounts_in: "Částky jsou uvedeny v",
|
||||||
notes: "Poznámky",
|
notes: "Poznámky",
|
||||||
issued_by: "Vystavil:",
|
issued_by: "Vystavil:",
|
||||||
@@ -228,6 +229,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
subtotal: "Subtotal:",
|
subtotal: "Subtotal:",
|
||||||
vat_label: "VAT",
|
vat_label: "VAT",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
|
total_no_vat: "Total excl. VAT",
|
||||||
amounts_in: "Amounts are in",
|
amounts_in: "Amounts are in",
|
||||||
notes: "Notes",
|
notes: "Notes",
|
||||||
issued_by: "Issued by:",
|
issued_by: "Issued by:",
|
||||||
@@ -388,14 +390,19 @@ export default async function ordersPdfRoutes(
|
|||||||
const lineTotal = lineSubtotal + lineVat;
|
const lineTotal = lineSubtotal + lineVat;
|
||||||
const qtyDecimals =
|
const qtyDecimals =
|
||||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||||
|
// Without "Uplatnit DPH" the VAT columns are dropped entirely
|
||||||
|
// (the header does the same) instead of printing 0% / 0.00.
|
||||||
|
const vatCells = applyVat
|
||||||
|
? `
|
||||||
|
<td class="center">${Math.floor(item.vat_rate)}%</td>
|
||||||
|
<td class="right">${formatNum(lineVat)}</td>`
|
||||||
|
: "";
|
||||||
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)}</td>
|
||||||
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||||
<td class="right">${formatNum(item.unit_price)}</td>
|
<td class="right">${formatNum(item.unit_price)}</td>
|
||||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
|
||||||
<td class="center">${applyVat ? Math.floor(item.vat_rate) : 0}%</td>
|
|
||||||
<td class="right">${formatNum(lineVat)}</td>
|
|
||||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
@@ -848,13 +855,17 @@ ${indentCSS}
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
|
||||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
|
||||||
|
applyVat
|
||||||
|
? `
|
||||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>`
|
||||||
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
|
: ""
|
||||||
|
}
|
||||||
|
<th class="right" style="width:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -862,17 +873,21 @@ ${indentCSS}
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Soucty -->
|
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
|
||||||
<div class="totals-wrapper">
|
<div class="totals-wrapper">
|
||||||
<div class="totals">
|
<div class="totals">${
|
||||||
|
applyVat
|
||||||
|
? `
|
||||||
<div class="detail-rows">
|
<div class="detail-rows">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||||
</div>${vatDetailHtml}
|
</div>${vatDetailHtml}
|
||||||
</div>
|
</div>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
<div class="grand">
|
<div class="grand">
|
||||||
<span class="label">${escapeHtml(t.total)}</span>
|
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
|
||||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export const CreateIssuedOrderSchema = z.object({
|
|||||||
delivery_terms: z.string().max(500).nullish(),
|
delivery_terms: z.string().max(500).nullish(),
|
||||||
payment_terms: z.string().max(500).nullish(),
|
payment_terms: z.string().max(500).nullish(),
|
||||||
issued_by: z.string().max(255).nullish(),
|
issued_by: z.string().max(255).nullish(),
|
||||||
|
// Editable heading above the PDF items table; empty/null falls back to the
|
||||||
|
// default "Objednáváme si u Vás:" (issued-orders-pdf t.billing).
|
||||||
|
order_text: z.string().max(500).nullish(),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
internal_notes: z.string().nullish(),
|
internal_notes: z.string().nullish(),
|
||||||
items: z.array(IssuedOrderItemSchema).optional(),
|
items: z.array(IssuedOrderItemSchema).optional(),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface IssuedOrderInput {
|
|||||||
delivery_terms?: string | null;
|
delivery_terms?: string | null;
|
||||||
payment_terms?: string | null;
|
payment_terms?: string | null;
|
||||||
issued_by?: string | null;
|
issued_by?: string | null;
|
||||||
|
order_text?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
internal_notes?: string | null;
|
internal_notes?: string | null;
|
||||||
items?: IssuedOrderItemInput[];
|
items?: IssuedOrderItemInput[];
|
||||||
@@ -304,6 +305,7 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
: null,
|
: null,
|
||||||
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
||||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
issued_by: body.issued_by ? String(body.issued_by) : null,
|
||||||
|
order_text: body.order_text ? String(body.order_text) : null,
|
||||||
notes: body.notes ? String(body.notes) : null,
|
notes: body.notes ? String(body.notes) : null,
|
||||||
internal_notes: body.internal_notes
|
internal_notes: body.internal_notes
|
||||||
? String(body.internal_notes)
|
? String(body.internal_notes)
|
||||||
@@ -354,6 +356,7 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
"delivery_terms",
|
"delivery_terms",
|
||||||
"payment_terms",
|
"payment_terms",
|
||||||
"issued_by",
|
"issued_by",
|
||||||
|
"order_text",
|
||||||
];
|
];
|
||||||
for (const f of strFields) {
|
for (const f of strFields) {
|
||||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||||
|
|||||||
Reference in New Issue
Block a user