Skip to content

feat(web): productivity tools - copy citation button and print styles - #157

Open
Hard-system wants to merge 5 commits into
midt-bg:mainfrom
Hard-system:feat/productivity-tools
Open

feat(web): productivity tools - copy citation button and print styles#157
Hard-system wants to merge 5 commits into
midt-bg:mainfrom
Hard-system:feat/productivity-tools

Conversation

@Hard-system

Copy link
Copy Markdown
Contributor

Какво и защо

Този PR добавя Инструменти за продуктивност, които улесняват работата на разследващи журналисти, анализатори и граждани при извличане и споделяне на данни:

  1. Бутон "Копирай" (Copy as Citation): В страниците за Договори, Компании и Институции е добавен бутон, който с един клик копира в клипборда чисто текстово резюме с най-важните факти за обекта и директен линк. Това спестява досадното ръчно копиране (copy-paste) при писане на статии и бележки.
  2. Изчистен изглед за печат (Print-Friendly View): Добавени са @media print стилове. Когато потребителят принтира страницата или я запазва като PDF (Ctrl+P), навигацията, бутоните и търсачката автоматично се скриват. Това генерира изчистен, официално изглеждащ документ и предпазва таблиците от "сцепване".

Как е тествано

  • Ръчно тествано копирането на договор, компания и институция — текстът се форматира правилно с коректни данни в клипборда.
  • Ръчно тестван изгледът за печат (Print Preview) — излишните интерактивни UI елементи са скрити успешно.
  • Няма промени в базата данни или съществуващата логика; използват се стандартни web API (navigator.clipboard).

@nedda76 nedda76 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Прегледах PR-а на два прохода (copy-citation + print стилове). Идеята е полезна, но има няколко реални бъга — част user-facing — които си струва да се затворят преди merge:

🔴 Цитатът за договор е счупен на ВСЯКА страница на договор. contract.tsx чете c.value.current и c.value.currency, но c.value (ContractValueTimeline) има currentEur/signingEur/estimatedEur — няма current, няма currency. Затова money(c.value.current) → „—", а ${c.value.currency} → буквалното „undefined". Всеки копиран цитат показва „Стойност: — undefined" вместо реалната сума (money() и без това добавя „€", та currency суфиксът е излишен). UI-ят по-горе ползва правилно v.currentEur.

🟠 Бутонът е без стил на екран. Класовете .save-btn / .is-saved / .save-btn-text / .header-actions нямат CSS извън @media print блока — няма базово .save-btn правило никъде. Print блокът само ги скрива; на екран (което виждат 99% от хората) бутонът е суров browser-default, до иначе стилизирания .source-cta. Добави стил или преизползвай .source-cta.

🟠 Няма .catch на clipboard write. navigator.clipboard.writeText(...).then(...) без .catch → при отказ (липса на фокус, отказано позволение, несигурен контекст) има unhandled rejection и нула обратна връзка — потребителят натиска „Копирай", нищо не става, не знае защо. Guard-ът за navigator.clipboard спира hard crash на HTTP, но тогава е тих no-op (без execCommand fallback).

🟡 Логиката за цитата е inline и дублирана в трите маршрута, без тест. Низът се сглобява на ръка в contract.tsx / company.tsx / authority.tsx. Репото има конвенция lib/*.test.ts точно за такава чиста логика — извади buildCitation(entity) и го покрий с тест (точно това щеше да хване №1; company/authority цитатите иначе са коректни).

🟡 Print: .facts-list е мъртъв селектор — компонентът рендира .facts, та break-inside: avoid за панела с ключови показатели е no-op и панелът пак се цепи през страниците.

🟡 Print скрива .source-cta — а линкът към официалния източник (ЦАИС ЕОП) е най-цитируемото на разпечатката; освен това никое print правило не извежда href-овете като текст, та хартиеното копие остава без използваеми URL-и.

🟡 Дребни: setTimeout(… setCopied(false)) не се чисти при unmount (setState-after-unmount, ако се навигира до 2s — а страниците са пълни с линкове); „Копирано!" е само визуално, без aria-live за екранни четци.

Хубаво: company/authority цитатите са добре оформени, линковете резолват правилно, CRLF е чист (LF), print правилата не текат към екрана, бутонът е keyboard-операбилен.

Идеята е добра — но бих помолила №1 (счупения цитат) и №2 (нестилизирания бутон) да се оправят преди merge, плюс .catch и изваден+тестван buildCitation. 🙏

Hard-system added a commit to Hard-system/sigma that referenced this pull request Jun 29, 2026
@Hard-system
Hard-system force-pushed the feat/productivity-tools branch from f15858f to 63ad0ad Compare June 29, 2026 11:25
@nedda76

nedda76 commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Благодаря за PR-а — функционалността е полезна и кодът е чист (SSR guard за navigator.clipboard, cleanup на timeout-а, добро тестово покритие на citation builder-ите). Прегледах го; ето как да мине CI и две неща за дооправяне.

Защо пада CI и как се оправя

Падащата стъпка е Lint (pnpm lint = prettier --check ., вече блокираща). Маркира 5 файла, от които 3 са от този PR:

  • apps/web/app/app.css
  • apps/web/app/components/CopyCitationButton.tsx
  • apps/web/app/lib/citation.test.ts

Другите два (apps/web/app/components/RiskIndicators.tsx, apps/web/app/lib/riskLogic.test.ts) не са от теб — това е заварен prettier дълг на main, който се оправя в #174. Понеже lint тече за целия repo, той чупи CI на всеки отворен PR, докато #174 не влезе.

Стъпки:

  1. pnpm format (форматира твоите 3 файла; ще пипне и горните 2, което е ок).
  2. Commit + push.
  3. Идеално — rebase върху main, след като docs(etl): target architecture and phased plan (RFC) #174 влезе, за да не носиш чужди промени.

Имай предвид, че typecheck и test се изпълняват след lint, тоест още не са текли на този PR. След като lint мине, ще се пуснат — гледай и тях.

Две неща за дооправяне

1. ЕИК в цитата за компания заобикаля правилото за показване (важно за поверителност).
В apps/web/app/lib/citation.ts buildCompanyCitation включва реда ЕИК: ${c.eik || 'Няма'}, тоест само при наличие на c.eik. Но навсякъде другаде в приложението ЕИК се показва само при c.hasEik (= eik_valid === 1) — виж routes/company.tsx:120 ({c.hasEik && c.eik && (…)}) и routes/companies.tsx:71. Така компания с наличен, но невалиден ЕИК (hasEik = false, но c.eik непразно) се показва като „без ЕИК" в UI-я, а цитатът копира суровия ЕИК.

Освен това профилите на единични физически лица са noindex (routes/company.tsx:51-55); вграждането на ЕИК в текст за копиране подкопава точно тази защита (свързано с #173).

Предложение: подай hasEik на builder-а и го пускай по същото правило (c.hasEik && c.eik), иначе „Няма".

2. Линкът в цитата е hardcode-нат https://sigma.midt.bg.
И трите builder-а долепят https://sigma.midt.bg/... (citation.ts редове 12, 26, 40). В dev/staging/preview това дава грешен линк, а при смяна на канонния домейн тихо се разминава. Вече има хелпер за това — getRootOrigin(matches) в apps/web/app/lib/meta.ts:10, който чете origin от root loader-а (root.tsx:52). Подай origin-а на builder-ите вместо hardcode.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

#157, #174 и #178 правят идентичния prettier reflow на RiskIndicators.tsxriskLogic.test.ts). #178 е специалният форматиращ PR, който владее тези редове. Предложение: първо #178, после #157 и #174 rebase-ват и махат промените по RiskIndicators.tsx/riskLogic.test.ts — извън обхвата им са (AGENTS.md). Иначе двата от трите ще конфликтват на точно тези редове.

(За #157 отделно, ако постваме: линковете в citation.ts са коректни — параметърът :eik носи slug през bidderIdFromSlug, съвпада с companies.tsx:138 / authorities.tsx:117; CopyCitationButton е SSR-безопасен. Дребно: Стойност ползва value.currentEur — струва си да съвпада с показаното на страницата на договора.)

@ydimitrof

Copy link
Copy Markdown
Contributor

Преглед на PR #157 — Инструменти за продуктивност (copy-citation + print стилове)

Благодаря за PR-а и за старателната работа по обратната връзка — прегледах текущото състояние ред по ред, с фокус върху сигурност, целостта на данните и съответствие с OWASP, и сверих имплементацията със заявеното в описанието. Всички основни забележки от предишните проходи вече са затворени. По-долу е обобщението.

Сигурност (OWASP) — чисто

  • Инжекции (A03). citation.ts е чиста функция за низове; резултатът се предава единствено на navigator.clipboard.writeText(...) като обикновен текст — никъде не се рендира като HTML и не се ползва dangerouslySetInnerHTML. Няма SQL, няма промени по заявки към базата, няма нов вход, който да достига до слой за данни. Не намерих вектор за SQL/XSS/командна инжекция.
  • Изтичане на данни / поверителност (A01, A04). Критичната забележка за ЕИК е адресирана коректно: buildCompanyCitation вече пуска ЕИК само при c.hasEik && c.eik, иначе „Няма" — точно правилото, което ползва и останалата част от приложението (company.tsx / companies.tsx). Така компания с наличен, но невалиден ЕИК не изнася суровата стойност в клипборда. Останалите полета в цитата (име, агрегирани суми, линк) са публично видими на самата страница.
  • Секрети / зависимости / малициозен код. Няма нови зависимости, няма hardcode-нати тайни, няма обфускация, промени по URL-и или подозрителни външни извиквания. rel="noopener" на външния линк към ЕОП е запазен. Нищо, което да застраши проекта.

Целост на данните — коригирано

  • Счупеният цитат за договор е поправен: citation.ts вече чете c.value.currentEur (съвпада с ContractValueTimeline), а излишният currency суфикс е премахнат. Логиката е изведена в lib/citation.ts и покрита с тест — локално 5/5 минават (вкл. случая с currentEur: null → „—").
  • Print селекторите вече са живи: .facts (не мъртвото .facts-list), #main, .page-header, .site-header/.site-nav/.search-drawer/.site-footer — всички съществуват в кода. .save-btn се скрива при печат, но .source-cta остава, а a[href]::after извежда URL-ите на хартия. Бутонът вече има стил и на екран (.source-cta, .save-btn), timeout-ът се чисти при unmount, има .catch и aria-live за екранни четци.

Съответствие с описанието

Имплементацията покрива точно двете заявени неща — бутон „Копирай" на страниците за Договори, Компании и Институции, и изчистен изглед за печат. Няма разширяване на обхвата към логика или база.

Дребни бележки (незадължителни)

  1. Извън обхват (AGENTS.md). PR-ът все още носи prettier-reflow по RiskIndicators.tsx и riskLogic.test.ts — това е заварен дълг на main, който се владее от style(web): fix prettier formatting in risk indicator files #178/docs(etl): target architecture and phased plan (RFC) #174. Моля, след като style(web): fix prettier formatting in risk indicator files #178 влезе, направете rebase и махнете тези редове, за да няма конфликт на точно тези места. Това е единственото, което бих помолила да се дооправи преди merge.
  2. origin fallback-ът ?? 'https://sigma.midt.bg' е приемлив (задейства се само при липсваща root loader data, напр. error boundary), но си струва кратък коментар защо е канонично hardcode-нат, за да не се разчете като регресия спрямо предишната забележка.
  3. При отказан/несигурен clipboard контекст поведението е тих console.error без визуална обратна връзка — не е блокер на HTTPS, но toast/role="alert" би подобрил UX.

Нищо от горните три не е свързано със сигурност или коректност на данните.

Вердикт: ОДОБРЯВАМ — след премахване на извън-обхватните промени по RiskIndicators.tsx/riskLogic.test.ts при rebase върху #178. Няма блокери по сигурност, данни или OWASP; CI е зелено, тестовете минават.

@ydimitrof ydimitrof left a comment

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.

Преглед на PR: продуктивни инструменти — бутон за копиране на цитат и стилове за печат

Като цяло чист, добре структуриран PR с ясна цел. Логиката за изграждане на цитати (citation.ts) е изнесена като чисти функции и е добре покрита с unit тестове. Промените по CSS (header-actions, save-btn, print styles) са разумни и минимални. Не открих проблеми със сигурността: няма hardcoded secrets, няма нови зависимости, rel="noopener" е запазен за външния линк, а navigator.clipboard.writeText не носи риск от инжекция.

Забележка за средата: Прегледът е базиран само на diff-а. Няколко елемента не можах да проверя срещу реалния код (напр. дали getRootOrigin е експортиран от ../lib/meta, дали типът ContractDetail съдържа bidder.displayName/value.currentEur, и дали <main> елементът има id="main"). Моля, потвърдете ги при merge.

Основни констатации

  1. Липсват тестове за CopyCitationButton.tsx — новият компонент съдържа нетривиална логика (clipboard, timeout cleanup, copied състояние), но няма нито един тест. Според изискванията за покритие (≥90% за нов код) това е блокиращо.
  2. Достъпност: потвърждението „Копирано!“ най-вероятно няма да бъде прочетено от екранни четци, защото aria-label на бутона потиска четенето на вътрешния aria-live регион.
  3. Print селекторът #main вероятно не съвпада с нищо (съществуващият CSS използва main.narrow, т.е. клас, а не id).
  4. Няколко дребни подобрения (тих провал при липсваща clipboard поддръжка, font: inherit за <button>, дублиран fallback за origin).

Silno препоръчвам добавяне на тестове за компонента и коригиране на a11y проблема преди merge. Останалите са дребни. Оценка: ~8.5/10 — не одобрявам единствено заради липсата на тестове за новия компонент и достъпността.

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.

}, []);

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') или съобщение за грешка), за да не изглежда бутонът счупен.

@@ -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).

Comment thread apps/web/app/app.css
}

/* 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).

Comment thread apps/web/app/app.css
/* 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; в този блок за визуална консистентност.

Comment thread apps/web/app/app.css
}

/* 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.


export default function Contract({ loaderData }: Route.ComponentProps) {
const matches = useMatches();
const origin = getRootOrigin(matches) ?? 'https://sigma.midt.bg';

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.

Fallback-ът getRootOrigin(matches) ?? 'https://sigma.midt.bg' е повторен дословно в contract.tsx, company.tsx и authority.tsx. За да избегнете дублиране (и разминаване при бъдеща смяна на домейна), помислете да изнесете fallback стойността като константа/помощна функция до getRootOrigin в lib/meta.

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Прегледах citation.ts + принт-стиловете. Кодът е чист: citation билдърите правят plain-text join към navigator.clipboard — няма HTML/innerHTML, тъй че няма XSS/инжекция; стойностите идват от типизирани данни, money()/count() са безопасни форматери; buildContractCitation чете current_value_eur (поправеното крак на #257), не наполовинения amount_eur. @media print скриването на nav/бутони/търсачка е презентационно, ниско-рисково.

Блокер за merge (не за кода): PR-ът е в конфликт (mergeable: dirty). Редактира монолитния app.css (ред ~676, .source-cta/main.narrow), но на main app.css вече е само @import манифест — тези правила са преместени в styles/*.css. Затова хункът не се прилага. Нужен е rebase: пренеси print-стиловете + новите .header-actions/.save-btn правила в съответния styles/*.css (вероятно components.css/pages.css), не в app.css.

Иначе фийчърът е издържан. След rebase-а върху текущата CSS структура може да върви.

@nedda76

nedda76 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Този клон е в конфликт с main, тъй че към момента не може да се ревюира — дифът, който GitHub показва, вече не отговаря на това, което би влязло. Ще го пребазираш ли върху актуалния main (или merge на main в клона) и да разрешиш конфликтите? След това веднага го поглеждам. Благодаря! 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants