feat(issued-orders)!: sections replace flat terms/notes fields; Obsah title
Per user decision the rich-text sections now carry all free-form PDF content on issued orders: dropped columns delivery_terms, payment_terms, issued_by, notes (migration applied to dev+test) and their PDF blocks + form fields. Kept: order_text (moved from the bottom card into the Zakladni udaje grid, replacing the Vystavil field) and internal_notes (now the only field in the bottom card, never printed). Sections card is titled 'Obsah' on issued orders; offers keep 'Rozsah projektu' and are untouched. Legacy clients sending the dropped keys are silently stripped (tested). PDF footer 'Vystavil:' stays - it prints the logged-in user, not the dropped column. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` DROP COLUMN `delivery_terms`,
|
||||||
|
DROP COLUMN `issued_by`,
|
||||||
|
DROP COLUMN `notes`,
|
||||||
|
DROP COLUMN `payment_terms`;
|
||||||
|
|
||||||
@@ -374,11 +374,7 @@ model issued_orders {
|
|||||||
order_date DateTime? @db.Date
|
order_date DateTime? @db.Date
|
||||||
delivery_date DateTime? @db.Date
|
delivery_date DateTime? @db.Date
|
||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
delivery_terms String? @db.VarChar(500)
|
|
||||||
payment_terms String? @db.VarChar(500)
|
|
||||||
issued_by String? @db.VarChar(255)
|
|
||||||
order_text String? @db.VarChar(500)
|
order_text String? @db.VarChar(500)
|
||||||
notes String? @db.Text
|
|
||||||
internal_notes String? @db.Text
|
internal_notes String? @db.Text
|
||||||
locked_by Int?
|
locked_by Int?
|
||||||
locked_at DateTime? @db.DateTime(0)
|
locked_at DateTime? @db.DateTime(0)
|
||||||
|
|||||||
@@ -481,6 +481,36 @@ describe("POST /api/admin/issued-orders supplier validation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /api/admin/issued-orders legacy dropped fields", () => {
|
||||||
|
it("silently ignores the removed flat-text keys (old clients keep working)", async () => {
|
||||||
|
// delivery_terms/payment_terms/issued_by/notes were dropped from the
|
||||||
|
// schema AND the table — a stale client still sending them must save
|
||||||
|
// cleanly (non-strict Zod strips unknown keys) with the kept fields intact.
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/issued-orders",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
payload: {
|
||||||
|
notes: "<p>stará poznámka</p>",
|
||||||
|
delivery_terms: "DAP, sklad odběratele",
|
||||||
|
payment_terms: "splatnost 14 dní",
|
||||||
|
issued_by: "Starý klient",
|
||||||
|
order_text: "Objednáváme dle smlouvy:",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
createdIds.push(body.data.id);
|
||||||
|
|
||||||
|
const row = await prisma.issued_orders.findUnique({
|
||||||
|
where: { id: body.data.id },
|
||||||
|
});
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
expect(row!.order_text).toBe("Objednáváme dle smlouvy:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
||||||
it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
|
it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
|
||||||
const order = await mkIssued({});
|
const order = await mkIssued({});
|
||||||
@@ -491,7 +521,7 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
url: `/api/admin/issued-orders/${order.id}`,
|
url: `/api/admin/issued-orders/${order.id}`,
|
||||||
headers: { Authorization: `Bearer ${adminToken}` },
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
payload: { notes: "pokus o úpravu" },
|
payload: { order_text: "pokus o úpravu" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat");
|
expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat");
|
||||||
@@ -518,12 +548,12 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
|||||||
|
|
||||||
describe("issued-order audit old/new values", () => {
|
describe("issued-order audit old/new values", () => {
|
||||||
it("PUT writes an audit row carrying oldValues and newValues", async () => {
|
it("PUT writes an audit row carrying oldValues and newValues", async () => {
|
||||||
const order = await mkIssued({ notes: "před úpravou" });
|
const order = await mkIssued({ internal_notes: "před úpravou" });
|
||||||
const res = await app!.inject({
|
const res = await app!.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
url: `/api/admin/issued-orders/${order.id}`,
|
url: `/api/admin/issued-orders/${order.id}`,
|
||||||
headers: { Authorization: `Bearer ${adminToken}` },
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
payload: { notes: "po úpravě" },
|
payload: { internal_notes: "po úpravě" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
@@ -538,15 +568,15 @@ describe("issued-order audit old/new values", () => {
|
|||||||
expect(audit).not.toBeNull();
|
expect(audit).not.toBeNull();
|
||||||
expect(audit!.old_values).toBeTruthy();
|
expect(audit!.old_values).toBeTruthy();
|
||||||
expect(audit!.new_values).toBeTruthy();
|
expect(audit!.new_values).toBeTruthy();
|
||||||
expect(JSON.parse(audit!.old_values!).notes).toBe("před úpravou");
|
expect(JSON.parse(audit!.old_values!).internal_notes).toBe("před úpravou");
|
||||||
expect(JSON.parse(audit!.new_values!).notes).toBe("po úpravě");
|
expect(JSON.parse(audit!.new_values!).internal_notes).toBe("po úpravě");
|
||||||
// Unnumbered drafts read "koncept", never "null".
|
// Unnumbered drafts read "koncept", never "null".
|
||||||
expect(audit!.description).toContain("koncept");
|
expect(audit!.description).toContain("koncept");
|
||||||
expect(audit!.description).not.toContain("null");
|
expect(audit!.description).not.toContain("null");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("DELETE writes an audit row carrying oldValues", async () => {
|
it("DELETE writes an audit row carrying oldValues", async () => {
|
||||||
const order = await mkIssued({ notes: "ke smazání" });
|
const order = await mkIssued({ internal_notes: "ke smazání" });
|
||||||
const res = await app!.inject({
|
const res = await app!.inject({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
url: `/api/admin/issued-orders/${order.id}`,
|
url: `/api/admin/issued-orders/${order.id}`,
|
||||||
@@ -563,7 +593,7 @@ describe("issued-order audit old/new values", () => {
|
|||||||
orderBy: { id: "desc" },
|
orderBy: { id: "desc" },
|
||||||
});
|
});
|
||||||
expect(audit).not.toBeNull();
|
expect(audit).not.toBeNull();
|
||||||
expect(JSON.parse(audit!.old_values!).notes).toBe("ke smazání");
|
expect(JSON.parse(audit!.old_values!).internal_notes).toBe("ke smazání");
|
||||||
expect(audit!.description).toContain("koncept");
|
expect(audit!.description).toContain("koncept");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -612,10 +642,6 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
order_date: new Date("2026-06-09T12:00:00"),
|
order_date: new Date("2026-06-09T12:00:00"),
|
||||||
delivery_date: null,
|
delivery_date: null,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
notes: "<p>Pozn</p><script>alert(1)</script>",
|
|
||||||
delivery_terms: null,
|
|
||||||
payment_terms: null,
|
|
||||||
issued_by: null,
|
|
||||||
};
|
};
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -690,10 +716,17 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
expect(html).not.toContain("DIČ: ");
|
expect(html).not.toContain("DIČ: ");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips script tags from notes", () => {
|
it("escapes HTML in the order_text heading (no raw tags reach the PDF)", () => {
|
||||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
const html = renderIssuedOrderHtml(
|
||||||
|
{ ...order, order_text: '<script>alert(1)</script>Text "X"' },
|
||||||
|
items,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
expect(html).not.toContain("<script>");
|
expect(html).not.toContain("<script>");
|
||||||
expect(html).not.toContain("alert(1)");
|
expect(html).toContain("<script>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the custom order_text heading when set, default when not", () => {
|
it("renders the custom order_text heading when set, default when not", () => {
|
||||||
@@ -846,7 +879,7 @@ describe("issued-order sections (service)", () => {
|
|||||||
describe("updateIssuedOrder atomicity (single transaction)", () => {
|
describe("updateIssuedOrder atomicity (single transaction)", () => {
|
||||||
it("rolls back the header write when the items replace fails", async () => {
|
it("rolls back the header write when the items replace fails", async () => {
|
||||||
const order = await mkIssued({
|
const order = await mkIssued({
|
||||||
notes: "původní",
|
internal_notes: "původní",
|
||||||
items: [{ description: "A", quantity: 1, unit_price: 10 }],
|
items: [{ description: "A", quantity: 1, unit_price: 10 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -856,7 +889,7 @@ describe("updateIssuedOrder atomicity (single transaction)", () => {
|
|||||||
const tooLong = "x".repeat(600);
|
const tooLong = "x".repeat(600);
|
||||||
await expect(
|
await expect(
|
||||||
updateIssuedOrder(order.id, {
|
updateIssuedOrder(order.id, {
|
||||||
notes: "změněno",
|
internal_notes: "změněno",
|
||||||
items: [{ description: tooLong, quantity: 1, unit_price: 1 }],
|
items: [{ description: tooLong, quantity: 1, unit_price: 1 }],
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
@@ -864,7 +897,7 @@ describe("updateIssuedOrder atomicity (single transaction)", () => {
|
|||||||
const row = await prisma.issued_orders.findUnique({
|
const row = await prisma.issued_orders.findUnique({
|
||||||
where: { id: order.id },
|
where: { id: order.id },
|
||||||
});
|
});
|
||||||
expect(row!.notes).toBe("původní");
|
expect(row!.internal_notes).toBe("původní");
|
||||||
const items = await prisma.issued_order_items.findMany({
|
const items = await prisma.issued_order_items.findMany({
|
||||||
where: { issued_order_id: order.id },
|
where: { issued_order_id: order.id },
|
||||||
});
|
});
|
||||||
@@ -1156,10 +1189,6 @@ describe("renderIssuedOrderHtml sections (scope page)", () => {
|
|||||||
order_date: new Date("2026-06-09T12:00:00"),
|
order_date: new Date("2026-06-09T12:00:00"),
|
||||||
delivery_date: null,
|
delivery_date: null,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
notes: null,
|
|
||||||
delivery_terms: null,
|
|
||||||
payment_terms: null,
|
|
||||||
issued_by: null,
|
|
||||||
};
|
};
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,11 +53,7 @@ export interface IssuedOrderDetail extends IssuedOrder {
|
|||||||
exchange_rate: number | string | null;
|
exchange_rate: number | string | null;
|
||||||
delivery_date: string | null;
|
delivery_date: string | null;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
delivery_terms: string | null;
|
|
||||||
payment_terms: string | null;
|
|
||||||
issued_by: string | null;
|
|
||||||
order_text: string | null;
|
order_text: string | null;
|
||||||
notes: string | null;
|
|
||||||
internal_notes: string | null;
|
internal_notes: string | null;
|
||||||
items: IssuedOrderItem[];
|
items: IssuedOrderItem[];
|
||||||
sections: IssuedOrderSection[];
|
sections: IssuedOrderSection[];
|
||||||
|
|||||||
@@ -106,11 +106,7 @@ interface OrderForm {
|
|||||||
order_date: string;
|
order_date: string;
|
||||||
delivery_date: string;
|
delivery_date: string;
|
||||||
language: string;
|
language: string;
|
||||||
delivery_terms: string;
|
|
||||||
payment_terms: string;
|
|
||||||
issued_by: string;
|
|
||||||
order_text: string;
|
order_text: string;
|
||||||
notes: string;
|
|
||||||
internal_notes: string;
|
internal_notes: string;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
@@ -137,11 +133,7 @@ interface IssuedOrderSavePayload {
|
|||||||
order_date: string;
|
order_date: string;
|
||||||
delivery_date: string | null;
|
delivery_date: string | null;
|
||||||
language: string;
|
language: string;
|
||||||
delivery_terms: string;
|
|
||||||
payment_terms: string;
|
|
||||||
issued_by: string;
|
|
||||||
order_text: string | null;
|
order_text: string | null;
|
||||||
notes: string;
|
|
||||||
internal_notes: string;
|
internal_notes: string;
|
||||||
items: IssuedOrderItemPayload[];
|
items: IssuedOrderItemPayload[];
|
||||||
sections: IssuedOrderSectionPayload[];
|
sections: IssuedOrderSectionPayload[];
|
||||||
@@ -154,7 +146,7 @@ export default function IssuedOrderDetail() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission, user } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [form, setForm] = useState<OrderForm>({
|
const [form, setForm] = useState<OrderForm>({
|
||||||
@@ -164,11 +156,7 @@ export default function IssuedOrderDetail() {
|
|||||||
order_date: todayLocalStr(),
|
order_date: todayLocalStr(),
|
||||||
delivery_date: "",
|
delivery_date: "",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
delivery_terms: "",
|
|
||||||
payment_terms: "",
|
|
||||||
issued_by: user?.fullName || "",
|
|
||||||
order_text: "",
|
order_text: "",
|
||||||
notes: "",
|
|
||||||
internal_notes: "",
|
internal_notes: "",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
});
|
});
|
||||||
@@ -287,11 +275,7 @@ export default function IssuedOrderDetail() {
|
|||||||
order_date: normalizeDateStr(d.order_date),
|
order_date: normalizeDateStr(d.order_date),
|
||||||
delivery_date: normalizeDateStr(d.delivery_date),
|
delivery_date: normalizeDateStr(d.delivery_date),
|
||||||
language: d.language || "cs",
|
language: d.language || "cs",
|
||||||
delivery_terms: d.delivery_terms || "",
|
|
||||||
payment_terms: d.payment_terms || "",
|
|
||||||
issued_by: d.issued_by || "",
|
|
||||||
order_text: d.order_text || "",
|
order_text: d.order_text || "",
|
||||||
notes: d.notes || "",
|
|
||||||
internal_notes: d.internal_notes || "",
|
internal_notes: d.internal_notes || "",
|
||||||
status: d.status,
|
status: d.status,
|
||||||
};
|
};
|
||||||
@@ -423,11 +407,7 @@ export default function IssuedOrderDetail() {
|
|||||||
order_date: form.order_date,
|
order_date: form.order_date,
|
||||||
delivery_date: form.delivery_date || null,
|
delivery_date: form.delivery_date || null,
|
||||||
language: form.language,
|
language: form.language,
|
||||||
delivery_terms: form.delivery_terms,
|
|
||||||
payment_terms: form.payment_terms,
|
|
||||||
issued_by: form.issued_by,
|
|
||||||
order_text: form.order_text || null,
|
order_text: form.order_text || null,
|
||||||
notes: form.notes,
|
|
||||||
internal_notes: form.internal_notes,
|
internal_notes: form.internal_notes,
|
||||||
items: items
|
items: items
|
||||||
.filter((i) => i.description.trim())
|
.filter((i) => i.description.trim())
|
||||||
@@ -859,11 +839,15 @@ export default function IssuedOrderDetail() {
|
|||||||
</Field>
|
</Field>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Field label="Vystavil">
|
<Field label="Text objednávky (na PDF)">
|
||||||
<TextField
|
<TextField
|
||||||
value={form.issued_by}
|
value={form.order_text}
|
||||||
InputProps={{ readOnly: true }}
|
InputProps={{ readOnly: !editable }}
|
||||||
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
|
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||||
|
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>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -877,57 +861,17 @@ export default function IssuedOrderDetail() {
|
|||||||
error={errors.items}
|
error={errors.items}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Rich-text PDF sections (mirrors offers' scope sections) */}
|
{/* Rich-text PDF sections — the free-form PDF content lives here */}
|
||||||
<SectionsEditor
|
<SectionsEditor
|
||||||
sections={sections}
|
sections={sections}
|
||||||
onChange={setSections}
|
onChange={setSections}
|
||||||
readOnly={!editable}
|
readOnly={!editable}
|
||||||
|
title="Obsah"
|
||||||
language={form.language}
|
language={form.language}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Notes & terms */}
|
{/* Internal notes */}
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
<Field label="Text objednávky (na PDF)">
|
|
||||||
<TextField
|
|
||||||
value={form.order_text}
|
|
||||||
InputProps={{ readOnly: !editable }}
|
|
||||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
|
||||||
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">
|
|
||||||
<TextField
|
|
||||||
value={form.delivery_terms}
|
|
||||||
InputProps={{ readOnly: !editable }}
|
|
||||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((prev) => ({ ...prev, delivery_terms: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Např. DAP, sklad odběratele..."
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Platební podmínky">
|
|
||||||
<TextField
|
|
||||||
value={form.payment_terms}
|
|
||||||
InputProps={{ readOnly: !editable }}
|
|
||||||
slotProps={{ htmlInput: { maxLength: 500 } }}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((prev) => ({ ...prev, payment_terms: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Např. splatnost 14 dní..."
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Poznámky (na PDF)">
|
|
||||||
<RichEditor
|
|
||||||
value={form.notes}
|
|
||||||
readOnly={!editable}
|
|
||||||
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))}
|
|
||||||
placeholder="Veřejné poznámky zobrazené na objednávce..."
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Interní poznámky" hint="Nezobrazí se na PDF.">
|
<Field label="Interní poznámky" hint="Nezobrazí se na PDF.">
|
||||||
<RichEditor
|
<RichEditor
|
||||||
value={form.internal_notes}
|
value={form.internal_notes}
|
||||||
|
|||||||
@@ -160,9 +160,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
col_unit_price: "Jedn. cena",
|
col_unit_price: "Jedn. cena",
|
||||||
col_total: "Celkem",
|
col_total: "Celkem",
|
||||||
total_no_vat: "Celkem bez DPH",
|
total_no_vat: "Celkem bez DPH",
|
||||||
notes: "Poznámky",
|
|
||||||
delivery_terms: "Dodací podmínky:",
|
|
||||||
payment_terms: "Platební podmínky:",
|
|
||||||
issued_by: "Vystavil:",
|
issued_by: "Vystavil:",
|
||||||
ico: "IČ: ",
|
ico: "IČ: ",
|
||||||
dic: "DIČ: ",
|
dic: "DIČ: ",
|
||||||
@@ -181,9 +178,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
col_unit_price: "Unit price",
|
col_unit_price: "Unit price",
|
||||||
col_total: "Total",
|
col_total: "Total",
|
||||||
total_no_vat: "Total excl. VAT",
|
total_no_vat: "Total excl. VAT",
|
||||||
notes: "Notes",
|
|
||||||
delivery_terms: "Delivery terms:",
|
|
||||||
payment_terms: "Payment terms:",
|
|
||||||
issued_by: "Issued by:",
|
issued_by: "Issued by:",
|
||||||
ico: "Reg. No.: ",
|
ico: "Reg. No.: ",
|
||||||
dic: "Tax ID: ",
|
dic: "Tax ID: ",
|
||||||
@@ -195,10 +189,6 @@ interface IssuedOrderPdfData {
|
|||||||
order_date: Date | null;
|
order_date: Date | null;
|
||||||
delivery_date: Date | null;
|
delivery_date: Date | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
notes: string | null;
|
|
||||||
delivery_terms: string | null;
|
|
||||||
payment_terms: string | null;
|
|
||||||
issued_by: string | null;
|
|
||||||
// Editable heading above the items table; null → t.billing default.
|
// Editable heading above the items table; null → t.billing default.
|
||||||
order_text?: string | null;
|
order_text?: string | null;
|
||||||
}
|
}
|
||||||
@@ -287,30 +277,9 @@ export function renderIssuedOrderHtml(
|
|||||||
|
|
||||||
total = Math.round(total * 100) / 100;
|
total = Math.round(total * 100) / 100;
|
||||||
|
|
||||||
const notesRaw = order.notes ?? "";
|
|
||||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
|
||||||
const notesHtml = notesStripped
|
|
||||||
? `
|
|
||||||
<div class="invoice-notes">
|
|
||||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
|
||||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const issueDateStr = formatDate(order.order_date);
|
const issueDateStr = formatDate(order.order_date);
|
||||||
const deliveryDateStr = formatDate(order.delivery_date);
|
const deliveryDateStr = formatDate(order.delivery_date);
|
||||||
|
|
||||||
// Order-specific terms (purchase-order addition; not on invoices).
|
|
||||||
let termsHtml = "";
|
|
||||||
if (order.delivery_terms) {
|
|
||||||
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.delivery_terms)}</span> ${escapeHtml(order.delivery_terms)}</div>`;
|
|
||||||
}
|
|
||||||
if (order.payment_terms) {
|
|
||||||
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.payment_terms)}</span> ${escapeHtml(order.payment_terms)}</div>`;
|
|
||||||
}
|
|
||||||
const termsBlock = termsHtml ? `<div class="terms">${termsHtml}</div>` : "";
|
|
||||||
|
|
||||||
// Quill indent CSS
|
// Quill indent CSS
|
||||||
const indentCSS = buildQuillIndentCss();
|
const indentCSS = buildQuillIndentCss();
|
||||||
|
|
||||||
@@ -592,19 +561,6 @@ export function renderIssuedOrderHtml(
|
|||||||
border-bottom: 2.5pt solid #de3a3a;
|
border-bottom: 2.5pt solid #de3a3a;
|
||||||
padding-bottom: 1mm;
|
padding-bottom: 1mm;
|
||||||
}
|
}
|
||||||
/* Dodaci / platebni podminky (PO-specificke) */
|
|
||||||
.terms {
|
|
||||||
margin-top: 4mm;
|
|
||||||
font-size: 9pt;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
.terms-row { margin-bottom: 1mm; }
|
|
||||||
.terms-row .lbl {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Vystavil */
|
/* Vystavil */
|
||||||
.issued-by {
|
.issued-by {
|
||||||
font-size: 9pt;
|
font-size: 9pt;
|
||||||
@@ -621,33 +577,6 @@ export function renderIssuedOrderHtml(
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Poznamky */
|
|
||||||
.invoice-notes {
|
|
||||||
margin-top: 4mm;
|
|
||||||
font-size: 10pt;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
.invoice-notes-label {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 9pt;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #555;
|
|
||||||
margin-bottom: 1mm;
|
|
||||||
}
|
|
||||||
.invoice-notes-content p { margin: 0 0 0.4em 0; }
|
|
||||||
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
|
|
||||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
|
||||||
.invoice-notes-content,
|
|
||||||
.invoice-notes-content * {
|
|
||||||
font-family: Tahoma, sans-serif !important;
|
|
||||||
}
|
|
||||||
.invoice-notes-content { font-size: 14px; }
|
|
||||||
.invoice-notes-content h1 { font-size: 20px; }
|
|
||||||
.invoice-notes-content h2 { font-size: 18px; }
|
|
||||||
.invoice-notes-content h3 { font-size: 16px; }
|
|
||||||
.invoice-notes-content h4 { font-size: 15px; }
|
|
||||||
|
|
||||||
/* Sekce (scope) na vlastni strance — zrcadli offers-pdf .scope-page,
|
/* Sekce (scope) na vlastni strance — zrcadli offers-pdf .scope-page,
|
||||||
hlavicka se opakuje v cervenem stylu teto sablony */
|
hlavicka se opakuje v cervenem stylu teto sablony */
|
||||||
.scope-page {
|
.scope-page {
|
||||||
@@ -791,10 +720,6 @@ ${indentCSS}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${notesHtml}
|
|
||||||
|
|
||||||
${termsBlock}
|
|
||||||
|
|
||||||
</div><!-- /.invoice-content -->
|
</div><!-- /.invoice-content -->
|
||||||
<div class="invoice-footer">
|
<div class="invoice-footer">
|
||||||
|
|
||||||
|
|||||||
@@ -33,13 +33,9 @@ export const CreateIssuedOrderSchema = z.object({
|
|||||||
order_date: isoDateString.nullish(),
|
order_date: isoDateString.nullish(),
|
||||||
delivery_date: isoDateString.nullish(),
|
delivery_date: isoDateString.nullish(),
|
||||||
language: z.string().max(5).optional(),
|
language: z.string().max(5).optional(),
|
||||||
delivery_terms: z.string().max(500).nullish(),
|
|
||||||
payment_terms: z.string().max(500).nullish(),
|
|
||||||
issued_by: z.string().max(255).nullish(),
|
|
||||||
// Editable heading above the PDF items table; empty/null falls back to the
|
// 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).
|
// default "Objednáváme si u Vás:" (issued-orders-pdf t.billing).
|
||||||
order_text: z.string().max(500).nullish(),
|
order_text: z.string().max(500).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(),
|
||||||
// Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers'
|
// Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers'
|
||||||
|
|||||||
@@ -35,11 +35,7 @@ export interface IssuedOrderInput {
|
|||||||
order_date?: string | null;
|
order_date?: string | null;
|
||||||
delivery_date?: string | null;
|
delivery_date?: string | null;
|
||||||
language?: string;
|
language?: string;
|
||||||
delivery_terms?: string | null;
|
|
||||||
payment_terms?: string | null;
|
|
||||||
issued_by?: string | null;
|
|
||||||
order_text?: string | null;
|
order_text?: string | null;
|
||||||
notes?: string | null;
|
|
||||||
internal_notes?: string | null;
|
internal_notes?: string | null;
|
||||||
items?: IssuedOrderItemInput[];
|
items?: IssuedOrderItemInput[];
|
||||||
sections?: IssuedOrderSectionInput[];
|
sections?: IssuedOrderSectionInput[];
|
||||||
@@ -127,11 +123,7 @@ const NON_STATUS_UPDATE_FIELDS = [
|
|||||||
"order_date",
|
"order_date",
|
||||||
"delivery_date",
|
"delivery_date",
|
||||||
"language",
|
"language",
|
||||||
"delivery_terms",
|
|
||||||
"payment_terms",
|
|
||||||
"issued_by",
|
|
||||||
"order_text",
|
"order_text",
|
||||||
"notes",
|
|
||||||
"internal_notes",
|
"internal_notes",
|
||||||
"items",
|
"items",
|
||||||
"sections",
|
"sections",
|
||||||
@@ -322,13 +314,7 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
? new Date(String(body.delivery_date))
|
? new Date(String(body.delivery_date))
|
||||||
: null,
|
: null,
|
||||||
language: body.language ? String(body.language) : "cs",
|
language: body.language ? String(body.language) : "cs",
|
||||||
delivery_terms: body.delivery_terms
|
|
||||||
? String(body.delivery_terms)
|
|
||||||
: null,
|
|
||||||
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
|
||||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
|
||||||
order_text: body.order_text ? String(body.order_text) : null,
|
order_text: body.order_text ? String(body.order_text) : 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)
|
||||||
: null,
|
: null,
|
||||||
@@ -406,14 +392,7 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||||
|
|
||||||
if (editable) {
|
if (editable) {
|
||||||
const strFields = [
|
const strFields = ["currency", "language", "order_text"];
|
||||||
"currency",
|
|
||||||
"language",
|
|
||||||
"delivery_terms",
|
|
||||||
"payment_terms",
|
|
||||||
"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;
|
||||||
}
|
}
|
||||||
@@ -440,8 +419,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
data.delivery_date = body.delivery_date
|
data.delivery_date = body.delivery_date
|
||||||
? new Date(String(body.delivery_date))
|
? new Date(String(body.delivery_date))
|
||||||
: null;
|
: null;
|
||||||
if (body.notes !== undefined)
|
|
||||||
data.notes = body.notes ? String(body.notes) : null;
|
|
||||||
if (body.internal_notes !== undefined)
|
if (body.internal_notes !== undefined)
|
||||||
data.internal_notes = body.internal_notes
|
data.internal_notes = body.internal_notes
|
||||||
? String(body.internal_notes)
|
? String(body.internal_notes)
|
||||||
|
|||||||
Reference in New Issue
Block a user