diff --git a/apps/web/app/app.css b/apps/web/app/app.css index 322bde31..bd170654 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; @@ -3548,3 +3565,49 @@ 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, + .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; + color: #000 !important; + } + + /* Prevent elements from breaking mid-page */ + .section, + .table-wrap, + .result, + .facts { + 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..fbaf4124 --- /dev/null +++ b/apps/web/app/components/CopyCitationButton.tsx @@ -0,0 +1,75 @@ +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); + 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]); + + return ( + + ); +} 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 new file mode 100644 index 00000000..e199a93e --- /dev/null +++ b/apps/web/app/lib/citation.test.ts @@ -0,0 +1,110 @@ +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', () => { + const c = { + subject: 'Доставка на компютри', + authority: { name: 'Община Пловдив' }, + bidder: { displayName: 'Техно ООД' }, + value: { currentEur: 125000.5 }, + id: 'abc-123', + }; + + const citation = buildContractCitation(c, 'https://sigma.test'); + expect(citation).toBe( + [ + 'Договор: Доставка на компютри', + 'Възложител: Община Пловдив', + 'Изпълнител: Техно ООД', + `Стойност: ${money(125000.5)}`, + 'Връзка: https://sigma.test/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, 'https://sigma.test'); + expect(citation).toBe( + [ + 'Договор: Одит', + 'Възложител: Община Пловдив', + 'Изпълнител: Техно ООД', + 'Стойност: —', + 'Връзка: https://sigma.test/contracts/abc-123', + ].join('\n'), + ); + }); + + it('builds a company citation with EIK', () => { + const c = { + displayName: 'Техно ООД', + eik: '123456789', + wonEur: 5000000, + contracts: 42, + slug: 'techno-ood', + hasEik: true, + }; + + const citation = buildCompanyCitation(c, 'https://sigma.test'); + expect(citation).toBe( + [ + 'Компания: Техно ООД', + 'ЕИК: 123456789', + `Общо спечелено: ${money(5000000)}`, + 'Брой договори: 42', + 'Връзка: https://sigma.test/companies/techno-ood', + ].join('\n'), + ); + }); + + it('builds a company citation without EIK', () => { + const c = { + displayName: 'Чуждестранна фирма', + eik: null, + wonEur: 0, + contracts: 1, + slug: 'foreign-corp', + hasEik: false, + }; + + const citation = buildCompanyCitation(c, 'https://sigma.test'); + expect(citation).toBe( + [ + 'Компания: Чуждестранна фирма', + 'ЕИК: Няма', + `Общо спечелено: ${money(0)}`, + 'Брой договори: 1', + 'Връзка: https://sigma.test/companies/foreign-corp', + ].join('\n'), + ); + }); + + it('builds an authority citation', () => { + const a = { + name: 'Община Варна', + spentEur: 1000000, + contracts: 5, + slug: 'obshtina-varna', + }; + + const citation = buildAuthorityCitation(a, 'https://sigma.test'); + expect(citation).toBe( + [ + 'Институция: Община Варна', + `Общо похарчено: ${money(1000000)}`, + 'Брой договори: 5', + 'Връзка: https://sigma.test/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..e9870d7e --- /dev/null +++ b/apps/web/app/lib/citation.ts @@ -0,0 +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; + }, + origin: string, +): string { + return [ + `Договор: ${c.subject}`, + `Възложител: ${c.authority.name}`, + `Изпълнител: ${c.bidder.displayName}`, + `Стойност: ${money(c.value.currentEur)}`, + `Връзка: ${origin}/contracts/${c.id}`, + ].join('\n'); +} + +export function buildCompanyCitation( + c: { + displayName: string; + eik: string | null; + wonEur: number; + contracts: number; + slug: string; + hasEik?: boolean; + }, + origin: string, +): string { + return [ + `Компания: ${c.displayName}`, + `ЕИК: ${c.hasEik && c.eik ? c.eik : 'Няма'}`, + `Общо спечелено: ${money(c.wonEur)}`, + `Брой договори: ${count(c.contracts)}`, + `Връзка: ${origin}/companies/${c.slug}`, + ].join('\n'); +} + +export function buildAuthorityCitation( + a: { + name: string; + spentEur: number; + contracts: number; + slug: string; + }, + origin: string, +): string { + return [ + `Институция: ${a.name}`, + `Общо похарчено: ${money(a.spentEur)}`, + `Брой договори: ${count(a.contracts)}`, + `Връзка: ${origin}/authorities/${a.slug}`, + ].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' }), + ); }); }); diff --git a/apps/web/app/routes/authority.tsx b/apps/web/app/routes/authority.tsx index 20298c1b..a4e9f991 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, @@ -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'; @@ -22,7 +23,8 @@ 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 { seoMeta } from '../lib/meta'; +import { buildAuthorityCitation } from '../lib/citation'; +import { seoMeta, getRootOrigin } from '../lib/meta'; export function meta({ data, params, matches }: Route.MetaArgs) { const name = data?.authority.name ?? 'Институция'; @@ -58,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) ?? 'https://sigma.midt.bg'; const a = loaderData.authority; const { trend, network, competition } = loaderData; const ct = competition; @@ -90,7 +94,11 @@ 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. - - - Виж документите в ЦАИС ЕОП - - - )} + + Виж документите в ЦАИС ЕОП + + + )} +