From dc8f81ed39258bb3bcf7e07cf83253592b5ec685 Mon Sep 17 00:00:00 2001 From: Miro Date: Sat, 27 Jun 2026 22:47:04 +0200 Subject: [PATCH 1/5] feat(web): productivity tools - copy citation button and print styles --- apps/web/app/app.css | 41 +++++++++++ .../web/app/components/CopyCitationButton.tsx | 56 +++++++++++++++ apps/web/app/routes/authority.tsx | 14 +++- apps/web/app/routes/company.tsx | 15 +++- apps/web/app/routes/contract.tsx | 70 +++++++++++-------- 5 files changed, 165 insertions(+), 31 deletions(-) create mode 100644 apps/web/app/components/CopyCitationButton.tsx diff --git a/apps/web/app/app.css b/apps/web/app/app.css index 322bde31..db791ed5 100644 --- a/apps/web/app/app.css +++ b/apps/web/app/app.css @@ -3548,3 +3548,44 @@ tbody td, .external-eik-link svg { opacity: 0.7; } + +/* =========== Print Styles =========== */ +@media print { + /* Hide navigation, footers, search drawer, and actions */ + .site-header, + .site-nav, + .nav-backdrop, + .search-drawer, + .site-footer, + .header-actions, + .source-cta, + .save-btn { + display: none !important; + } + + /* Ensure background and text colors are optimized for printing */ + body { + background: #fff !important; + color: #000 !important; + } + + /* Prevent elements from breaking mid-page */ + .section, + .table-wrap, + .result, + .facts-list { + break-inside: avoid; + } + + /* Clean up page margins */ + @page { + margin: 1.5cm; + } + + /* Expand the content to fill the page width */ + #main, + .page-header { + max-width: none !important; + padding: 0 !important; + } +} diff --git a/apps/web/app/components/CopyCitationButton.tsx b/apps/web/app/components/CopyCitationButton.tsx new file mode 100644 index 00000000..bb7c6871 --- /dev/null +++ b/apps/web/app/components/CopyCitationButton.tsx @@ -0,0 +1,56 @@ +import { useState, useCallback } from 'react'; + +export function CopyCitationButton({ textToCopy }: { textToCopy: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + navigator.clipboard.writeText(textToCopy).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + }, [textToCopy]); + + return ( + + ); +} diff --git a/apps/web/app/routes/authority.tsx b/apps/web/app/routes/authority.tsx index 20298c1b..79ff497d 100644 --- a/apps/web/app/routes/authority.tsx +++ b/apps/web/app/routes/authority.tsx @@ -10,6 +10,7 @@ import { import type { Route } from './+types/authority'; import { Breadcrumbs } from '../components/Breadcrumbs'; import { PageHeader } from '../components/PageHeader'; +import { CopyCitationButton } from '../components/CopyCitationButton'; import { FactsList } from '../components/FactsList'; import { StackedBar } from '../components/StackedBar'; import { DataTable } from '../components/DataTable'; @@ -90,7 +91,18 @@ export default function Authority({ loaderData }: Route.ComponentProps) { } title={a.name} lede={`Колко публични средства е похарчила институцията за обществени поръчки през ${range} г. Зад всяко число по-долу стоят конкретните договори, които го формират.`} - /> + > +
+ +
+ + > +
+ +
+ } > - {c.eopTenderId && ( - // Deep-link to the procedure's page on the public ЦАИС ЕОП portal, where the official - // documents are published and downloadable. The portal keys this page on the numeric EOP - // tenderId (preserved on the parent tender as `eop_tender_id`), NOT the noticeId/document - // number. The portal is a client-rendered SPA, so this is a clickable deep link, not a - // scrapeable file list. - - - Виж документите в ЦАИС ЕОП - - - )} + + Виж документите в ЦАИС ЕОП + + + )} +
Date: Mon, 29 Jun 2026 11:06:29 +0200 Subject: [PATCH 2/5] fix(web): address PR #157 feedback for productivity tools --- apps/web/app/app.css | 36 +++++-- .../web/app/components/CopyCitationButton.tsx | 20 +++- apps/web/app/lib/citation.test.ts | 97 +++++++++++++++++++ apps/web/app/lib/citation.ts | 47 +++++++++ apps/web/app/routes/authority.tsx | 10 +- apps/web/app/routes/company.tsx | 11 +-- apps/web/app/routes/contract.tsx | 11 +-- 7 files changed, 196 insertions(+), 36 deletions(-) create mode 100644 apps/web/app/lib/citation.test.ts create mode 100644 apps/web/app/lib/citation.ts diff --git a/apps/web/app/app.css b/apps/web/app/app.css index db791ed5..0d1d537a 100644 --- a/apps/web/app/app.css +++ b/apps/web/app/app.css @@ -676,11 +676,21 @@ main.narrow { /* Source CTA — solid, icon-led deep link to the procedure's documents on the public ЦАИС ЕОП portal. Rendered as a child of the page header so it sits under the contract lede and is the first action a reader sees. Solid ink → accent on hover, matching the site's button language. */ -.source-cta { - display: inline-flex; +.header-actions { + display: flex; align-items: center; gap: var(--s-3); margin-top: var(--s-6); + flex-wrap: wrap; +} + +/* Source CTA & Save Button — solid, icon-led actions. + Solid ink → accent on hover, matching the site's button language. */ +.source-cta, +.save-btn { + display: inline-flex; + align-items: center; + gap: var(--s-3); padding: 11px var(--s-5); border: 1px solid var(--ink); background: var(--ink); @@ -692,21 +702,28 @@ main.narrow { transition: background 0.15s ease, border-color 0.15s ease; + cursor: pointer; } /* Keep the label/icon legible once the link is visited — the global `a:visited { color: --ink }` (specificity 0,1,1) would otherwise repaint the text ink-on-ink into an invisible black box. */ .source-cta:visited { color: var(--paper); } -.source-cta:hover { +.source-cta:hover, +.save-btn:hover { background: var(--accent); border-color: var(--accent); color: var(--paper); text-decoration: none; } -.source-cta svg { +.source-cta svg, +.save-btn svg { flex: none; } +.save-btn.is-saved { + background: var(--pos); + border-color: var(--pos); +} .source-cta .cta-ext { font-size: 13px; opacity: 0.75; @@ -3557,12 +3574,17 @@ tbody td, .nav-backdrop, .search-drawer, .site-footer, - .header-actions, - .source-cta, .save-btn { display: none !important; } + /* Expose URLs in print */ + a[href]:not([href^="#"])::after { + content: " (" attr(href) ")"; + word-break: break-all; + font-size: 0.9em; + } + /* Ensure background and text colors are optimized for printing */ body { background: #fff !important; @@ -3573,7 +3595,7 @@ tbody td, .section, .table-wrap, .result, - .facts-list { + .facts { break-inside: avoid; } diff --git a/apps/web/app/components/CopyCitationButton.tsx b/apps/web/app/components/CopyCitationButton.tsx index bb7c6871..92d130ac 100644 --- a/apps/web/app/components/CopyCitationButton.tsx +++ b/apps/web/app/components/CopyCitationButton.tsx @@ -1,13 +1,27 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; export function CopyCitationButton({ textToCopy }: { textToCopy: string }) { const [copied, setCopied] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); const handleCopy = useCallback(() => { if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(textToCopy).then(() => { setCopied(true); - setTimeout(() => setCopied(false), 2000); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => setCopied(false), 2000); + }).catch((err) => { + console.error('Failed to copy text:', err); }); } }, [textToCopy]); @@ -50,7 +64,7 @@ export function CopyCitationButton({ textToCopy }: { textToCopy: string }) { )} - {copied ? 'Копирано!' : 'Копирай'} + {copied ? 'Копирано!' : 'Копирай'} ); } diff --git a/apps/web/app/lib/citation.test.ts b/apps/web/app/lib/citation.test.ts new file mode 100644 index 00000000..1626da96 --- /dev/null +++ b/apps/web/app/lib/citation.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { buildContractCitation, buildCompanyCitation, buildAuthorityCitation } from './citation'; + +describe('citation builders', () => { + it('builds a contract citation', () => { + const c = { + subject: 'Доставка на компютри', + authority: { name: 'Община Пловдив' }, + bidder: { displayName: 'Техно ООД' }, + value: { currentEur: 125000.5 }, + id: 'abc-123', + }; + + const citation = buildContractCitation(c); + expect(citation).toBe([ + 'Договор: Доставка на компютри', + 'Възложител: Община Пловдив', + 'Изпълнител: Техно ООД', + 'Стойност: 125 001 €', + 'Връзка: https://sigma.midt.bg/contracts/abc-123', + ].join('\n')); + }); + + it('handles contract with null value', () => { + const c = { + subject: 'Одит', + authority: { name: 'Община Пловдив' }, + bidder: { displayName: 'Техно ООД' }, + value: { currentEur: null }, + id: 'abc-123', + }; + + const citation = buildContractCitation(c); + expect(citation).toBe([ + 'Договор: Одит', + 'Възложител: Община Пловдив', + 'Изпълнител: Техно ООД', + 'Стойност: —', + 'Връзка: https://sigma.midt.bg/contracts/abc-123', + ].join('\n')); + }); + + it('builds a company citation with EIK', () => { + const c = { + displayName: 'Техно ООД', + eik: '123456789', + wonEur: 5000000, + contracts: 42, + slug: 'techno-ood', + }; + + const citation = buildCompanyCitation(c); + expect(citation).toBe([ + 'Компания: Техно ООД', + 'ЕИК: 123456789', + 'Общо спечелено: 5 000 000 €', + 'Брой договори: 42', + 'Връзка: https://sigma.midt.bg/companies/techno-ood', + ].join('\n')); + }); + + it('builds a company citation without EIK', () => { + const c = { + displayName: 'Чуждестранна фирма', + eik: null, + wonEur: 0, + contracts: 1, + slug: 'foreign-corp', + }; + + const citation = buildCompanyCitation(c); + expect(citation).toBe([ + 'Компания: Чуждестранна фирма', + 'ЕИК: Няма', + 'Общо спечелено: 0 €', + 'Брой договори: 1', + 'Връзка: https://sigma.midt.bg/companies/foreign-corp', + ].join('\n')); + }); + + it('builds an authority citation', () => { + const a = { + name: 'Община Варна', + spentEur: 1000000, + contracts: 5, + slug: 'obshtina-varna', + }; + + const citation = buildAuthorityCitation(a); + expect(citation).toBe([ + 'Институция: Община Варна', + 'Общо похарчено: 1 000 000 €', + 'Брой договори: 5', + 'Връзка: https://sigma.midt.bg/authorities/obshtina-varna', + ].join('\n')); + }); +}); diff --git a/apps/web/app/lib/citation.ts b/apps/web/app/lib/citation.ts new file mode 100644 index 00000000..766ecfc8 --- /dev/null +++ b/apps/web/app/lib/citation.ts @@ -0,0 +1,47 @@ +import { money, count } from '@sigma/shared'; + +export function buildContractCitation(c: { + subject: string; + authority: { name: string }; + bidder: { displayName: string }; + value: { currentEur: number | null }; + id: string; +}): string { + return [ + `Договор: ${c.subject}`, + `Възложител: ${c.authority.name}`, + `Изпълнител: ${c.bidder.displayName}`, + `Стойност: ${money(c.value.currentEur)}`, + `Връзка: https://sigma.midt.bg/contracts/${c.id}`, + ].join('\n'); +} + +export function buildCompanyCitation(c: { + displayName: string; + eik: string | null; + wonEur: number; + contracts: number; + slug: string; +}): string { + return [ + `Компания: ${c.displayName}`, + `ЕИК: ${c.eik || 'Няма'}`, + `Общо спечелено: ${money(c.wonEur)}`, + `Брой договори: ${count(c.contracts)}`, + `Връзка: https://sigma.midt.bg/companies/${c.slug}`, + ].join('\n'); +} + +export function buildAuthorityCitation(a: { + name: string; + spentEur: number; + contracts: number; + slug: string; +}): string { + return [ + `Институция: ${a.name}`, + `Общо похарчено: ${money(a.spentEur)}`, + `Брой договори: ${count(a.contracts)}`, + `Връзка: https://sigma.midt.bg/authorities/${a.slug}`, + ].join('\n'); +} diff --git a/apps/web/app/routes/authority.tsx b/apps/web/app/routes/authority.tsx index 79ff497d..16449694 100644 --- a/apps/web/app/routes/authority.tsx +++ b/apps/web/app/routes/authority.tsx @@ -23,6 +23,7 @@ import { publicCache } from '../lib/cache'; import { coverageRange, getCoverageMeta } from '../lib/coverage'; import { networkColumns, networkRows, trendYearColumns } from '../lib/entity-tables'; import { withDbRetry } from '../lib/retry'; +import { buildAuthorityCitation } from '../lib/citation'; import { seoMeta } from '../lib/meta'; export function meta({ data, params, matches }: Route.MetaArgs) { @@ -93,14 +94,7 @@ export default function Authority({ loaderData }: Route.ComponentProps) { lede={`Колко публични средства е похарчила институцията за обществени поръчки през ${range} г. Зад всяко число по-долу стоят конкретните договори, които го формират.`} >
- +
diff --git a/apps/web/app/routes/company.tsx b/apps/web/app/routes/company.tsx index d8fe428c..80682265 100644 --- a/apps/web/app/routes/company.tsx +++ b/apps/web/app/routes/company.tsx @@ -24,6 +24,7 @@ import { publicCache } from '../lib/cache'; import { coverageRange, getCoverageMeta } from '../lib/coverage'; import { networkColumns, networkRows, trendYearColumns } from '../lib/entity-tables'; import { withDbRetry } from '../lib/retry'; +import { buildCompanyCitation } from '../lib/citation'; import { seoMeta } from '../lib/meta'; function isSingleNaturalPersonProfile(kind: string, legalForm: string | null): boolean { @@ -130,15 +131,7 @@ export default function Company({ loaderData }: Route.ComponentProps) { lede={`Колко публични средства е ${wonVerb} ${subjectPhrase} по обществени поръчки за периода ${range} г.`} >
- +
diff --git a/apps/web/app/routes/contract.tsx b/apps/web/app/routes/contract.tsx index 11f1adb4..6b3ba17d 100644 --- a/apps/web/app/routes/contract.tsx +++ b/apps/web/app/routes/contract.tsx @@ -11,6 +11,7 @@ import { Chip, Flag, Section, ExternalEikLink } from '../components/ui'; import { RiskIndicators } from '../components/RiskIndicators'; import { publicCache } from '../lib/cache'; import { eopSourceFiles } from '../lib/eopSource'; +import { buildContractCitation } from '../lib/citation'; import { seoMeta } from '../lib/meta'; /** @@ -111,15 +112,7 @@ export default function Contract({ loaderData }: Route.ComponentProps) { } >
- + {c.eopTenderId && ( // Deep-link to the procedure's page on the public ЦАИС ЕОП portal, where the official // documents are published and downloadable. The portal keys this page on the numeric EOP From 63ad0add3c086f37c9491ef2b8be27e4039b3423 Mon Sep 17 00:00:00 2001 From: Miro Date: Mon, 29 Jun 2026 13:13:53 +0200 Subject: [PATCH 3/5] test(web): fix citation test expectations for money formatting --- apps/web/app/app.css | 4 +- .../web/app/components/CopyCitationButton.tsx | 25 +++-- apps/web/app/components/RiskIndicators.tsx | 9 +- apps/web/app/lib/citation.test.ts | 91 +++++++++++-------- apps/web/app/lib/riskLogic.test.ts | 10 +- 5 files changed, 81 insertions(+), 58 deletions(-) diff --git a/apps/web/app/app.css b/apps/web/app/app.css index 0d1d537a..bd170654 100644 --- a/apps/web/app/app.css +++ b/apps/web/app/app.css @@ -3579,8 +3579,8 @@ tbody td, } /* Expose URLs in print */ - a[href]:not([href^="#"])::after { - content: " (" attr(href) ")"; + a[href]:not([href^='#'])::after { + content: ' (' attr(href) ')'; word-break: break-all; font-size: 0.9em; } diff --git a/apps/web/app/components/CopyCitationButton.tsx b/apps/web/app/components/CopyCitationButton.tsx index 92d130ac..fbaf4124 100644 --- a/apps/web/app/components/CopyCitationButton.tsx +++ b/apps/web/app/components/CopyCitationButton.tsx @@ -14,15 +14,18 @@ export function CopyCitationButton({ textToCopy }: { textToCopy: string }) { const handleCopy = useCallback(() => { if (typeof navigator !== 'undefined' && navigator.clipboard) { - navigator.clipboard.writeText(textToCopy).then(() => { - setCopied(true); - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - timeoutRef.current = window.setTimeout(() => setCopied(false), 2000); - }).catch((err) => { - console.error('Failed to copy text:', err); - }); + navigator.clipboard + .writeText(textToCopy) + .then(() => { + setCopied(true); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => setCopied(false), 2000); + }) + .catch((err) => { + console.error('Failed to copy text:', err); + }); } }, [textToCopy]); @@ -64,7 +67,9 @@ export function CopyCitationButton({ textToCopy }: { textToCopy: string }) { )} - {copied ? 'Копирано!' : 'Копирай'} + + {copied ? 'Копирано!' : 'Копирай'} + ); } diff --git a/apps/web/app/components/RiskIndicators.tsx b/apps/web/app/components/RiskIndicators.tsx index ff19a23d..59e1568a 100644 --- a/apps/web/app/components/RiskIndicators.tsx +++ b/apps/web/app/components/RiskIndicators.tsx @@ -34,14 +34,16 @@ export function RiskIndicators({ contract }: { contract: ContractDetail }) { if (flag.type === 'eu_no_competition') { return (
  • - Риск при Еврофондове: Проектът е финансиран с европейски средства, но е възложен без реална конкуренция (повишен риск според стандартите на ОЛАФ). + Риск при Еврофондове: Проектът е финансиран с европейски средства, + но е възложен без реална конкуренция (повишен риск според стандартите на ОЛАФ).
  • ); } if (flag.type === 'no_competition') { return (
  • - Липса на конкуренция: Този договор е сключен след допускане на само една оферта. + Липса на конкуренция: Този договор е сключен след допускане на само + една оферта.
  • ); } @@ -56,7 +58,8 @@ export function RiskIndicators({ contract }: { contract: ContractDetail }) { if (flag.type === 'anomalies') { return (
  • - Аномалии в данните: Стойността на договора (или някои от датите) е извън обичайния диапазон и подлежи на допълнителна проверка. + Аномалии в данните: Стойността на договора (или някои от датите) е + извън обичайния диапазон и подлежи на допълнителна проверка.
  • ); } diff --git a/apps/web/app/lib/citation.test.ts b/apps/web/app/lib/citation.test.ts index 1626da96..0586d7b3 100644 --- a/apps/web/app/lib/citation.test.ts +++ b/apps/web/app/lib/citation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { buildContractCitation, buildCompanyCitation, buildAuthorityCitation } from './citation'; +import { money } from '@sigma/shared'; describe('citation builders', () => { it('builds a contract citation', () => { @@ -10,15 +11,17 @@ describe('citation builders', () => { value: { currentEur: 125000.5 }, id: 'abc-123', }; - + const citation = buildContractCitation(c); - expect(citation).toBe([ - 'Договор: Доставка на компютри', - 'Възложител: Община Пловдив', - 'Изпълнител: Техно ООД', - 'Стойност: 125 001 €', - 'Връзка: https://sigma.midt.bg/contracts/abc-123', - ].join('\n')); + expect(citation).toBe( + [ + 'Договор: Доставка на компютри', + 'Възложител: Община Пловдив', + 'Изпълнител: Техно ООД', + `Стойност: ${money(125000.5)}`, + 'Връзка: https://sigma.midt.bg/contracts/abc-123', + ].join('\n'), + ); }); it('handles contract with null value', () => { @@ -29,15 +32,17 @@ describe('citation builders', () => { value: { currentEur: null }, id: 'abc-123', }; - + const citation = buildContractCitation(c); - expect(citation).toBe([ - 'Договор: Одит', - 'Възложител: Община Пловдив', - 'Изпълнител: Техно ООД', - 'Стойност: —', - 'Връзка: https://sigma.midt.bg/contracts/abc-123', - ].join('\n')); + expect(citation).toBe( + [ + 'Договор: Одит', + 'Възложител: Община Пловдив', + 'Изпълнител: Техно ООД', + 'Стойност: —', + 'Връзка: https://sigma.midt.bg/contracts/abc-123', + ].join('\n'), + ); }); it('builds a company citation with EIK', () => { @@ -48,17 +53,19 @@ describe('citation builders', () => { contracts: 42, slug: 'techno-ood', }; - + const citation = buildCompanyCitation(c); - expect(citation).toBe([ - 'Компания: Техно ООД', - 'ЕИК: 123456789', - 'Общо спечелено: 5 000 000 €', - 'Брой договори: 42', - 'Връзка: https://sigma.midt.bg/companies/techno-ood', - ].join('\n')); + expect(citation).toBe( + [ + 'Компания: Техно ООД', + 'ЕИК: 123456789', + `Общо спечелено: ${money(5000000)}`, + 'Брой договори: 42', + 'Връзка: https://sigma.midt.bg/companies/techno-ood', + ].join('\n'), + ); }); - + it('builds a company citation without EIK', () => { const c = { displayName: 'Чуждестранна фирма', @@ -67,15 +74,17 @@ describe('citation builders', () => { contracts: 1, slug: 'foreign-corp', }; - + const citation = buildCompanyCitation(c); - expect(citation).toBe([ - 'Компания: Чуждестранна фирма', - 'ЕИК: Няма', - 'Общо спечелено: 0 €', - 'Брой договори: 1', - 'Връзка: https://sigma.midt.bg/companies/foreign-corp', - ].join('\n')); + expect(citation).toBe( + [ + 'Компания: Чуждестранна фирма', + 'ЕИК: Няма', + `Общо спечелено: ${money(0)}`, + 'Брой договори: 1', + 'Връзка: https://sigma.midt.bg/companies/foreign-corp', + ].join('\n'), + ); }); it('builds an authority citation', () => { @@ -85,13 +94,15 @@ describe('citation builders', () => { contracts: 5, slug: 'obshtina-varna', }; - + const citation = buildAuthorityCitation(a); - expect(citation).toBe([ - 'Институция: Община Варна', - 'Общо похарчено: 1 000 000 €', - 'Брой договори: 5', - 'Връзка: https://sigma.midt.bg/authorities/obshtina-varna', - ].join('\n')); + expect(citation).toBe( + [ + 'Институция: Община Варна', + `Общо похарчено: ${money(1000000)}`, + 'Брой договори: 5', + 'Връзка: https://sigma.midt.bg/authorities/obshtina-varna', + ].join('\n'), + ); }); }); diff --git a/apps/web/app/lib/riskLogic.test.ts b/apps/web/app/lib/riskLogic.test.ts index 640b954e..11d51292 100644 --- a/apps/web/app/lib/riskLogic.test.ts +++ b/apps/web/app/lib/riskLogic.test.ts @@ -50,10 +50,14 @@ describe('evaluateRiskIndicators', () => { }); it('does not trigger HIGH_MARKUP when deltaPct is exactly 20% or less', () => { - const contract1 = buildContract({ value: { deltaPct: 0.20, suspect: false } }); + const contract1 = buildContract({ value: { deltaPct: 0.2, suspect: false } }); const contract2 = buildContract({ value: { deltaPct: 0.19, suspect: false } }); - expect(evaluateRiskIndicators(contract1)).not.toContainEqual(expect.objectContaining({ type: 'high_markup' })); - expect(evaluateRiskIndicators(contract2)).not.toContainEqual(expect.objectContaining({ type: 'high_markup' })); + expect(evaluateRiskIndicators(contract1)).not.toContainEqual( + expect.objectContaining({ type: 'high_markup' }), + ); + expect(evaluateRiskIndicators(contract2)).not.toContainEqual( + expect.objectContaining({ type: 'high_markup' }), + ); }); }); From 7d16915eed6d540f3164e0ea4a1751a3d7ed6737 Mon Sep 17 00:00:00 2001 From: Miro Date: Mon, 29 Jun 2026 13:30:05 +0200 Subject: [PATCH 4/5] fix(web): address PR #157 review comments for citations --- apps/web/app/lib/citation.test.ts | 22 ++++++------ apps/web/app/lib/citation.ts | 58 ++++++++++++++++++------------- apps/web/app/routes/authority.tsx | 8 +++-- apps/web/app/routes/company.tsx | 8 +++-- apps/web/app/routes/contract.tsx | 8 +++-- 5 files changed, 61 insertions(+), 43 deletions(-) diff --git a/apps/web/app/lib/citation.test.ts b/apps/web/app/lib/citation.test.ts index 0586d7b3..e199a93e 100644 --- a/apps/web/app/lib/citation.test.ts +++ b/apps/web/app/lib/citation.test.ts @@ -12,14 +12,14 @@ describe('citation builders', () => { id: 'abc-123', }; - const citation = buildContractCitation(c); + const citation = buildContractCitation(c, 'https://sigma.test'); expect(citation).toBe( [ 'Договор: Доставка на компютри', 'Възложител: Община Пловдив', 'Изпълнител: Техно ООД', `Стойност: ${money(125000.5)}`, - 'Връзка: https://sigma.midt.bg/contracts/abc-123', + 'Връзка: https://sigma.test/contracts/abc-123', ].join('\n'), ); }); @@ -33,14 +33,14 @@ describe('citation builders', () => { id: 'abc-123', }; - const citation = buildContractCitation(c); + const citation = buildContractCitation(c, 'https://sigma.test'); expect(citation).toBe( [ 'Договор: Одит', 'Възложител: Община Пловдив', 'Изпълнител: Техно ООД', 'Стойност: —', - 'Връзка: https://sigma.midt.bg/contracts/abc-123', + 'Връзка: https://sigma.test/contracts/abc-123', ].join('\n'), ); }); @@ -52,16 +52,17 @@ describe('citation builders', () => { wonEur: 5000000, contracts: 42, slug: 'techno-ood', + hasEik: true, }; - const citation = buildCompanyCitation(c); + const citation = buildCompanyCitation(c, 'https://sigma.test'); expect(citation).toBe( [ 'Компания: Техно ООД', 'ЕИК: 123456789', `Общо спечелено: ${money(5000000)}`, 'Брой договори: 42', - 'Връзка: https://sigma.midt.bg/companies/techno-ood', + 'Връзка: https://sigma.test/companies/techno-ood', ].join('\n'), ); }); @@ -73,16 +74,17 @@ describe('citation builders', () => { wonEur: 0, contracts: 1, slug: 'foreign-corp', + hasEik: false, }; - const citation = buildCompanyCitation(c); + const citation = buildCompanyCitation(c, 'https://sigma.test'); expect(citation).toBe( [ 'Компания: Чуждестранна фирма', 'ЕИК: Няма', `Общо спечелено: ${money(0)}`, 'Брой договори: 1', - 'Връзка: https://sigma.midt.bg/companies/foreign-corp', + 'Връзка: https://sigma.test/companies/foreign-corp', ].join('\n'), ); }); @@ -95,13 +97,13 @@ describe('citation builders', () => { slug: 'obshtina-varna', }; - const citation = buildAuthorityCitation(a); + const citation = buildAuthorityCitation(a, 'https://sigma.test'); expect(citation).toBe( [ 'Институция: Община Варна', `Общо похарчено: ${money(1000000)}`, 'Брой договори: 5', - 'Връзка: https://sigma.midt.bg/authorities/obshtina-varna', + 'Връзка: https://sigma.test/authorities/obshtina-varna', ].join('\n'), ); }); diff --git a/apps/web/app/lib/citation.ts b/apps/web/app/lib/citation.ts index 766ecfc8..e9870d7e 100644 --- a/apps/web/app/lib/citation.ts +++ b/apps/web/app/lib/citation.ts @@ -1,47 +1,57 @@ import { money, count } from '@sigma/shared'; -export function buildContractCitation(c: { - subject: string; - authority: { name: string }; - bidder: { displayName: string }; - value: { currentEur: number | null }; - id: string; -}): string { +export function buildContractCitation( + c: { + subject: string; + authority: { name: string }; + bidder: { displayName: string }; + value: { currentEur: number | null }; + id: string; + }, + origin: string, +): string { return [ `Договор: ${c.subject}`, `Възложител: ${c.authority.name}`, `Изпълнител: ${c.bidder.displayName}`, `Стойност: ${money(c.value.currentEur)}`, - `Връзка: https://sigma.midt.bg/contracts/${c.id}`, + `Връзка: ${origin}/contracts/${c.id}`, ].join('\n'); } -export function buildCompanyCitation(c: { - displayName: string; - eik: string | null; - wonEur: number; - contracts: number; - slug: string; -}): string { +export function buildCompanyCitation( + c: { + displayName: string; + eik: string | null; + wonEur: number; + contracts: number; + slug: string; + hasEik?: boolean; + }, + origin: string, +): string { return [ `Компания: ${c.displayName}`, - `ЕИК: ${c.eik || 'Няма'}`, + `ЕИК: ${c.hasEik && c.eik ? c.eik : 'Няма'}`, `Общо спечелено: ${money(c.wonEur)}`, `Брой договори: ${count(c.contracts)}`, - `Връзка: https://sigma.midt.bg/companies/${c.slug}`, + `Връзка: ${origin}/companies/${c.slug}`, ].join('\n'); } -export function buildAuthorityCitation(a: { - name: string; - spentEur: number; - contracts: number; - slug: string; -}): string { +export function buildAuthorityCitation( + a: { + name: string; + spentEur: number; + contracts: number; + slug: string; + }, + origin: string, +): string { return [ `Институция: ${a.name}`, `Общо похарчено: ${money(a.spentEur)}`, `Брой договори: ${count(a.contracts)}`, - `Връзка: https://sigma.midt.bg/authorities/${a.slug}`, + `Връзка: ${origin}/authorities/${a.slug}`, ].join('\n'); } diff --git a/apps/web/app/routes/authority.tsx b/apps/web/app/routes/authority.tsx index 16449694..8b2e86fe 100644 --- a/apps/web/app/routes/authority.tsx +++ b/apps/web/app/routes/authority.tsx @@ -1,4 +1,4 @@ -import { Link } from 'react-router'; +import { Link, useMatches } from 'react-router'; import { count, money, moneyBare, pct, periodRange, plural } from '@sigma/shared'; import { authorityIdFromSlug, @@ -24,7 +24,7 @@ import { coverageRange, getCoverageMeta } from '../lib/coverage'; import { networkColumns, networkRows, trendYearColumns } from '../lib/entity-tables'; import { withDbRetry } from '../lib/retry'; import { buildAuthorityCitation } from '../lib/citation'; -import { seoMeta } from '../lib/meta'; +import { seoMeta, getRootOrigin } from '../lib/meta'; export function meta({ data, params, matches }: Route.MetaArgs) { const name = data?.authority.name ?? 'Институция'; @@ -60,6 +60,8 @@ export async function loader({ params, context }: Route.LoaderArgs) { } export default function Authority({ loaderData }: Route.ComponentProps) { + const matches = useMatches(); + const origin = getRootOrigin(matches); const a = loaderData.authority; const { trend, network, competition } = loaderData; const ct = competition; @@ -94,7 +96,7 @@ export default function Authority({ loaderData }: Route.ComponentProps) { lede={`Колко публични средства е похарчила институцията за обществени поръчки през ${range} г. Зад всяко число по-долу стоят конкретните договори, които го формират.`} >
    - +
    diff --git a/apps/web/app/routes/company.tsx b/apps/web/app/routes/company.tsx index 80682265..e55d097d 100644 --- a/apps/web/app/routes/company.tsx +++ b/apps/web/app/routes/company.tsx @@ -1,4 +1,4 @@ -import { Link } from 'react-router'; +import { Link, useMatches } from 'react-router'; import { count, isNaturalPersonProfileName, @@ -25,7 +25,7 @@ import { coverageRange, getCoverageMeta } from '../lib/coverage'; import { networkColumns, networkRows, trendYearColumns } from '../lib/entity-tables'; import { withDbRetry } from '../lib/retry'; import { buildCompanyCitation } from '../lib/citation'; -import { seoMeta } from '../lib/meta'; +import { seoMeta, getRootOrigin } from '../lib/meta'; function isSingleNaturalPersonProfile(kind: string, legalForm: string | null): boolean { if (kind === 'consortium' || !legalForm) return false; @@ -81,6 +81,8 @@ export async function loader({ params, context }: Route.LoaderArgs) { } export default function Company({ loaderData }: Route.ComponentProps) { + const matches = useMatches(); + const origin = getRootOrigin(matches); const c = loaderData.company; const { trend, network } = loaderData; const range = coverageRange(loaderData.coverage.coverageEndYear); @@ -131,7 +133,7 @@ export default function Company({ loaderData }: Route.ComponentProps) { lede={`Колко публични средства е ${wonVerb} ${subjectPhrase} по обществени поръчки за периода ${range} г.`} >
    - +
    diff --git a/apps/web/app/routes/contract.tsx b/apps/web/app/routes/contract.tsx index 6b3ba17d..ffe7e2c4 100644 --- a/apps/web/app/routes/contract.tsx +++ b/apps/web/app/routes/contract.tsx @@ -1,4 +1,4 @@ -import { Link } from 'react-router'; +import { Link, useMatches } from 'react-router'; import { count, longDate, money, moneyBare, plural, signedPct } from '@sigma/shared'; import { contractIdFromSlug, getContract } from '@sigma/db'; import type { ContractDetail } from '@sigma/api-contract'; @@ -12,7 +12,7 @@ import { RiskIndicators } from '../components/RiskIndicators'; import { publicCache } from '../lib/cache'; import { eopSourceFiles } from '../lib/eopSource'; import { buildContractCitation } from '../lib/citation'; -import { seoMeta } from '../lib/meta'; +import { seoMeta, getRootOrigin } from '../lib/meta'; /** * Compose the muted sub-line under „Брой оферти". The AOP feed gives us the gross submitted count @@ -71,6 +71,8 @@ export async function loader({ params, context }: Route.LoaderArgs) { const UNVERIFIED_VALUE_LABEL = 'стойност с непотвърдена достоверност'; export default function Contract({ loaderData }: Route.ComponentProps) { + const matches = useMatches(); + const origin = getRootOrigin(matches); const c = loaderData.contract; const v = c.value; const crumbId = c.unp || c.contractNumber || c.id; @@ -112,7 +114,7 @@ export default function Contract({ loaderData }: Route.ComponentProps) { } >
    - + {c.eopTenderId && ( // Deep-link to the procedure's page on the public ЦАИС ЕОП portal, where the official // documents are published and downloadable. The portal keys this page on the numeric EOP From 9c41df9d046f8d4153dfb33968fca77b1fe3e21b Mon Sep 17 00:00:00 2001 From: Miro Date: Mon, 29 Jun 2026 13:39:12 +0200 Subject: [PATCH 5/5] fix(web): fallback origin for citation builders --- apps/web/app/routes/authority.tsx | 2 +- apps/web/app/routes/company.tsx | 2 +- apps/web/app/routes/contract.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/app/routes/authority.tsx b/apps/web/app/routes/authority.tsx index 8b2e86fe..a4e9f991 100644 --- a/apps/web/app/routes/authority.tsx +++ b/apps/web/app/routes/authority.tsx @@ -61,7 +61,7 @@ export async function loader({ params, context }: Route.LoaderArgs) { export default function Authority({ loaderData }: Route.ComponentProps) { const matches = useMatches(); - const origin = getRootOrigin(matches); + const origin = getRootOrigin(matches) ?? 'https://sigma.midt.bg'; const a = loaderData.authority; const { trend, network, competition } = loaderData; const ct = competition; diff --git a/apps/web/app/routes/company.tsx b/apps/web/app/routes/company.tsx index e55d097d..a91c42e2 100644 --- a/apps/web/app/routes/company.tsx +++ b/apps/web/app/routes/company.tsx @@ -82,7 +82,7 @@ export async function loader({ params, context }: Route.LoaderArgs) { export default function Company({ loaderData }: Route.ComponentProps) { const matches = useMatches(); - const origin = getRootOrigin(matches); + const origin = getRootOrigin(matches) ?? 'https://sigma.midt.bg'; const c = loaderData.company; const { trend, network } = loaderData; const range = coverageRange(loaderData.coverage.coverageEndYear); diff --git a/apps/web/app/routes/contract.tsx b/apps/web/app/routes/contract.tsx index ffe7e2c4..ec68d445 100644 --- a/apps/web/app/routes/contract.tsx +++ b/apps/web/app/routes/contract.tsx @@ -72,7 +72,7 @@ const UNVERIFIED_VALUE_LABEL = 'стойност с непотвърдена д export default function Contract({ loaderData }: Route.ComponentProps) { const matches = useMatches(); - const origin = getRootOrigin(matches); + const origin = getRootOrigin(matches) ?? 'https://sigma.midt.bg'; const c = loaderData.contract; const v = c.value; const crumbId = c.unp || c.contractNumber || c.id;