Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions apps/web/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.save-btn вече е <button>, а не <a> като .source-cta. Браузърите не наследяват font-family/font-size за <button> по подразбиране, така че текстът на бутона може да изглежда различно от останалата типография на сайта. Добавете font: inherit; в този блок за визуална консистентност.

display: inline-flex;
align-items: center;
gap: var(--s-3);
padding: 11px var(--s-5);
border: 1px solid var(--ink);
background: var(--ink);
Expand All @@ -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;
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a[href]:not([href^='#'])::after ще добави href-а и към вътрешните относителни линкове (напр. в таблиците), давайки частичен път като „ (/contracts/abc)“ вместо пълен URL — по-скоро шум, отколкото полза при печат. Обмислете да ограничите правилото само до абсолютни URL-и, напр. a[href^='http']::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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Селекторът #main вероятно не съвпада с нито един елемент — останалата част от файла таргетира main.narrow (клас, не id). Ако <main> няма id="main", max-width: none при печат няма да се приложи. Моля потвърдете реалния селектор (вероятно main или main.narrow).

.page-header {
max-width: none !important;
padding: 0 !important;
}
}
75 changes: 75 additions & 0 deletions apps/web/app/components/CopyCitationButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useState, useCallback, useEffect, useRef } from 'react';

export function CopyCitationButton({ textToCopy }: { textToCopy: string }) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Липсват тестове за този компонент. citation.ts е добре покрит, но CopyCitationButton съдържа нетривиална логика (успех/грешка при копиране, смяна на състоянието copied, изчистване на timeout при unmount), която не се проверява. Според изискванията за покритие (≥90% за нов код) добавете тестове (напр. с mock на navigator.clipboard и fake timers).

const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);

useEffect(() => {
return () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);

const handleCopy = useCallback(() => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

При липса на navigator.clipboard (несигурен контекст/HTTP, по-стари браузъри) кликът тихо не прави нищо — без обратна връзка към потребителя. Обмислете fallback (напр. document.execCommand('copy') или съобщение за грешка), за да не изглежда бутонът счупен.

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 (
<button
type="button"
onClick={handleCopy}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Достъпност: Бутонът има статичен aria-label="Копирай данните като цитат". Когато aria-label е зададен, екранните четци използват него като достъпно име и обикновено не прочитат децата на бутона — включително aria-live="polite" спана на ред 68. Така промяната на текста към „Копирано!“ най-вероятно няма да бъде обявена на потребители с екранен четец, а именно това е целта на aria-live. Обмислете да преместите aria-live статуса в отделен визуално скрит елемент извън бутона, или да актуализирате aria-label/използвате aria-pressed, когато copied е true.

className={`save-btn ${copied ? 'is-saved' : ''}`}
aria-label="Копирай данните като цитат"
title="Копирай основните факти"
>
{copied ? (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
<span className="save-btn-text" aria-live="polite">
{copied ? 'Копирано!' : 'Копирай'}
</span>
</button>
);
}
9 changes: 6 additions & 3 deletions apps/web/app/components/RiskIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,16 @@ export function RiskIndicators({ contract }: { contract: ContractDetail }) {
if (flag.type === 'eu_no_competition') {
return (
<li key={i}>
<strong>Риск при Еврофондове:</strong> Проектът е финансиран с европейски средства, но е възложен без реална конкуренция (повишен риск според стандартите на ОЛАФ).
<strong>Риск при Еврофондове:</strong> Проектът е финансиран с европейски средства,
но е възложен без реална конкуренция (повишен риск според стандартите на ОЛАФ).
</li>
);
}
if (flag.type === 'no_competition') {
return (
<li key={i}>
<strong>Липса на конкуренция:</strong> Този договор е сключен след допускане на само една оферта.
<strong>Липса на конкуренция:</strong> Този договор е сключен след допускане на само
една оферта.
</li>
);
}
Expand All @@ -56,7 +58,8 @@ export function RiskIndicators({ contract }: { contract: ContractDetail }) {
if (flag.type === 'anomalies') {
return (
<li key={i}>
<strong>Аномалии в данните:</strong> Стойността на договора (или някои от датите) е извън обичайния диапазон и подлежи на допълнителна проверка.
<strong>Аномалии в данните:</strong> Стойността на договора (или някои от датите) е
извън обичайния диапазон и подлежи на допълнителна проверка.
</li>
);
}
Expand Down
110 changes: 110 additions & 0 deletions apps/web/app/lib/citation.test.ts
Original file line number Diff line number Diff line change
@@ -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'),
);
});
});
57 changes: 57 additions & 0 deletions apps/web/app/lib/citation.ts
Original file line number Diff line number Diff line change
@@ -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');
}
10 changes: 7 additions & 3 deletions apps/web/app/lib/riskLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
);
});
});

Expand Down
Loading